fix(plugins): stabilize plugin integration

This commit is contained in:
Jinpy authored and GitHub committed 2026-09-14 21:03:47 +08:00
1 parent b70d6da227
commit 3d1b84831f
76 files changed
+3846 -431

No files matched your search

@@ -1443,8 +1443,8 @@ watch(driverStoreTab, (tab) => {
<div class="driver-store-scroll flex-1 min-h-0 overflow-y-auto">
<div class="driver-store-container max-w-4xl mx-auto px-6 py-6">
<Tabs v-model="driverStoreTab" default-value="agent" class="driver-store-tabs-root">
<div class="driver-store-header flex items-center justify-between">
<TabsList class="driver-store-tabs grid w-[360px] grid-cols-3">
<div class="driver-store-header flex flex-wrap items-center justify-between gap-2">
<TabsList class="driver-store-tabs grid w-full max-w-[380px] grid-cols-3">
<TabsTrigger value="agent" class="gap-1.5 relative">
{{ t("driverStore.agentDrivers") }}
<span v-if="agentTabUpdateCount > 0" class="inline-block h-2 w-2 rounded-full bg-red-500" />
@@ -2263,7 +2263,7 @@ watch(driverStoreTab, (tab) => {
.driver-store-tabs {
display: grid !important;
width: 360px !important;
width: 480px !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
@@ -92,6 +92,7 @@ import { assertCompleteDatabaseCategories, databaseSelectionForCategory } from "
import { loadConnectionPickerView, saveConnectionPickerView, type DbPickerView } from "@/lib/connection/connectionPickerViewPreference";
import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv";
import { normalizeRabbitmqAddresses, parseRabbitmqAddress } from "@/lib/connection/rabbitmqAddresses";
import { pluginFieldIsRequired, pluginFieldIsVisible } from "@/lib/plugins/pluginFieldConditions";
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
import { driverInstallProgressChannel, driverInstallProgressPercent, isDriverInstallProgressForOperation, requestAgentInstallCancellation, resolveAgentInstallOutcome, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityConfig, sqlServerUsesLegacyCompatibility, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
@@ -255,6 +256,8 @@ type LegacyTransportFields = {
type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields;
type ConnectionForm = Omit<ConnectionConfig, "id">;
type ConnectionTestState = ConnectionTestResult & { ok: boolean; scope?: "connection" | "ssh" };
type PluginActionStatus = { ok: boolean; message: string };
type SaveConnectionOptions = { connectAfterSave?: boolean; closeOnSuccess?: boolean };
const { t, locale: appLocale } = useI18n();
const { toast } = useToast();
@@ -328,11 +331,9 @@ const connectionErrorDetail = ref("");
const testResultCopied = ref(false);
const connectionErrorCopied = ref(false);
const editingId = ref<string | null>(null);
const installedPlugins = ref<InstalledPlugin[]>([]);
const pluginFormValues = ref<Record<string, PluginFormFieldValue>>({});
const pluginLoadError = ref("");
const runningPluginActionId = ref<string | null>(null);
type PluginActionStatus = { ok: boolean; message: string };
const pluginActionStatus = ref<PluginActionStatus | null>(null);
const draftTestConnectionId = ref(uuid());
const showVisibleDatabasesDialog = ref(false);
@@ -365,6 +366,7 @@ const isLoadingVisibleSchemas = ref(false);
const visibleSchemaNames = ref<string[]>([]);
const visibleSchemaInitialSelection = ref<string[]>([]);
const visibleSchemaError = ref("");
const installedPlugins = ref<InstalledPlugin[]>([]);
let testRunId = 0;
let unlistenAgentInstallProgress: (() => void) | null = null;
@@ -759,6 +761,13 @@ const keepaliveEnabled = computed({
const selectedTransportLayerId = ref<string | null>(null);
const draggedTransportLayerId = ref<string | null>(null);
const selectedType = ref("mysql");
const pluginRegistry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
const pluginConnectionProviders = computed(() => pluginRegistry.value.listConnectionProviders());
const selectedPluginProvider = computed(() => pluginProviderEntryForOption(selectedType.value));
const selectedPluginIcon = computed(() => (selectedPluginProvider.value ? pluginConnectionProviderIcon(selectedPluginProvider.value) : undefined));
const isPluginConnection = computed(() => form.value.db_type === "plugin");
const pluginFooterActions = computed<PluginConnectionAction[]>(() => (selectedPluginProvider.value ? pluginConnectionActionsForDialog(selectedPluginProvider.value.contribution, !!editingId.value) : []));
const pluginFooterBusy = computed(() => isTesting.value || isSaving.value || !!runningPluginActionId.value);
const customDriverName = ref("");
const mongoUseUrl = ref(false);
const jdbcDriverPathsInput = ref("");
@@ -1190,13 +1199,6 @@ function selectedProfile() {
return driverProfiles[profile] ?? driverProfiles.mysql;
}
const isPluginConnection = computed(() => form.value.db_type === "plugin");
const pluginConnectionProviders = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value).listConnectionProviders());
const selectedPluginProvider = computed(() => pluginProviderEntryForOption(selectedType.value));
const selectedPluginIcon = computed(() => (selectedPluginProvider.value ? pluginConnectionProviderIcon(selectedPluginProvider.value) : undefined));
const pluginFooterActions = computed<PluginConnectionAction[]>(() => (selectedPluginProvider.value ? pluginConnectionActionsForDialog(selectedPluginProvider.value.contribution, !!editingId.value) : []));
const pluginFooterBusy = computed(() => isTesting.value || isSaving.value || !!runningPluginActionId.value);
function pluginProviderEntry(pluginId: string, providerId: string) {
return pluginConnectionProviders.value.find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId) || null;
}
@@ -1206,62 +1208,6 @@ function pluginProviderEntryForOption(value: string) {
return target ? pluginProviderEntry(target.pluginId, target.providerId) : null;
}
function applyPluginProvider(value: string, preserveConnectionFields = false, existing?: ConnectionConfig): boolean {
const target = parsePluginConnectionProviderOptionValue(value);
if (!target) return false;
const entry = pluginProviderEntry(target.pluginId, target.providerId);
if (!entry) return false;
selectedType.value = value;
if (!preserveConnectionFields) {
form.value = {
...defaultForm(),
name: entry.contribution.label,
db_type: "plugin",
driver_profile: "plugin",
driver_label: entry.contribution.label,
host: "",
port: 0,
username: "",
password: "",
query_timeout_secs: 60,
};
} else {
form.value.db_type = "plugin";
form.value.driver_profile = "plugin";
form.value.driver_label = entry.contribution.label;
}
pluginFormValues.value = pluginConnectionFormValues(entry.contribution, existing);
if (!preserveConnectionFields) {
const nameField = entry.contribution.fields.find((field) => field.binding === "name");
const defaultName = nameField ? pluginFormValues.value[nameField.key] : undefined;
if (typeof defaultName === "string" && defaultName.trim()) form.value.name = defaultName.trim();
}
connectionUrlInput.value = "";
appliedConnectionUrlInput.value = "";
return true;
}
async function loadInstalledPlugins() {
pluginLoadError.value = "";
try {
installedPlugins.value = await api.listPlugins();
} catch (cause) {
installedPlugins.value = [];
pluginLoadError.value = cause instanceof Error ? cause.message : String(cause);
}
}
function applyRequestedPluginProvider(target: PluginCenterFocus | null | undefined) {
if (!target?.pluginId || !target.providerId) return false;
const value = pluginConnectionProviderOptionValue(target.pluginId, target.providerId);
if (!applyPluginProvider(value)) return false;
selectedDbCategory.value = "plugins";
dialogStep.value = "config";
configTab.value = "connection";
return true;
}
function mqExtraRecord(config?: Partial<MqAdminConfig>): Record<string, unknown> {
const extra = config?.extra;
return extra && typeof extra === "object" && !Array.isArray(extra) ? (extra as Record<string, unknown>) : {};
@@ -2589,6 +2535,62 @@ function applyProfile(val: string, preserveConnectionFields = false) {
}
}
function applyPluginProvider(value: string, preserveConnectionFields = false, existing?: ConnectionConfig): boolean {
const target = parsePluginConnectionProviderOptionValue(value);
if (!target) return false;
const entry = pluginProviderEntry(target.pluginId, target.providerId);
if (!entry) return false;
selectedType.value = value;
if (!preserveConnectionFields) {
form.value = {
...defaultForm(),
name: entry.contribution.label,
db_type: "plugin",
driver_profile: "plugin",
driver_label: entry.contribution.label,
host: "",
port: 0,
username: "",
password: "",
query_timeout_secs: 60,
};
} else {
form.value.db_type = "plugin";
form.value.driver_profile = "plugin";
form.value.driver_label = entry.contribution.label;
}
pluginFormValues.value = pluginConnectionFormValues(entry.contribution, existing);
if (!preserveConnectionFields) {
const nameField = entry.contribution.fields.find((field) => field.binding === "name");
const defaultName = nameField ? pluginFormValues.value[nameField.key] : undefined;
if (typeof defaultName === "string" && defaultName.trim()) form.value.name = defaultName.trim();
}
connectionUrlInput.value = "";
appliedConnectionUrlInput.value = "";
return true;
}
async function loadInstalledPlugins() {
pluginLoadError.value = "";
try {
installedPlugins.value = await api.listPlugins();
} catch (cause) {
installedPlugins.value = [];
pluginLoadError.value = cause instanceof Error ? cause.message : String(cause);
}
}
function applyRequestedPluginProvider(target: PluginCenterFocus | null | undefined) {
if (!target?.pluginId || !target.providerId) return false;
const value = pluginConnectionProviderOptionValue(target.pluginId, target.providerId);
if (!applyPluginProvider(value)) return false;
selectedDbCategory.value = "plugins";
dialogStep.value = "config";
configTab.value = "connection";
return true;
}
function switchOceanbaseMode(mode: "mysql" | "oracle") {
oceanbaseSubMode.value = mode;
if (mode === "mysql") {
@@ -2626,7 +2628,7 @@ watch(
name: config.name,
note: config.note || "",
db_type: oceanbasePatch?.db_type || profileConfig?.type || config.db_type,
driver_profile: oceanbasePatch?.driver_profile || config.driver_profile || profile,
driver_profile: config.db_type === "plugin" ? "plugin" : oceanbasePatch?.driver_profile || config.driver_profile || profile,
driver_label: config.driver_label || oceanbasePatch?.driver_label || driverProfiles[profile]?.label || config.db_type,
url_params: config.url_params || "",
agent_java_options: config.agent_java_options || [],
@@ -2770,6 +2772,7 @@ watch(
productionProtectionEnabled.value = false;
selectedTransportLayerId.value = null;
selectedType.value = "mysql";
pluginFormValues.value = {};
customDriverName.value = "";
resetMqFields();
resetCassandraTlsFields(undefined);
@@ -3013,10 +3016,10 @@ const dbCategoryMetadata: Array<{ key: DbCategoryKey; titleKey: string }> = [
{ key: "registry_config", titleKey: "connection.databaseCategoryRegistryConfig" },
];
function jdbcProductCategory(profileId: string): DbCategoryKey {
const category = dbCategoryMetadata.find(({ key }) => jdbcProductProfileIdsForCategory(key).includes(profileId))?.key;
if (!category) throw new Error(`JDBC product profile ${profileId} has no connection picker category`);
return category;
function jdbcProductCategory(profileId: string): ConnectionProfileCategory {
const category: DbCategoryKey | undefined = dbCategoryMetadata.find(({ key }) => jdbcProductProfileIdsForCategory(key).includes(profileId))?.key;
if (category !== "plugins" && category) return category;
throw new Error(`JDBC product profile ${profileId} has no connection picker category`);
}
// `influxdb3` is presented as a version option inside the InfluxDB card
@@ -3092,7 +3095,7 @@ const selectedDbOptionIsVisible = computed(() => visibleDbCategories.value.some(
function selectDbCategory(category: DbCategoryKey) {
selectedDbCategory.value = category;
dbSearchQuery.value = "";
const categoryOptions = dbCategoryDefinitions.find((definition) => definition.key === category)?.optionValues ?? [];
const categoryOptions = dbCategories.value.find((definition) => definition.key === category)?.options.map((option) => option.value) ?? [];
const nextSelection = databaseSelectionForCategory(selectedType.value, categoryOptions);
if (nextSelection && nextSelection !== selectedType.value) onDbTypeChange(nextSelection);
}
@@ -3573,6 +3576,32 @@ const pluginActionStatusMessage = computed(() => {
if (!pluginActionStatus.value) return "";
return pluginActionStatus.value.ok ? pluginActionStatus.value.message : translateBackendError(t, pluginActionStatus.value.message);
});
const agentInstallPercent = computed(() => driverInstallProgressPercent(agentInstallProgress.value));
const agentInstallProgressLabel = computed(() => {
const progress = agentInstallProgress.value;
if (agentInstallError.value) return t("connection.driverInstall.statusFailed");
if (!agentInstallRunning.value) return t("connection.driverInstall.statusWaiting");
if (!progress) return t("connection.driverInstall.statusPreparing");
if (progress.step === "jre-extract") return t("connection.driverInstall.statusExtractingJre");
const label = progress.step === "jre" ? t("connection.driverInstall.stepJre") : progress.step === "driver" ? t("connection.driverInstall.stepDriver") : progress.step || t("connection.driverInstall.stepDefault");
if (!progress.total) return `${label}...`;
return `${label} ${formatInstallSize(progress.downloaded ?? 0)} / ${formatInstallSize(progress.total)} (${agentInstallPercent.value ?? 0}%)`;
});
const canCloseAgentInstallDialog = computed(() => !agentInstallRunning.value || !!agentInstallError.value);
const sqlServerDriverMode = computed<"auto" | "legacy">(() => (sqlServerUsesLegacyCompatibility(form.value) ? "legacy" : "auto"));
const shouldUseWideConnectionDialog = computed(
() => dialogStep.value === "config" && (canChooseVisibleDatabases.value || canChooseVisibleNacosNamespaces.value || (canChooseVisibleSchemas.value && !visibleFilterUsesSchemas.value) || (selectedPluginProvider.value?.contribution.fields.length ?? 0) >= 6),
);
const connectionDialogContentClass = computed(() => {
if (dialogStep.value === "select") return "connection-dialog-content--picker sm:h-[720px] sm:max-w-[880px]";
const widthClass = shouldUseWideConnectionDialog.value ? "connection-dialog-content--wide sm:max-w-[660px]" : "connection-dialog-content--standard sm:max-w-[560px]";
const scrollableAdvancedNacos = form.value.db_type === "nacos" && configTab.value === "advanced";
return `${widthClass} connection-dialog-content--config${scrollableAdvancedNacos ? " connection-dialog-content--scrollable" : ""}`;
});
const connectionLabelClass = "justify-self-start text-left";
const connectionLabelSmallClass = `${connectionLabelClass} text-xs`;
const connectionLabelTopClass = `${connectionLabelClass} mt-2`;
const connectionLabelSmallPaddedClass = `${connectionLabelClass} pt-2 text-xs`;
function pluginFieldValue(field: PluginFormField): PluginFormFieldValue {
if (field.binding === "name") return form.value.name;
@@ -3584,6 +3613,19 @@ function pluginFieldHasValue(field: PluginFormField): boolean {
return typeof value === "string" ? value.trim().length > 0 : value !== undefined;
}
function pluginFieldValueByKey(key: string): PluginFormFieldValue {
const field = selectedPluginProvider.value?.contribution.fields.find((candidate) => candidate.key === key);
return field ? pluginFieldValue(field) : undefined;
}
function pluginFieldVisible(field: PluginFormField): boolean {
return pluginFieldIsVisible(field, pluginFieldValueByKey, (key) => selectedPluginProvider.value?.contribution.fields.find((candidate) => candidate.key === key));
}
function pluginFieldRequired(field: PluginFormField): boolean {
return pluginFieldIsRequired(field, pluginFieldValueByKey);
}
function pluginActionLabel(action: PluginConnectionAction): string {
if (action.label) return action.label;
if (action.kind === "test") return t("connection.test");
@@ -3633,34 +3675,11 @@ function applyPluginActionFieldValues(values: Record<string, PluginFormFieldValu
}
pluginFormValues.value = nextValues;
}
const agentInstallPercent = computed(() => driverInstallProgressPercent(agentInstallProgress.value));
const agentInstallProgressLabel = computed(() => {
const progress = agentInstallProgress.value;
if (agentInstallError.value) return t("connection.driverInstall.statusFailed");
if (!agentInstallRunning.value) return t("connection.driverInstall.statusWaiting");
if (!progress) return t("connection.driverInstall.statusPreparing");
if (progress.step === "jre-extract") return t("connection.driverInstall.statusExtractingJre");
const label = progress.step === "jre" ? t("connection.driverInstall.stepJre") : progress.step === "driver" ? t("connection.driverInstall.stepDriver") : progress.step || t("connection.driverInstall.stepDefault");
if (!progress.total) return `${label}...`;
return `${label} ${formatInstallSize(progress.downloaded ?? 0)} / ${formatInstallSize(progress.total)} (${agentInstallPercent.value ?? 0}%)`;
});
const canCloseAgentInstallDialog = computed(() => !agentInstallRunning.value || !!agentInstallError.value);
const sqlServerDriverMode = computed<"auto" | "legacy">(() => (sqlServerUsesLegacyCompatibility(form.value) ? "legacy" : "auto"));
const shouldUseWideConnectionDialog = computed(() => dialogStep.value === "config" && (canChooseVisibleDatabases.value || canChooseVisibleNacosNamespaces.value || (canChooseVisibleSchemas.value && !visibleFilterUsesSchemas.value)));
const connectionDialogContentClass = computed(() => {
if (dialogStep.value === "select") return "connection-dialog-content--picker sm:h-[720px] sm:max-w-[880px]";
const widthClass = shouldUseWideConnectionDialog.value ? "connection-dialog-content--wide sm:max-w-[660px]" : "connection-dialog-content--standard sm:max-w-[560px]";
const scrollableAdvancedNacos = form.value.db_type === "nacos" && configTab.value === "advanced";
return `${widthClass} connection-dialog-content--config${scrollableAdvancedNacos ? " connection-dialog-content--scrollable" : ""}`;
});
const connectionLabelClass = "justify-self-start text-left";
const connectionLabelSmallClass = `${connectionLabelClass} text-xs`;
const connectionLabelTopClass = `${connectionLabelClass} mt-2`;
const connectionLabelSmallPaddedClass = `${connectionLabelClass} pt-2 text-xs`;
const hasRequiredConnectionTarget = computed(() => {
if (isPluginConnection.value) {
const provider = selectedPluginProvider.value?.contribution;
return !!provider && provider.fields.every((field) => !field.required || pluginFieldHasValue(field));
const entry = selectedPluginProvider.value;
return !!entry && entry.contribution.fields.every((field) => !pluginFieldVisible(field) || !pluginFieldRequired(field) || pluginFieldHasValue(field));
}
if (form.value.db_type === "mq") {
if (mqSystemKind.value === "kafka") return mqKafkaConnectionSource.value === "zookeeper" ? !!mqKafkaZooKeeperServers.value.trim() : !!mqKafkaBootstrapServers.value.trim();
@@ -4061,11 +4080,12 @@ function connectionConfigForSshTunnelTest(id: string): ConnectionConfig {
}
function connectionConfigForSubmit(id: string, generatedName = "", validatePluginRequired = true): ConnectionConfig {
let config: LegacyConnectionConfig;
if (isPluginConnection.value) {
const entry = selectedPluginProvider.value;
if (!entry) throw new Error(pluginLoadError.value || t("connection.pluginProviderUnavailable"));
if (validatePluginRequired) {
const missingField = entry.contribution.fields.find((field) => field.required && !pluginFieldHasValue(field));
const missingField = entry.contribution.fields.find((field) => pluginFieldVisible(field) && pluginFieldRequired(field) && !pluginFieldHasValue(field));
if (missingField) throw new Error(t("connection.pluginRequiredField", { field: missingField.label }));
}
const values = { ...pluginFormValues.value };
@@ -4073,10 +4093,23 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
if (field.binding === "name") values[field.key] = form.value.name;
}
const existing = props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id === entry.plugin.manifest.id && props.editConfig.plugin_connection_provider === entry.contribution.id ? props.editConfig : undefined;
const config = buildPluginConnectionConfig(entry.plugin.manifest.id, entry.contribution, values, existing);
return { ...formValueForSubmit(), ...config, id, name: form.value.name.trim() || generatedName.trim() || config.name, note: form.value.note?.trim() || undefined };
config = buildPluginConnectionConfig(entry.plugin.manifest.id, entry.contribution, values, existing) as LegacyConnectionConfig;
config.id = id;
config.name = form.value.name.trim() || config.name;
config.note = form.value.note;
config.color = form.value.color;
config.transport_layers = form.value.transport_layers || [];
config.connect_timeout_secs = form.value.connect_timeout_secs;
config.query_timeout_secs = form.value.query_timeout_secs;
config.idle_timeout_secs = form.value.idle_timeout_secs;
config.keepalive_interval_secs = form.value.keepalive_interval_secs;
config.read_only = form.value.read_only;
config.save_password = form.value.save_password;
config.is_production = form.value.is_production;
config.production_databases = form.value.production_databases;
} else {
config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
}
const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
config.database_info = undefined;
config.database = normalizeStoredConnectionDatabase(config.db_type, config.database);
config.note = config.note?.trim() || undefined;
@@ -4286,6 +4319,9 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
setGaussdbTargetServerType(config, targetServerType);
setGaussdbCountQueryDop(config, countQueryDop);
} else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile)) {
// Plugin connections keep `external_config`: the manifest-driven form
// fields land there via buildPluginConnectionConfig. Only the built-in
// drivers without an external-config payload get wiped here.
config.external_config = undefined;
}
if (config.db_type === "mongodb" && !mongoUseUrl.value) {
@@ -4382,12 +4418,6 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
config.port = firstEndpoint.port;
}
config.database = undefined;
config.ca_cert_path = config.ca_cert_path?.trim() || "";
config.client_cert_path = config.client_cert_path?.trim() || "";
config.client_key_path = config.client_key_path?.trim() || "";
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
}
if (config.db_type === "etcd") {
config.etcd_endpoints = normalizeEndpointLines(config.etcd_endpoints || "");
@@ -4402,7 +4432,14 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
} else if (form.value.db_type !== "consul" && config.db_type !== "zookeeper" && config.db_type !== "elasticsearch" && config.db_type !== "easysearch") {
} else if (config.db_type === "zookeeper") {
config.etcd_endpoints = undefined;
config.client_cert_path = config.client_cert_path?.trim() || "";
config.client_key_path = config.client_key_path?.trim() || "";
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
} else if (form.value.db_type !== "consul" && config.db_type !== "elasticsearch" && config.db_type !== "easysearch") {
config.etcd_endpoints = undefined;
config.client_cert_path = undefined;
config.client_key_path = undefined;
@@ -4751,6 +4788,7 @@ function resetTestState() {
isTesting.value = false;
isTestingSshTunnel.value = false;
testResult.value = null;
pluginActionStatus.value = null;
clearTestedConnectionInfo();
showConnectionErrorDialog.value = false;
connectionErrorRawDetail.value = "";
@@ -5290,6 +5328,7 @@ function resetForm(options: { preservePickerState?: boolean } = {}) {
selectedTransportLayerId.value = null;
draggedTransportLayerId.value = null;
selectedType.value = "mysql";
pluginFormValues.value = {};
customDriverName.value = "";
mongoUseUrl.value = false;
resetMqFields();
@@ -5425,6 +5464,19 @@ watch(
resetForm();
if (props.prefillConfig) applyConnectionPrefill(props.prefillConfig);
}
void loadInstalledPlugins().then(() => {
if (!open.value) return;
if (props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id && props.editConfig.plugin_connection_provider) {
const entry = pluginProviderEntry(props.editConfig.plugin_id, props.editConfig.plugin_connection_provider);
if (entry) {
selectedType.value = pluginConnectionProviderOptionValue(props.editConfig.plugin_id, props.editConfig.plugin_connection_provider);
selectedDbCategory.value = "plugins";
pluginFormValues.value = pluginConnectionFormValues(entry.contribution, props.editConfig);
}
} else if (!props.prefillConfig) {
applyRequestedPluginProvider(props.pluginProvider);
}
});
if (!props.prefillConfig?.oneTime) {
void loadJdbcDrivers();
void loadAgentDrivers();
@@ -5713,83 +5765,74 @@ async function persistConnectionNoteVisibilityDraft() {
await persistConnectionNoteVisibilityDraftState(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes, (value) => settingsStore.updateEditorSettingsAndPersist({ sidebarShowConnectionNotes: value }));
}
async function save(options: { connectAfterSave?: boolean; closeOnSuccess?: boolean } = {}) {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (isSaving.value) return;
function startSavedConnection(config: ConnectionConfig) {
emit("connectStarted", config.name);
void store
.connect(config)
.then(() => {
emit("connectSucceeded", config.name);
})
.catch((e: any) => {
const message = String(e?.message || e);
if (message.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
// Keep failed one-time connections available for retry and error inspection.
// They are still removed by connectionStore.disconnect after a successful session.
emit("connectFailed", appendConnectionErrorHints(config, mongodbAuthFailureHint(message), t));
});
}
async function save(options: SaveConnectionOptions = {}): Promise<boolean> {
if (!ensureConnectionHostResolvedFromUrl()) return false;
if (isSaving.value) return false;
if (!hasNacosNamespaceScopeForSave()) {
testResult.value = null;
await openVisibleNacosNamespacesPicker();
return;
return false;
}
const connectAfterSave = options.connectAfterSave ?? (!editingId.value && form.value.db_type !== "jdbc");
const wasEditing = !!editingId.value;
const connectAfterSave = options.connectAfterSave ?? (!wasEditing && !isJdbcConnection.value);
const closeOnSuccess = options.closeOnSuccess ?? true;
const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value;
isSaving.value = true;
let connectionSaved = false;
try {
let savedConfig: ConnectionConfig;
if (editingId.value) {
const updated = withSavedDatabaseInfo(connectionConfigForSubmit(editingId.value), databaseInfoForSave);
await ensureRequiredAgentDriverInstalled(updated);
await ensureRequiredGaussdbMJdbcRuntime(updated);
await persistGlobalTimeoutDrafts();
await store.updateConnection(updated);
savedConfig = updated;
connectionSaved = true;
await persistConnectionNoteVisibilityDraft();
store.stopEditing();
if (connectAfterSave) {
if (closeOnSuccess) {
open.value = false;
await nextTick();
}
emit("connectStarted", updated.name);
void store
.connect(updated)
.then(() => {
emit("connectSucceeded", updated.name);
})
.catch((e: any) => {
const message = String(e?.message || e);
if (message.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
emit("connectFailed", appendConnectionErrorHints(updated, mongodbAuthFailureHint(message), t));
});
return;
}
} else {
const config = withSavedDatabaseInfo(connectionConfigForSubmit(draftTestConnectionId.value), databaseInfoForSave);
await ensureRequiredAgentDriverInstalled(config);
await ensureRequiredGaussdbMJdbcRuntime(config);
await persistGlobalTimeoutDrafts();
await store.addConnection(config, selectedConnectionGroupId.value);
savedConfig = config;
connectionSaved = true;
await persistConnectionNoteVisibilityDraft();
draftTestConnectionId.value = uuid();
if (!connectAfterSave) {
if (closeOnSuccess) open.value = false;
return;
}
if (closeOnSuccess) open.value = false;
await nextTick();
emit("connectStarted", config.name);
void store
.connect(config)
.then(() => {
emit("connectSucceeded", config.name);
})
.catch((e: any) => {
const message = String(e?.message || e);
if (message.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
// Keep failed one-time connections available for retry and error inspection.
// They are still removed by connectionStore.disconnect after a successful session.
emit("connectFailed", appendConnectionErrorHints(config, mongodbAuthFailureHint(message), t));
});
return;
}
if (closeOnSuccess) open.value = false;
if (closeOnSuccess) {
if (wasEditing) store.stopEditing();
open.value = false;
await nextTick();
} else if (!wasEditing) {
editingId.value = savedConfig.id;
store.startEditing(savedConfig.id);
}
if (connectAfterSave && savedConfig.db_type !== "jdbc") startSavedConnection(savedConfig);
return true;
} catch (e: any) {
const cause = mongodbAuthFailureHint(String(e?.message || e));
const message = connectionSaved ? t("connection.savedSettingsFailed", { message: cause }) : cause;
testResult.value = { ok: false, message };
showConnectionError(message);
return false;
} finally {
isSaving.value = false;
}
@@ -6442,7 +6485,15 @@ function openExternalUrl(url: string) {
</div>
<template v-if="isPluginConnection">
<PluginConnectionFields v-if="selectedPluginProvider" :model-value="pluginFormValues" :contribution="selectedPluginProvider.contribution" :hidden-bindings="['name']" layout="connection-dialog" @update:model-value="updatePluginFormValues" />
<PluginConnectionFields
v-if="selectedPluginProvider"
:model-value="pluginFormValues"
:contribution="selectedPluginProvider.contribution"
:hidden-bindings="['name']"
layout="connection-dialog"
:plugin-id="selectedPluginProvider.plugin.manifest.id"
@update:model-value="updatePluginFormValues"
/>
<div v-else class="col-span-full rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
{{ pluginLoadError || t("connection.pluginProviderUnavailable") }}
</div>
@@ -8416,7 +8467,7 @@ function openExternalUrl(url: string) {
</Tooltip>
</div>
<p class="text-[11px] leading-4 text-muted-foreground">
{{ t("connection.clientCertHint") }}
{{ t("connection.etcdClientCertHint") }}
</p>
</div>
</div>
@@ -9313,7 +9364,7 @@ function openExternalUrl(url: string) {
<Button variant="outline" class="shrink-0" :disabled="isTesting || isTestingSshTunnel || isSaving" @click="testConnection">
{{ isTesting ? t("connection.testing") : t("connection.test") }}
</Button>
<Button class="shrink-0" @click="save" :disabled="isSaving || isTestingSshTunnel || !hasRequiredConnectionTarget">
<Button class="shrink-0" @click="save()" :disabled="isSaving || isTestingSshTunnel || !hasRequiredConnectionTarget">
{{ isSaving ? t("common.loading") : editingId || isJdbcConnection ? t("connection.save") : t("connection.saveAndConnect") }}
</Button>
</template>
@@ -28,6 +28,7 @@ const props = defineProps<{
const emit = defineEmits<{
"activate-driver-store": [];
"close-driver-store": [];
"activate-plugin-center": [];
"close-plugin-center": [];
"activate-settings-page": [];
"close-settings-page": [];
@@ -130,6 +131,9 @@ function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) {
if (keep !== "driverStore" && props.driverStoreOpen) {
emit("close-driver-store");
}
if (keep !== "pluginCenter" && props.pluginCenterOpen) {
emit("close-plugin-center");
}
if (keep !== "settings" && props.settingsPageOpen) {
emit("close-settings-page");
}
@@ -201,6 +205,9 @@ function handleCancelClose() {
<div v-show="driverStoreActive || pluginCenterActive || settingsPageActive" data-special-page-workspace class="flex min-h-0 min-w-0 flex-1 overflow-hidden" :class="layoutClass">
<div data-special-page-navigation class="flex min-h-0 min-w-0 shrink-0 flex-col overflow-auto" :style="navigationStyle">
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-special-page-tab-target="group.id" class="flex min-h-0 min-w-0" :class="isVerticalLayout ? 'flex-1' : 'shrink-0'" />
<button v-if="pluginCenterOpen" type="button" data-plugin-center-tab class="shrink-0 rounded-md px-2 py-1 text-left text-sm" :class="pluginCenterActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" @click="emit('activate-plugin-center')">
{{ t("toolbar.pluginCenter") }}
</button>
</div>
<div data-special-page-content class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<slot />
@@ -61,8 +61,6 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
import QueryMessagesView from "@/components/layout/QueryMessagesView.vue";
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
import PluginFilesystemTab from "@/components/plugins/PluginFilesystemTab.vue";
import PluginWorkbenchTab from "@/components/plugins/PluginWorkbenchTab.vue";
import ResultSetNavigator from "@/components/layout/ResultSetNavigator.vue";
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
@@ -123,8 +121,10 @@ const MySqlDashboard = defineAsyncComponent(() => import("@/components/admin/MyS
const PostgresDashboard = defineAsyncComponent(() => import("@/components/admin/PostgresDashboard.vue"));
const XuguServerDashboard = defineAsyncComponent(() => import("@/components/admin/XuguServerDashboard.vue"));
const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
const DamengUserAdmin = defineAsyncComponent(() => import("@/components/admin/DamengUserAdmin.vue"));
const DamengRoleAdmin = defineAsyncComponent(() => import("@/components/admin/DamengRoleAdmin.vue"));
const PluginFilesystemTab = defineAsyncComponent(() => import("@/components/plugins/PluginFilesystemTab.vue"));
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
@@ -210,10 +210,6 @@ type SearchableBrowserHandle = {
executeCommand?: (command: string) => Promise<boolean>;
};
type PluginTabHandle = {
refresh?: () => Promise<void> | void;
};
type ElasticsearchJsonResponsePanelHandle = {
focusSearch: () => boolean;
};
@@ -329,8 +325,7 @@ const consulOverviewRef = ref<{ refresh?: () => boolean }>();
const consulWorkspaceRef = ref<SearchableBrowserHandle>();
const databaseBrowserRef = ref<SearchableBrowserHandle>();
const objectBrowserRef = ref<SearchableBrowserHandle>();
const pluginWorkbenchRef = ref<PluginTabHandle>();
const pluginFilesystemRef = ref<PluginTabHandle>();
const pluginFilesystemTabRef = ref<{ refresh: () => Promise<unknown> }>();
const activeTableMeta = computed(() => props.activeTab.tableMeta);
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
const activeResultExecutionTarget = computed(() => queryStore.activeResultExecutionTarget(props.activeTab.id));
@@ -988,10 +983,8 @@ function refreshData(): boolean {
if (props.activeTab.mode === "consul-overview") return consulOverviewRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "databases") return databaseBrowserRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "plugin-workbench" || props.activeTab.mode === "plugin-filesystem") {
const pluginTab = props.activeTab.mode === "plugin-workbench" ? pluginWorkbenchRef.value : pluginFilesystemRef.value;
if (!pluginTab?.refresh) return false;
void pluginTab.refresh();
if (props.activeTab.mode === "plugin-filesystem") {
void pluginFilesystemTabRef.value?.refresh();
return true;
}
// Restored data tabs intentionally omit row data, so refresh must work before DataGrid mounts.
@@ -1219,7 +1212,6 @@ function handleModRTarget(target: Element): boolean {
if (target.closest("[data-cell-detail-editor-root]")) return dataGridRef.value?.openCellDetailSearch() ?? false;
if (target.closest("[data-grid-root], [data-elasticsearch-json-response-root]")) return refreshData();
if (canReloadUnavailableDataTab(props.activeTab)) return refreshData();
if (props.activeTab.mode === "plugin-workbench" || props.activeTab.mode === "plugin-filesystem") return refreshData();
return false;
}
@@ -2571,23 +2563,13 @@ defineExpose({
</div>
</template>
<template v-else-if="activeTab.mode === 'plugin-workbench' && activeTab.pluginWorkbench">
<div class="min-w-0 flex-1 min-h-0 bg-background">
<PluginWorkbenchTab
ref="pluginWorkbenchRef"
:key="activeTab.id"
:plugin-id="activeTab.pluginWorkbench.pluginId"
:contribution-id="activeTab.pluginWorkbench.contributionId"
:connection-id="activeTab.connectionId || undefined"
:context="activeTab.pluginWorkbench.context"
@close-tab="emit('closeTab', activeTab.id)"
/>
</div>
</template>
<!-- plugin-workbench tabs render in the always-mounted layer in App.vue
(v-show visibility): remounting the webview per tab switch would
reload the plugin iframe and drop its live session state. -->
<template v-else-if="activeTab.mode === 'plugin-filesystem' && activeTab.pluginFilesystem">
<div class="flex h-full min-h-0 min-w-0 flex-col">
<div class="flex h-full min-h-0 flex-col">
<PluginFilesystemTab
ref="pluginFilesystemRef"
ref="pluginFilesystemTabRef"
:key="activeTab.id"
:plugin-id="activeTab.pluginFilesystem.pluginId"
:provider-id="activeTab.pluginFilesystem.providerId"
@@ -30,6 +30,8 @@ const props = defineProps<
tabBarCollapsed?: boolean;
canDetachTabs?: boolean;
detachedDropTarget?: boolean;
/** Collapse the group to its tab strip while a plugin workbench tab owns the layout. */
contentSuppressed?: boolean;
}
>();
@@ -98,6 +100,7 @@ const tabBarTarget = computed(() => {
if (!tabBarPortal?.active.value) return undefined;
return tabBarPortal.targets.get(props.groupId);
});
const groupTabs = computed(() => {
const byId = new Map(queryStore.tabs.map((tab) => [tab.id, tab]));
return props.tabIds.map((id) => byId.get(id)).filter((tab): tab is QueryTab => !!tab);
@@ -138,7 +141,7 @@ const groupExecutableSql = computed(() => {
</script>
<template>
<div class="editor-group flex h-full min-h-0 min-w-0 overflow-hidden" :class="[groupClass, groupLayoutClass]" :data-group-id="groupId" @pointerdown.capture="$emit('focus-group', groupId)" @focusin="$emit('focus-group', groupId)">
<div class="editor-group flex min-h-0 min-w-0 overflow-hidden" :class="[contentSuppressed ? '' : 'h-full', groupClass, groupLayoutClass]" :data-group-id="groupId" @pointerdown.capture="$emit('focus-group', groupId)" @focusin="$emit('focus-group', groupId)">
<Teleport defer v-if="showTabNavigation !== false" :to="tabBarTarget" :disabled="!tabBarPortal?.active.value || !tabBarTarget">
<EditorGroupTabBar
:group-id="groupId"
@@ -157,15 +160,17 @@ const groupExecutableSql = computed(() => {
@detach-tab="$emit('detach-tab', $event)"
@activate-settings="toolbar.activateSettingsPage()"
@close-settings="toolbar.closeSettingsPage()"
@activate-driver-store="toolbar.activateDriverStore()"
@close-driver-store="toolbar.closeDriverStore()"
@activate-plugin-center="toolbar.activatePluginCenter()"
@close-plugin-center="toolbar.closePluginCenter()"
@activate-driver-store="toolbar.activateDriverStore()"
@close-driver-store="toolbar.closeDriverStore()"
/>
</Teleport>
<!-- The toolbar stays at the top of the pane's content column in every
placement; only the tab bar moves around it. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
placement; only the tab bar moves around it. While contentSuppressed
(plugin workbench tab active) the whole column yields to the
always-mounted plugin layer and the group collapses to its strip. -->
<div v-show="!contentSuppressed" class="flex min-h-0 min-w-0 flex-1 flex-col">
<EditorToolbar
v-if="activeTab && showGroupToolbar"
:active-tab="activeTab"
@@ -26,6 +26,8 @@ const props = defineProps<
tabBarCollapsed?: boolean;
canDetachTabs?: boolean;
detachedDropTarget?: boolean;
/** Render only the groups' tab strips (a plugin workbench tab owns the layout). */
contentSuppressed?: boolean;
}
>();
const emit = defineEmits<
@@ -116,12 +118,13 @@ const globalTabBarPortal = inject(GROUP_TAB_BAR_PORTAL, null);
const workspaceTabBarPortal = createGroupTabBarPortal(isVerticalTabLayout);
// A special page owns navigation while active. Otherwise side tabs stay
// outside the editor/result split, and horizontal tabs return to their group.
provide(GROUP_TAB_BAR_PORTAL, {
const providedPortal = {
active: computed(() => !!globalTabBarPortal?.active.value || isVerticalTabLayout.value),
get targets() {
return globalTabBarPortal?.active.value ? globalTabBarPortal.targets : workspaceTabBarPortal.targets;
},
});
};
provide(GROUP_TAB_BAR_PORTAL, providedPortal);
const tabNavigationStyle = computed(() => {
const width = props.tabBarCollapsed ? "var(--collapsed-tab-rail-width)" : `${props.tabBarWidth ?? 240}px`;
return { width, flex: `0 0 ${width}` };
@@ -254,12 +257,45 @@ function handleFocusStatement(tabId: string, range: StatementRange | null): bool
</script>
<template>
<div class="sql-editor-workspace relative flex h-full min-h-0 min-w-0 flex-1 overflow-hidden" :class="[workspaceClass, settingsStore.editorSettings.tabPlacement === 'right' ? 'flex-row-reverse' : 'flex-row']">
<!-- contentSuppressed (plugin workbench tab active): the App.vue wrapper is
flex-none with indefinite height, so h-full/flex-1 here would collapse
the workspace to 0 and clip the group tab strips (overflow-hidden).
Size to content instead — same contract as EditorGroup's suppressed
h-auto; normal mode keeps h-full/flex-1 to fill the column. -->
<div class="sql-editor-workspace relative flex min-h-0 min-w-0 overflow-hidden" :class="[contentSuppressed ? 'h-auto' : 'h-full flex-1', workspaceClass, settingsStore.editorSettings.tabPlacement === 'right' ? 'flex-row-reverse' : 'flex-row']">
<div v-show="isVerticalTabLayout && showTabNavigation !== false" data-workspace-tab-navigation class="flex min-h-0 shrink-0 flex-col overflow-hidden" :style="tabNavigationStyle">
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-workspace-tab-target="group.id" class="flex min-h-0 min-w-0 flex-1" @pointerdown.capture="queryStore.focusGroup(group.id)" @focusin="queryStore.focusGroup(group.id)" />
</div>
<div data-workspace-content class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<Splitpanes horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
<!-- Suppressed mode: a plugin workbench tab owns the layout. Render the
groups' tab strips directly (no Splitpanes, no shared result pane) so
App.vue's always-mounted plugin layer can fill the remaining column
while the strips stay visible and switchable. -->
<template v-if="contentSuppressed">
<EditorGroup
v-for="group in queryStore.groups"
:key="group.id"
:ref="(el: unknown) => setGroupRef(group.id, el)"
:group-id="group.id"
:tab-ids="group.tabIds"
:active-tab-id="group.activeTabId"
:tab-bar-width="tabBarWidth"
:tab-bar-collapsed="tabBarCollapsed"
:can-detach-tabs="canDetachTabs"
:detached-drop-target="detachedDropTarget"
:content-suppressed="true"
:show-tab-navigation="showTabNavigation"
v-bind="editorGroupBindings"
@focus-group="queryStore.focusGroup($event)"
@activate-tab="queryStore.activateTabInGroup(group.id, $event)"
@locate-tab="emit('locate-tab', $event)"
@toggle-zen-mode="emit('toggle-zen-mode')"
@start-resize="emit('start-resize', $event)"
@toggle-collapse="emit('toggle-collapse')"
@detach-tab="emit('detach-tab', $event)"
/>
</template>
<Splitpanes v-else horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
<Pane class="min-h-0 min-w-0" :size="editorPaneSize" :min-size="100 - SHARED_RESULT_PANE_MAX_SIZE">
<Splitpanes
:horizontal="queryStore.orientation === 'horizontal'"
@@ -63,8 +63,10 @@ describe("Group strip special page tabs", () => {
it("keeps special page tabs and their update badge in the focused group", () => {
expect(groupTabBarSource).toContain("queryStore.focusedGroupId === props.groupId");
expect(groupTabBarSource).toContain("data-settings-page-tab");
expect(groupTabBarSource).toContain("data-plugin-center-tab");
expect(groupTabBarSource).toContain("data-driver-store-tab");
expect(groupTabBarSource).toContain("@click=\"emit('activate-settings')\"");
expect(groupTabBarSource).toContain("@click=\"emit('activate-plugin-center')\"");
expect(groupTabBarSource).toContain("@click=\"emit('activate-driver-store')\"");
expect(groupTabBarSource).toContain('t("toolbar.driverManager")');
expect(groupTabBarSource).toContain("aria-label=\"t('toolbar.updatableDriverCount')\"");
@@ -74,6 +76,8 @@ describe("Group strip special page tabs", () => {
it("routes special-page actions and preserves ordinary-tab dirty closing", () => {
expect(groupSource).toContain(':special-page-tabs="toolbar.specialPageTabs.value"');
expect(groupSource).toContain('@activate-settings="toolbar.activateSettingsPage()"');
expect(groupSource).toContain('@activate-plugin-center="toolbar.activatePluginCenter()"');
expect(groupSource).toContain('@close-plugin-center="toolbar.closePluginCenter()"');
expect(groupSource).toContain('@close-driver-store="toolbar.closeDriverStore()"');
expect(groupTabBarSource).toContain('@mousedown.middle.prevent="closeTab(entry.tab)"');
expect(groupTabBarSource).toContain('@click.stop="closeTab(entry.tab)"');
@@ -83,6 +87,7 @@ describe("Group strip special page tabs", () => {
it("uses one presentation path for active special pages and inactive ordinary tabs", () => {
expect(groupTabBarSource).toContain("return !specialPageActive.value && tab.id === props.activeTabId;");
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.settingsActive)");
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.pluginCenterActive)");
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.driverStoreActive)");
expect(groupTabBarSource).toContain("if (!active) return undefined;");
expect(groupTabBarSource).toContain('return { "--app-tab-background": activeBackground, boxShadow: "inset 0 -2px 0 color-mix(in srgb, var(--foreground) 72%, transparent)" };');
@@ -24,6 +24,16 @@ vi.mock("@/components/ui/popover", () => ({
PopoverTrigger: { name: "PopoverTriggerStub", template: `<div><slot /></div>` },
}));
vi.mock("@/lib/plugins/pluginIconResolver", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/plugins/pluginIconResolver")>()),
resolvePluginIcon: vi.fn(() => Promise.resolve("assets/plugin.svg")),
}));
vi.mock("@/lib/backend/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/backend/api")>()),
readPluginAsset: vi.fn(() => Promise.resolve({ contentType: "image/svg+xml", dataBase64: "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" })),
}));
const specDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(specDir, "../EditorGroupTabBar.vue"), "utf8");
const sharedStyles = readFileSync(resolve(specDir, "../appTabBar.css"), "utf8");
@@ -713,6 +723,30 @@ describe("EditorGroupTabBar group behavior", () => {
app.unmount();
host.remove();
});
it("renders plugin workbench tabs with the plugin icon instead of the code fallback", async () => {
const store = useQueryStore();
const pluginTabId = store.openPluginWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench", { title: "SSH server", connectionId: "ssh-1", forceNew: true });
const { app, host } = mountBar(store.groups[0].id, store.tabs.slice(), pluginTabId, pinia);
await settle();
// PluginIcon loads the asset asynchronously; wait for the blob <img> to appear.
for (let i = 0; i < 20 && !host.querySelector(`[data-tab-id="${pluginTabId}"] img`); i += 1) {
await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 5));
}
const pill = host.querySelector<HTMLElement>(`[data-tab-id="${pluginTabId}"]`);
expect(pill).toBeTruthy();
expect(pill!.querySelector("img")).toBeTruthy();
// The pill and the overflow popup list both render via TabModeIcon, whose
// per-mode chain must keep the plugin-workbench plugin icon branch.
const tabModeIconSource = readFileSync(resolve(specDir, "../TabModeIcon.vue"), "utf8");
expect(tabModeIconSource).toContain("tab.mode === 'plugin-workbench' && tab.pluginWorkbench");
expect(tabModeIconSource).toContain(':contribution-id="tab.pluginWorkbench.contributionId"');
expect(source).toContain('<TabModeIcon :tab="entry.tab"');
app.unmount();
host.remove();
});
});
describe("EditorGroupTabBar special page navigation", () => {
@@ -26,7 +26,7 @@ const groupExecutionCalls = vi.hoisted(() => ({ capture: [] as string[], request
vi.mock("@/components/layout/EditorGroup.vue", () => ({
default: {
name: "EditorGroupStub",
props: ["groupId", "tabIds", "activeTabId"],
props: ["groupId", "tabIds", "activeTabId", "contentSuppressed"],
emits: ["execute"],
methods: {
previewStatementRange(tabId: string, range: unknown) {
@@ -54,7 +54,7 @@ vi.mock("@/components/layout/EditorGroup.vue", () => ({
return true;
},
},
template: `<div data-test="editor-group" :data-group-id="groupId" :data-tab-ids="tabIds.join(',')" :data-active-tab-id="activeTabId"><button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">execute</button></div>`,
template: `<div data-test="editor-group" :data-group-id="groupId" :data-tab-ids="tabIds.join(',')" :data-active-tab-id="activeTabId" :data-content-suppressed="contentSuppressed ? 'true' : 'false'"><button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">execute</button></div>`,
},
}));
@@ -336,6 +336,95 @@ describe("SqlEditorWorkspace mount contract", () => {
host.remove();
});
it("keeps group tab strips mounted and drops the splitpanes workspace while suppressed", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a"), { ...tab("tab-b"), mode: "plugin-workbench" } as unknown as ReturnType<typeof tab>];
store.activeTabId = "tab-b";
store.groups = [
{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" },
{ id: "g2", tabIds: ["tab-b"], activeTabId: "tab-b" },
];
store.focusedGroupId = "g2";
store.orientation = "vertical";
store.sizes = [50, 50];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: { ...tab("tab-b"), mode: "plugin-workbench" } as unknown as ReturnType<typeof tab>,
activeConnection: undefined,
executableSql: "",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
contentSuppressed: true,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
// Suppressed mode renders the groups' tab strips directly (no Splitpanes,
// no shared result pane) so App.vue's always-mounted plugin workbench
// layer can own the remaining column while the strips stay visible.
expect(host.querySelector('[data-test="splitpanes-stub"]')).toBeNull();
expect(host.querySelector('[data-test="result-surface"]')).toBeNull();
const groups = host.querySelectorAll('[data-test="editor-group"]');
expect(groups).toHaveLength(2);
expect(groups[1]?.getAttribute("data-group-id")).toBe("g2");
expect(groups[1]?.getAttribute("data-content-suppressed")).toBe("true");
// The App.vue wrapper is flex-none with indefinite height while a plugin
// tab is active: the workspace must size to its strip content, or the
// h-full/flex-1 fill contract collapses it to zero height and clips the
// strips away (overflow-hidden) — the "plugin tab bar vanishes" regression.
const workspaceRoot = host.querySelector<HTMLElement>(".sql-editor-workspace");
expect(workspaceRoot?.classList.contains("h-auto")).toBe(true);
expect(workspaceRoot?.classList.contains("h-full")).toBe(false);
expect(workspaceRoot?.classList.contains("flex-1")).toBe(false);
app.unmount();
host.remove();
});
it("keeps the fill-height contract on the workspace root outside suppressed mode", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a")];
store.activeTabId = "tab-a";
store.groups = [{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" }];
store.focusedGroupId = "g1";
store.orientation = "vertical";
store.sizes = [100];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: tab("tab-a"),
activeConnection: undefined,
executableSql: "",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
contentSuppressed: false,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
const workspaceRoot = host.querySelector<HTMLElement>(".sql-editor-workspace");
expect(workspaceRoot?.classList.contains("h-full")).toBe(true);
expect(workspaceRoot?.classList.contains("flex-1")).toBe(true);
expect(workspaceRoot?.classList.contains("h-auto")).toBe(false);
app.unmount();
host.remove();
});
it("forwards editor execute events from EditorGroup to the App listener", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a")];
@@ -1,11 +1,21 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import PluginConnectionFields from "./PluginConnectionFields.vue";
import type { PluginConnectionProviderContribution, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
const invokePluginMock = vi.fn();
vi.mock("@/lib/backend/api", () => ({
listLocalSshKeys: vi.fn(async () => [
{ path: "/home/dev/.ssh/id_ed25519", algorithm: "ssh-ed25519", fingerprint: "SHA256:abc", hasPassphrase: false },
{ path: "/home/dev/.ssh/id_rsa", algorithm: "ssh-rsa", fingerprint: "", hasPassphrase: true },
]),
invokePlugin: (...args: unknown[]) => invokePluginMock(...args),
}));
const mountedApps: App[] = [];
const contribution: PluginConnectionProviderContribution = {
@@ -16,16 +26,6 @@ const contribution: PluginConnectionProviderContribution = {
fields: [
{ key: "host", label: "Host", type: "text", required: true, placeholder: "localhost" },
{ key: "password", label: "Password", type: "password" },
{
key: "protocol",
label: "Protocol",
type: "radio",
default: "https",
options: [
{ label: "HTTPS", value: "https" },
{ label: "HTTP", value: "http" },
],
},
],
};
@@ -62,6 +62,39 @@ afterEach(() => {
});
describe("PluginConnectionFields", () => {
it("preserves multiline secret input and masks it again after its branch is reopened", async () => {
const provider: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "pem.connection",
label: "PEM",
database_type: "example",
fields: [
{ key: "mode", label: "Mode", type: "text", default: "tls" },
{ key: "key", label: "Private key", type: "textarea", binding: "secret", visible_when: { field: "mode", one_of: ["tls"] } },
],
};
const state = await mountContribution(provider, {});
const textarea = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
const pem = "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----\n";
expect(textarea.classList.contains("secret-masked")).toBe(true);
textarea.value = pem;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
expect(state.values.key).toBe(pem);
textarea.parentElement!.querySelector<HTMLButtonElement>("button")!.click();
await nextTick();
expect(textarea.classList.contains("secret-masked")).toBe(false);
expect(textarea.value).toBe(pem);
state.values = { ...state.values, mode: "plain" };
await nextTick();
expect(document.querySelector("#pem-connection-key")).toBeNull();
state.values = { ...state.values, mode: "tls" };
await nextTick();
const reopened = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
expect(reopened.value).toBe(pem);
expect(reopened.classList.contains("secret-masked")).toBe(true);
});
it("renders declared fields and emits immutable model updates", async () => {
const state = await mountFields({ password: "secret" });
const hostInput = document.querySelector<HTMLInputElement>("#example-connection-host");
@@ -99,15 +132,199 @@ describe("PluginConnectionFields", () => {
expect(document.querySelector(".rounded-lg.border")).toBeNull();
});
it("renders radio fields and emits the selected option", async () => {
const state = await mountFields();
const radios = Array.from(document.querySelectorAll<HTMLInputElement>("input[type='radio']"));
it("hides conditional fields until visible_when matches and marks required_when", async () => {
const conditional: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "cond.connection",
label: "Conditional",
database_type: "cond",
fields: [
{ key: "mode", label: "Mode", type: "text", default: "password" },
{ key: "private_key_path", label: "Private key", type: "text", visible_when: { field: "mode", one_of: ["key"] }, required_when: { field: "mode", one_of: ["key"] } },
],
};
const state = await mountContribution(conditional, {});
expect(radios).toHaveLength(2);
expect(radios[0]?.checked).toBe(true);
radios[1]?.click();
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
state.values = { mode: "key" };
await nextTick();
expect(state.values.protocol).toBe("http");
const keyInput = document.querySelector<HTMLInputElement>("#cond-connection-private_key_path");
expect(keyInput).not.toBeNull();
const label = document.querySelector('label[for="cond-connection-private_key_path"]');
expect(label?.textContent).toContain("*");
state.values = { mode: "password" };
await nextTick();
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
});
it("keeps grandchild fields hidden while an intermediate default-only value would match (kafka msk_region regression)", async () => {
// sasl_mechanism (PLAIN default, hidden without SASL) → oauth_token_source
// (msk_iam default) → msk_region: the nested default must not surface an
// always-required AWS region on a plain PLAINTEXT/SCRAM connection.
const kafka: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "kafka.connection",
label: "Kafka",
database_type: "kafka",
fields: [
{ key: "bootstrap_servers", label: "Bootstrap servers", type: "textarea", required: true },
{
key: "security_protocol",
label: "Security protocol",
type: "select",
default: "PLAINTEXT",
options: [
{ label: "PLAINTEXT", value: "PLAINTEXT" },
{ label: "SASL_SSL", value: "SASL_SSL" },
],
},
{
key: "sasl_mechanism",
label: "SASL mechanism",
type: "select",
default: "PLAIN",
options: [
{ label: "PLAIN", value: "PLAIN" },
{ label: "OAUTHBEARER", value: "OAUTHBEARER" },
],
visible_when: { field: "security_protocol", one_of: ["SASL_PLAINTEXT", "SASL_SSL"] },
},
{
key: "oauth_token_source",
label: "OAuth token source",
type: "select",
default: "msk_iam",
options: [
{ label: "MSK IAM", value: "msk_iam" },
{ label: "Static token", value: "static_token" },
],
visible_when: { field: "sasl_mechanism", one_of: ["OAUTHBEARER"] },
},
{ key: "msk_region", label: "MSK AWS region", type: "text", visible_when: { field: "oauth_token_source", one_of: ["msk_iam"] }, required_when: { field: "oauth_token_source", one_of: ["msk_iam"] } },
],
};
const state = await mountContribution(kafka, {}, "io.dbx.kafka");
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
state.values = { security_protocol: "SASL_SSL", sasl_mechanism: "OAUTHBEARER" };
await nextTick();
expect(document.querySelector("#kafka-connection-msk_region")).not.toBeNull();
const label = document.querySelector('label[for="kafka-connection-msk_region"]');
expect(label?.textContent).toContain("*");
// Selecting the static token source hides the region field again.
state.values = { sasl_mechanism: "OAUTHBEARER", oauth_token_source: "static_token" };
await nextTick();
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
});
it("offers local SSH keys on private_key_path fields and fills the chosen path", async () => {
const withKey: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "keys.connection",
label: "Keys",
database_type: "keys",
fields: [{ key: "private_key_path", label: "Private key", type: "text" }],
};
const state = await mountContribution(withKey, {});
await flushAsync();
const select = document.querySelector<HTMLSelectElement>("select");
expect(select).not.toBeNull();
expect(select?.disabled).toBe(false);
const encryptedOption = select?.querySelector<HTMLOptionElement>('option[value="/home/dev/.ssh/id_rsa"]');
expect(encryptedOption?.textContent).toContain("id_rsa");
expect(encryptedOption?.textContent).toContain("ssh-rsa");
expect(encryptedOption?.textContent).toMatch(/encrypted|已加密/);
select!.value = "/home/dev/.ssh/id_rsa";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await nextTick();
expect(state.values.private_key_path).toBe("/home/dev/.ssh/id_rsa");
});
it("renders options_action fields as a dynamic select and keeps a stored value visible", async () => {
const dynamic: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "dyn.connection",
label: "Dyn",
database_type: "dyn",
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
};
invokePluginMock.mockResolvedValueOnce({
options: [
{ value: "p1", label: "Ops" },
{ value: "p2", label: "QA" },
],
});
// "ghost" is stored but no longer offered: the dropdown must keep it visible.
await mountContribution(dynamic, { sudo_profile: "ghost" }, "io.dbx.ssh");
await flushAsync();
expect(invokePluginMock).toHaveBeenCalledWith("io.dbx.ssh", "sudo/profiles/options");
expect(document.querySelector("input#dyn-connection-sudo_profile")).toBeNull();
const trigger = document.querySelector<HTMLButtonElement>('#dyn-connection-sudo_profile, button[role="combobox"]');
expect(trigger).not.toBeNull();
expect(trigger?.textContent).toContain("ghost");
});
it("falls back to the text input when the options_action call fails or returns nothing", async () => {
const dynamic: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "degrade.connection",
label: "Degrade",
database_type: "degrade",
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
};
invokePluginMock.mockRejectedValueOnce(new Error("method not registered"));
await mountContribution(dynamic, {}, "io.dbx.ssh");
await flushAsync();
const input = document.querySelector<HTMLInputElement>("input#degrade-connection-sudo_profile");
expect(input).not.toBeNull();
expect(input?.value).toBe("");
// An empty option list degrades the same way.
invokePluginMock.mockResolvedValueOnce({ options: [] });
const empty = { ...dynamic, id: "empty.connection", label: "Empty" };
await mountContribution(empty, {}, "io.dbx.ssh");
await flushAsync();
expect(document.querySelector("input#empty-connection-sudo_profile")).not.toBeNull();
});
});
async function flushAsync() {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function mountContribution(contribution: PluginConnectionProviderContribution, initialValues: Record<string, PluginFormFieldValue>, pluginId?: string) {
const state = reactive({ values: initialValues });
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
return () =>
h(PluginConnectionFields, {
contribution,
modelValue: state.values,
pluginId,
"onUpdate:modelValue": (value: Record<string, PluginFormFieldValue>) => {
state.values = value;
},
});
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
return state;
}
@@ -1,25 +1,45 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import PasswordTextarea from "@/components/ui/PasswordTextarea.vue";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
import { pluginFieldIsRequired, pluginFieldIsVisible, type PluginFieldResolver } from "@/lib/plugins/pluginFieldConditions";
import { invokePlugin, listLocalSshKeys } from "@/lib/backend/api";
import type { LocalSshKey, PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldOption, PluginFormFieldValue } from "@/types/database";
const props = defineProps<{
contribution: PluginConnectionProviderContribution;
modelValue: Record<string, PluginFormFieldValue>;
hiddenBindings?: PluginFormFieldBinding[];
layout?: "stacked" | "connection-dialog";
pluginId?: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: Record<string, PluginFormFieldValue>];
}>();
const { t } = useI18n();
const formValues = computed(() => props.modelValue);
const visibleFields = computed(() => props.contribution.fields.filter((field) => !field.binding || !props.hiddenBindings?.includes(field.binding)));
/** Value of any sibling field by key (condition evaluation reads current form values). */
function readFieldValue(key: string): PluginFormFieldValue {
const field = props.contribution.fields.find((candidate) => candidate.key === key);
return field ? (formValues.value[key] ?? field.default) : undefined;
}
const resolveSiblingField: PluginFieldResolver = (key) => props.contribution.fields.find((candidate) => candidate.key === key);
function fieldVisible(field: PluginFormField): boolean {
if (field.binding && props.hiddenBindings?.includes(field.binding)) return false;
return pluginFieldIsVisible(field, readFieldValue, resolveSiblingField);
}
function fieldRequired(field: PluginFormField): boolean {
return pluginFieldIsRequired(field, readFieldValue);
}
const visibleFields = computed(() => props.contribution.fields.filter((field) => fieldVisible(field)));
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");
function fieldValue(field: PluginFormField): PluginFormFieldValue {
@@ -59,6 +79,103 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
if (field.type === "number") return undefined;
return "";
}
// ---------------------------------------------------------------------------
// Dynamic option lists (fields declaring `options_action`)
// ---------------------------------------------------------------------------
// A field may point `options_action` at a plugin method returning
// `{ options: [{ value, label }] }`. The host fetches it once per dialog and
// renders the field as a select; a missing plugin id, a failed call, or an
// empty result falls back to the declared text input (older hosts without
// this extension never fetch and simply keep the text field).
const dynamicOptions = ref<Record<string, PluginFormFieldOption[]>>({});
const dynamicOptionsRequested = ref(new Set<string>());
const optionsActionFields = computed(() => visibleFields.value.filter((field) => field.options_action));
watch(
optionsActionFields,
(fields) => {
for (const field of fields) {
const action = field.options_action;
if (!action || !props.pluginId || dynamicOptionsRequested.value.has(field.key)) continue;
dynamicOptionsRequested.value.add(field.key);
invokePlugin<{ options?: PluginFormFieldOption[] }>(props.pluginId, action)
.then((result) => {
const options = Array.isArray(result?.options) ? result.options.filter((option) => option && option.value !== undefined) : [];
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: options };
})
.catch(() => {
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: [] };
});
}
},
{ immediate: true },
);
function selectOptionsFor(field: PluginFormField): PluginFormFieldOption[] | null {
if (!field.options_action) return null;
const options = dynamicOptions.value[field.key];
if (!options || options.length === 0) return null;
// Keep a stored value visible even when its profile disappeared so the
// dialog does not silently look "unset" on reopen.
const current = fieldValue(field);
if (current !== undefined && current !== "" && !options.some((option) => String(option.value) === String(current))) {
return [{ value: String(current), label: String(current) }, ...options];
}
return options;
}
// ---------------------------------------------------------------------------
// Local SSH key suggestions (Host API: private_key_path convenience picker)
// ---------------------------------------------------------------------------
const LOCAL_SSH_KEY_FIELD = "private_key_path";
function isLocalSshKeyField(field: PluginFormField): boolean {
return field.key === LOCAL_SSH_KEY_FIELD && (field.type === "text" || field.type === "password");
}
const hasLocalSshKeyField = computed(() => visibleFields.value.some((field) => isLocalSshKeyField(field)));
const localSshKeys = ref<LocalSshKey[]>([]);
const localSshKeysRequested = ref(false);
async function loadLocalSshKeys() {
if (localSshKeysRequested.value) return;
localSshKeysRequested.value = true;
try {
localSshKeys.value = await listLocalSshKeys();
} catch {
localSshKeys.value = [];
}
}
watch(
hasLocalSshKeyField,
(present) => {
if (present) void loadLocalSshKeys();
},
{ immediate: true },
);
function localSshKeyFile(path: string): string {
return path.replaceAll("\\", "/").split("/").pop() || path;
}
function localSshKeyLabel(key: LocalSshKey): string {
const algorithm = key.algorithm || "?";
return key.hasPassphrase ? `${localSshKeyFile(key.path)} · ${algorithm} · ${t("connection.pluginSshKeyEncrypted")}` : `${localSshKeyFile(key.path)} · ${algorithm}`;
}
function applyLocalSshKey(field: PluginFormField, event: Event) {
const select = event.target as HTMLSelectElement;
const selected = localSshKeys.value.find((key) => key.path === select.value);
select.value = "";
if (!selected) return;
updateField(field, selected.path);
}
</script>
<template>
@@ -75,11 +192,37 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
<div v-for="field in visibleFields" :key="field.key" :class="isConnectionDialogLayout ? 'col-span-full grid grid-cols-4 items-start gap-4' : 'space-y-1.5'">
<Label :for="fieldId(field)" :class="isConnectionDialogLayout ? 'justify-self-start pt-2 text-left' : 'text-xs'">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
<span v-if="fieldRequired(field)" class="text-destructive">*</span>
</Label>
<div :class="isConnectionDialogLayout ? 'col-span-3 min-w-0 space-y-1.5' : ''">
<Input v-if="field.type === 'text' || field.type === 'number'" :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
<template v-if="field.type === 'text' || field.type === 'number'">
<Select v-if="field.type === 'text' && selectOptionsFor(field)" :model-value="String(fieldValue(field) ?? '')" @update:model-value="updateSelectField(field, $event)">
<SelectTrigger :id="fieldId(field)" :class="isConnectionDialogLayout ? 'h-9' : 'h-8 text-xs'">
<SelectValue :placeholder="field.placeholder" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in selectOptionsFor(field)!" :key="String(option.value)" :value="String(option.value)">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-else-if="isLocalSshKeyField(field)" class="flex items-center gap-1.5">
<Input :id="fieldId(field)" type="text" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" class="min-w-0 flex-1" @update:model-value="updateTextField(field, $event)" />
<select
class="h-9 w-40 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="localSshKeys.length === 0"
:title="t('connection.pluginSshKeyPickerPlaceholder')"
:aria-label="t('connection.pluginSshKeyPickerPlaceholder')"
@change="applyLocalSshKey(field, $event)"
>
<option value="" disabled selected>{{ localSshKeys.length ? t("connection.pluginSshKeyPickerPlaceholder") : t("connection.pluginSshKeyPickerEmpty") }}</option>
<option v-for="key in localSshKeys" :key="key.path" :value="key.path" :title="key.path">{{ localSshKeyLabel(key) }}</option>
</select>
</div>
<Input v-else :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
</template>
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
<PasswordTextarea v-else-if="field.type === 'textarea' && field.binding === 'secret'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
<textarea
v-else-if="field.type === 'textarea'"
:id="fieldId(field)"
@@ -98,12 +241,6 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
</SelectItem>
</SelectContent>
</Select>
<div v-else-if="field.type === 'radio'" :id="fieldId(field)" class="flex min-h-9 flex-wrap items-center gap-x-4 gap-y-2" role="radiogroup" :aria-label="field.label">
<label v-for="option in field.options || []" :key="option.value" class="inline-flex cursor-pointer items-center gap-2 text-sm">
<input type="radio" :name="fieldId(field)" :value="option.value" :checked="String(fieldValue(field) ?? '') === option.value" class="size-4 accent-primary" @change="updateField(field, option.value)" />
<span>{{ option.label }}</span>
</label>
</div>
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
</div>
@@ -25,10 +25,14 @@ const emit = defineEmits<{
}>();
const { t, locale: appLocale } = useI18n();
const { isDark, activeCustomUiColors } = useTheme();
const { isDark, themeRevision } = useTheme();
const iframe = ref<HTMLIFrameElement>();
const source = ref("");
const loading = ref(true);
// Stays false until the iframe's load event: WKWebView paints a white canvas
// for a freshly inserted iframe before the sandbox document's first styled
// frame, so the themed overlay must keep covering the frame area until then.
const frameReady = ref(false);
const error = ref("");
let bridge: PluginHostBridge | undefined;
let unsubscribeEvents: (() => void) | undefined;
@@ -116,6 +120,7 @@ async function loadWorkbench() {
const generation = ++loadGeneration;
bridge = undefined;
loading.value = true;
frameReady.value = false;
error.value = "";
try {
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
@@ -132,7 +137,8 @@ async function loadWorkbench() {
if (disposed || generation !== loadGeneration) return;
error.value = cause instanceof Error ? cause.message : String(cause);
emit("error", error.value);
loading.value = false;
} finally {
if (!disposed && generation === loadGeneration) loading.value = false;
}
}
@@ -141,9 +147,18 @@ function onMessage(event: MessageEvent) {
}
function onFrameLoad() {
// The load event can precede the webview's first actual paint (notably on
// WKWebView); reveal after two animation frames, with a timer fallback
// because rAF stalls in occluded/background webviews. Guarded by generation
// so a stale callback from a rebuilt iframe can't lift the new overlay.
const generation = loadGeneration;
const reveal = () => {
if (!disposed && generation === loadGeneration) frameReady.value = true;
};
requestAnimationFrame(() => requestAnimationFrame(reveal));
setTimeout(reveal, 400);
bridge?.sendInit();
emit("ready");
loading.value = false;
}
onMounted(async () => {
@@ -172,7 +187,10 @@ watch(
{ deep: true },
);
watch(appLocale, (locale) => bridge?.updateLocale(locale));
watch([isDark, activeCustomUiColors], () => bridge?.updateTheme(currentBridgeTheme()), { deep: true });
// Keyed on the theme revision (bumped by applyTheme) so every theme change
// path — dark/light, palette switch, custom colors — re-pushes the resolved
// tokens; watching isDark/custom colors alone misses palette-only switches.
watch(themeRevision, () => bridge?.updateTheme(currentBridgeTheme()));
onBeforeUnmount(() => {
disposed = true;
@@ -185,14 +203,25 @@ onBeforeUnmount(() => {
<template>
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
<div v-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error }}</span>
</div>
<iframe v-else-if="source" ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
<div v-if="loading && !error" class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 text-sm text-muted-foreground backdrop-blur-sm">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
<div v-else-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error }}</span>
</div>
<template v-else>
<iframe ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
<!-- Cover until the frame has actually painted: the iframe stays mounted
underneath so its load event can fire (v-else on the overlay would
deadlock), it just isn't visible yet. Fully opaque so the covered
phase is visually identical to the host background, and faded out
instead of removed so the reveal is never a hard swap. -->
<div class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground transition-opacity duration-150 ease-out" :class="frameReady ? 'pointer-events-none opacity-0' : 'opacity-100'">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
</template>
</div>
</template>
@@ -15,7 +15,7 @@ vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const { defineComponent, h, Teleport } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
@@ -27,9 +27,20 @@ vi.mock("@/components/ui/dialog", async () => {
return () => (props.open ? h("div", slots.default?.()) : null);
},
});
const dialogContent = defineComponent({
props: {
class: [String, Array, Object],
overlayClass: [String, Array, Object],
portalClass: [String, Array, Object],
},
inheritAttrs: false,
setup(props, { slots }) {
return () => h(Teleport, { to: "body" }, [h("div", { class: ["dialog-overlay", "z-50", props.overlayClass] }), h("div", { class: ["dialog-positioner", "z-50", props.portalClass] }, [h("div", { class: ["dialog-content", props.class] }, slots.default?.())])]);
},
});
return {
Dialog: dialog,
DialogContent: passthrough,
DialogContent: dialogContent,
DialogDescription: passthrough,
DialogFooter: passthrough,
DialogHeader: passthrough,
@@ -133,6 +144,51 @@ describe("SshHostKeyPromptDialog web bridge", () => {
expect(dialogSource).toContain('portal-class="z-[200]"');
});
it("keeps the prompt above a later body-mounted connection dialog and answerable", async () => {
await mountDialog();
const eventSource = MockEventSource.instances[0];
eventSource?.emit({
type: "prompt",
request: {
id: "prompt-layered",
kind: "HostKeyVerify",
host: "layered.example.test",
port: 22,
key_type: "ssh-ed25519",
fingerprint: "SHA256:layered",
},
});
await nextTick();
// Simulate ConnectionDialog opening after the global prompt: its portal is
// appended later and remains on the shared default z-50 layer.
const connectionPositioner = document.createElement("div");
connectionPositioner.className = "dialog-positioner z-50";
connectionPositioner.textContent = "New connection";
document.body.append(connectionPositioner);
const promptPositioner = document.body.querySelector<HTMLElement>(".dialog-positioner.z-\\[200\\]");
const promptOverlay = document.body.querySelector<HTMLElement>(".dialog-overlay.z-\\[200\\]");
expect(promptPositioner).not.toBeNull();
expect(promptOverlay).not.toBeNull();
expect(connectionPositioner.classList.contains("z-50")).toBe(true);
expect(promptPositioner?.classList.contains("z-[200]")).toBe(true);
expect(promptOverlay?.classList.contains("z-[200]")).toBe(true);
expect(document.body.textContent).toContain("layered.example.test:22");
const buttons = document.body.querySelectorAll<HTMLButtonElement>("button");
buttons.item(buttons.length - 1).click();
await vi.waitFor(() => {
expect(resolveSshPromptMock).toHaveBeenCalledWith({
id: "prompt-layered",
action: "accept",
remember: true,
secret: undefined,
});
});
});
it("shows an SSE host-key prompt and posts the user's acceptance", async () => {
await mountDialog();
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Eye, EyeOff } from "@lucide/vue";
defineOptions({ inheritAttrs: false });
const model = defineModel<string>();
const visible = ref(false);
const { t } = useI18n();
const toggleLabel = computed(() => t(visible.value ? "common.hidePassword" : "common.showPassword"));
</script>
<template>
<div class="relative">
<textarea
v-model="model"
v-bind="$attrs"
:class="{ 'secret-masked': !visible }"
class="min-h-20 w-full resize-y rounded-md border border-input bg-transparent py-2 pl-2.5 pr-9 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
autocomplete="off"
autocapitalize="off"
:spellcheck="false"
/>
<button type="button" class="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground" :aria-label="toggleLabel" :title="toggleLabel" :aria-pressed="visible" @click="visible = !visible">
<Eye v-if="!visible" class="size-3.5" />
<EyeOff v-else class="size-3.5" />
</button>
</div>
</template>
<style scoped>
.secret-masked {
-webkit-text-security: disc;
}
</style>