fix(plugins): stabilize plugin integration
This commit is contained in:
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)" };');
|
||||
|
||||
+34
@@ -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", () => {
|
||||
|
||||
+91
-2
@@ -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")];
|
||||
|
||||
+235
-18
@@ -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>
|
||||
Reference in new issue
Block a user