fix(plugins): stabilize plugin integration

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

No files matched your search

+36 -8
View File
@@ -115,7 +115,7 @@ import { normalizeSqlExecutionTarget, sqlExecutionTargetCapabilities } from "@/l
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge";
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { apiUrl, webPath } from "@/lib/common/webPath";
import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
@@ -152,6 +152,7 @@ import { resolveWindowContext } from "@/lib/app/windowContext";
import { openDetachedTabWindow } from "@/lib/app/detachedTabWindow";
const AiAssistant = defineAsyncComponent(() => import("@/components/editor/AiAssistant.vue"));
const PluginWorkbenchTab = defineAsyncComponent(() => import("@/components/plugins/PluginWorkbenchTab.vue"));
const QueryHistory = defineAsyncComponent(() => import("@/components/editor/QueryHistory.vue"));
const SqlLibraryPanel = defineAsyncComponent(() => import("@/components/layout/SqlLibraryPanel.vue"));
const SqlFilePanel = defineAsyncComponent(() => import("@/components/layout/SqlFilePanel.vue"));
@@ -416,6 +417,10 @@ const blockingAiRunCount = computed(() => (isDesktop ? blockingDesktopAiRunsForQ
let aiRunsQuitConfirmed = false;
const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId));
// Plugin workbench tabs stay mounted once opened (hidden via v-show): an
// iframe moved out of the DOM reloads from scratch, so KeepAlive/ContentArea
// remounts flash the whole webview and drop its live session state.
const mountedPluginWorkbenchTabs = computed(() => queryStore.tabs.filter((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench));
const tabNavigationHistory = ref(createTabNavigationHistory());
let pendingTabHistoryNavigationId: string | null = null;
let detachedEventUnlisteners: Array<() => void> = [];
@@ -870,10 +875,10 @@ function requestActiveEditorExecuteInNewResultTab() {
const specialPageTabs = computed(() => ({
settingsOpen: settingsPageTabOpen.value,
settingsActive: settingsStore.settingsPageActive,
driverStoreOpen: driverStoreTabOpen.value,
driverStoreActive: driverStoreActive.value,
pluginCenterOpen: pluginCenterTabOpen.value,
pluginCenterActive: pluginCenterActive.value,
driverStoreOpen: driverStoreTabOpen.value,
driverStoreActive: driverStoreActive.value,
driverUpdateCount: toolbarAgentDriverUpdateCount.value,
}));
provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(computed(() => !isDetachedWindowContext && (driverStoreActive.value || pluginCenterActive.value || settingsStore.settingsPageActive))));
@@ -903,10 +908,10 @@ provide(EDITOR_TOOLBAR_ACTIONS, {
specialPageTabs,
activateSettingsPage,
closeSettingsPage,
activateDriverStore: () => openDriverStorePage(),
closeDriverStore: closeDriverStorePage,
activatePluginCenter: () => openPluginCenterPage(pluginCenterFocus.value),
closePluginCenter: closePluginCenterPage,
activateDriverStore: () => openDriverStorePage(),
closeDriverStore: closeDriverStorePage,
});
// Upstream "preview changes" entry: dormant until the group toolbar wires the
@@ -990,8 +995,8 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
openSqlFilePath,
openDbFilePath,
openConnectionDeepLink,
openAiConfigDeepLink,
closeActiveSurface,
openAiConfigDeepLink,
});
const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose });
useVisibilityChange();
@@ -1089,7 +1094,7 @@ function closeSettingsPage() {
const driverStoreFocus = ref<DriverStoreFocus | null>(null);
function openDriverStorePage(target?: "agent" | "jdbc" | "storage" | "runtime" | DriverStoreFocus | null) {
function openDriverStorePage(target?: DriverStoreTab | DriverStoreFocus | null) {
if (typeof target === "string") {
driverStoreActiveTab.value = target;
driverStoreFocus.value = null;
@@ -1101,6 +1106,7 @@ function openDriverStorePage(target?: "agent" | "jdbc" | "storage" | "runtime" |
}
driverStoreTabOpen.value = true;
activateMainContentSurface("driverStore");
pluginCenterActive.value = false;
}
function closeDriverStorePage() {
@@ -1329,6 +1335,7 @@ watch(
if (id) newQueryContextSource.value = "tab";
if (id) activateQuerySurface();
else if (previousId) activateOpenSpecialPageFallback();
if (id && pluginCenterActive.value) pluginCenterActive.value = false;
const tab = id ? queryStore.tabs.find((candidate) => candidate.id === id) : undefined;
const selection = tab?.editorSelection;
selectedSql.value = tab && selection && selection.anchor !== selection.head ? tab.sql.slice(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head)) : "";
@@ -2308,6 +2315,7 @@ async function openConnectionDeepLink(url: string) {
if (!draft) return;
connectionStore.stopEditing();
connectionStore.stopCreatingConnectionInGroup();
connectionPluginProvider.value = null;
connectionDialogPrefill.value = draft;
showConnectionDialog.value = true;
} catch (e: any) {
@@ -2372,6 +2380,7 @@ function setConnectionDialogOpen(value: boolean) {
function openConnectionSettings(connectionId: string, initialTab: ConfigTab = "connection") {
if (!connectionStore.getConfig(connectionId)) return;
connectionPluginProvider.value = null;
connectionDialogInitialTab.value = initialTab;
connectionStore.startEditing(connectionId);
showConnectionDialog.value = true;
@@ -2398,6 +2407,8 @@ async function newQuery() {
} else if (connectionTarget.kind === "nacos-admin") {
await connectionStore.loadNacosNamespaces(target.connectionId);
queryStore.openNacosAdmin(target.connectionId);
} else if (connectionTarget.kind === "plugin-workbench") {
await queryStore.openPluginConnection(target.connectionId);
} else {
queryStore.createTab(target.connectionId, "", `${conn.name}:keys`, connectionTarget.kind);
}
@@ -3038,6 +3049,7 @@ function activateQueryTab(tabId: string): boolean {
dispatchBeforeTabSwitch(tabId);
if (!queryStore.activateTab(tabId)) return false;
activateQuerySurface();
pluginCenterActive.value = false;
return true;
}
@@ -3357,6 +3369,11 @@ async function initApp() {
onOptionalStateError: (error) => console.error("[STARTUP] settingsStore.initAiConfigs failed", error),
});
}
// Restored plugin tabs need the sidecar connection registry repopulated
// (see reconnectRestoredPluginTabs); kick it off before the heavier
// optional init so it races ahead of each plugin webview's first
// session/open. Fire-and-forget: it must never block startup.
void queryStore.reconnectRestoredPluginTabs();
await settingsStore.initDesktopSettings().catch(() => {});
if (isDesktop) {
updateWindowReady = true;
@@ -3651,6 +3668,7 @@ onUnmounted(() => {
:tab-bar-width="tabBarWidth"
:tab-bar-collapsed="tabBarCollapsed"
@activate-driver-store="openDriverStorePage"
@activate-plugin-center="openPluginCenterPage(pluginCenterFocus)"
@activate-settings-page="activateSettingsPage"
@activate-tab="activateQueryTab"
@close-driver-store="closeDriverStorePage"
@@ -3707,9 +3725,10 @@ onUnmounted(() => {
</DialogContent>
</Dialog>
<div v-show="!driverStoreActive && !pluginCenterActive && !settingsStore.settingsPageActive" class="flex min-h-0 min-w-0 flex-1 flex-col">
<div class="flex flex-col flex-1 min-h-0">
<div class="flex flex-col min-h-0" :class="activeTab?.mode === 'plugin-workbench' ? 'flex-none' : 'flex-1'">
<SqlEditorWorkspace
ref="contentAreaRef"
:content-suppressed="activeTab?.mode === 'plugin-workbench'"
@locate-tab="locateTabInSidebar"
@close-tab="
(tabId: string) => {
@@ -3850,6 +3869,15 @@ onUnmounted(() => {
</template>
</SqlEditorWorkspace>
</div>
<!-- Always-mounted plugin workbench layer: switching tabs only
toggles visibility, so plugin webviews (SSH terminals) are
never destroyed and reloaded. SqlEditorWorkspace no longer
renders plugin tabs — this layer owns them. It in turn
yields the layout (display:none) while a plugin tab is
active, or both flex-1 siblings would split the column. -->
<div v-for="workbenchTab in mountedPluginWorkbenchTabs" :key="workbenchTab.id" v-show="activeTab && workbenchTab.id === activeTab.id" class="flex min-h-0 flex-1 flex-col">
<PluginWorkbenchTab :plugin-id="workbenchTab.pluginWorkbench!.pluginId" :contribution-id="workbenchTab.pluginWorkbench!.contributionId" :context="workbenchTab.pluginWorkbench!.context" @close-tab="queryStore.closeTab(workbenchTab.id)" />
</div>
</div>
</div>
</div>
@@ -1443,8 +1443,8 @@ watch(driverStoreTab, (tab) => {
<div class="driver-store-scroll flex-1 min-h-0 overflow-y-auto">
<div class="driver-store-container max-w-4xl mx-auto px-6 py-6">
<Tabs v-model="driverStoreTab" default-value="agent" class="driver-store-tabs-root">
<div class="driver-store-header flex items-center justify-between">
<TabsList class="driver-store-tabs grid w-[360px] grid-cols-3">
<div class="driver-store-header flex flex-wrap items-center justify-between gap-2">
<TabsList class="driver-store-tabs grid w-full max-w-[380px] grid-cols-3">
<TabsTrigger value="agent" class="gap-1.5 relative">
{{ t("driverStore.agentDrivers") }}
<span v-if="agentTabUpdateCount > 0" class="inline-block h-2 w-2 rounded-full bg-red-500" />
@@ -2263,7 +2263,7 @@ watch(driverStoreTab, (tab) => {
.driver-store-tabs {
display: grid !important;
width: 360px !important;
width: 480px !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
@@ -92,6 +92,7 @@ import { assertCompleteDatabaseCategories, databaseSelectionForCategory } from "
import { loadConnectionPickerView, saveConnectionPickerView, type DbPickerView } from "@/lib/connection/connectionPickerViewPreference";
import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv";
import { normalizeRabbitmqAddresses, parseRabbitmqAddress } from "@/lib/connection/rabbitmqAddresses";
import { pluginFieldIsRequired, pluginFieldIsVisible } from "@/lib/plugins/pluginFieldConditions";
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
import { driverInstallProgressChannel, driverInstallProgressPercent, isDriverInstallProgressForOperation, requestAgentInstallCancellation, resolveAgentInstallOutcome, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityConfig, sqlServerUsesLegacyCompatibility, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
@@ -255,6 +256,8 @@ type LegacyTransportFields = {
type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields;
type ConnectionForm = Omit<ConnectionConfig, "id">;
type ConnectionTestState = ConnectionTestResult & { ok: boolean; scope?: "connection" | "ssh" };
type PluginActionStatus = { ok: boolean; message: string };
type SaveConnectionOptions = { connectAfterSave?: boolean; closeOnSuccess?: boolean };
const { t, locale: appLocale } = useI18n();
const { toast } = useToast();
@@ -328,11 +331,9 @@ const connectionErrorDetail = ref("");
const testResultCopied = ref(false);
const connectionErrorCopied = ref(false);
const editingId = ref<string | null>(null);
const installedPlugins = ref<InstalledPlugin[]>([]);
const pluginFormValues = ref<Record<string, PluginFormFieldValue>>({});
const pluginLoadError = ref("");
const runningPluginActionId = ref<string | null>(null);
type PluginActionStatus = { ok: boolean; message: string };
const pluginActionStatus = ref<PluginActionStatus | null>(null);
const draftTestConnectionId = ref(uuid());
const showVisibleDatabasesDialog = ref(false);
@@ -365,6 +366,7 @@ const isLoadingVisibleSchemas = ref(false);
const visibleSchemaNames = ref<string[]>([]);
const visibleSchemaInitialSelection = ref<string[]>([]);
const visibleSchemaError = ref("");
const installedPlugins = ref<InstalledPlugin[]>([]);
let testRunId = 0;
let unlistenAgentInstallProgress: (() => void) | null = null;
@@ -759,6 +761,13 @@ const keepaliveEnabled = computed({
const selectedTransportLayerId = ref<string | null>(null);
const draggedTransportLayerId = ref<string | null>(null);
const selectedType = ref("mysql");
const pluginRegistry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
const pluginConnectionProviders = computed(() => pluginRegistry.value.listConnectionProviders());
const selectedPluginProvider = computed(() => pluginProviderEntryForOption(selectedType.value));
const selectedPluginIcon = computed(() => (selectedPluginProvider.value ? pluginConnectionProviderIcon(selectedPluginProvider.value) : undefined));
const isPluginConnection = computed(() => form.value.db_type === "plugin");
const pluginFooterActions = computed<PluginConnectionAction[]>(() => (selectedPluginProvider.value ? pluginConnectionActionsForDialog(selectedPluginProvider.value.contribution, !!editingId.value) : []));
const pluginFooterBusy = computed(() => isTesting.value || isSaving.value || !!runningPluginActionId.value);
const customDriverName = ref("");
const mongoUseUrl = ref(false);
const jdbcDriverPathsInput = ref("");
@@ -1190,13 +1199,6 @@ function selectedProfile() {
return driverProfiles[profile] ?? driverProfiles.mysql;
}
const isPluginConnection = computed(() => form.value.db_type === "plugin");
const pluginConnectionProviders = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value).listConnectionProviders());
const selectedPluginProvider = computed(() => pluginProviderEntryForOption(selectedType.value));
const selectedPluginIcon = computed(() => (selectedPluginProvider.value ? pluginConnectionProviderIcon(selectedPluginProvider.value) : undefined));
const pluginFooterActions = computed<PluginConnectionAction[]>(() => (selectedPluginProvider.value ? pluginConnectionActionsForDialog(selectedPluginProvider.value.contribution, !!editingId.value) : []));
const pluginFooterBusy = computed(() => isTesting.value || isSaving.value || !!runningPluginActionId.value);
function pluginProviderEntry(pluginId: string, providerId: string) {
return pluginConnectionProviders.value.find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId) || null;
}
@@ -1206,62 +1208,6 @@ function pluginProviderEntryForOption(value: string) {
return target ? pluginProviderEntry(target.pluginId, target.providerId) : null;
}
function applyPluginProvider(value: string, preserveConnectionFields = false, existing?: ConnectionConfig): boolean {
const target = parsePluginConnectionProviderOptionValue(value);
if (!target) return false;
const entry = pluginProviderEntry(target.pluginId, target.providerId);
if (!entry) return false;
selectedType.value = value;
if (!preserveConnectionFields) {
form.value = {
...defaultForm(),
name: entry.contribution.label,
db_type: "plugin",
driver_profile: "plugin",
driver_label: entry.contribution.label,
host: "",
port: 0,
username: "",
password: "",
query_timeout_secs: 60,
};
} else {
form.value.db_type = "plugin";
form.value.driver_profile = "plugin";
form.value.driver_label = entry.contribution.label;
}
pluginFormValues.value = pluginConnectionFormValues(entry.contribution, existing);
if (!preserveConnectionFields) {
const nameField = entry.contribution.fields.find((field) => field.binding === "name");
const defaultName = nameField ? pluginFormValues.value[nameField.key] : undefined;
if (typeof defaultName === "string" && defaultName.trim()) form.value.name = defaultName.trim();
}
connectionUrlInput.value = "";
appliedConnectionUrlInput.value = "";
return true;
}
async function loadInstalledPlugins() {
pluginLoadError.value = "";
try {
installedPlugins.value = await api.listPlugins();
} catch (cause) {
installedPlugins.value = [];
pluginLoadError.value = cause instanceof Error ? cause.message : String(cause);
}
}
function applyRequestedPluginProvider(target: PluginCenterFocus | null | undefined) {
if (!target?.pluginId || !target.providerId) return false;
const value = pluginConnectionProviderOptionValue(target.pluginId, target.providerId);
if (!applyPluginProvider(value)) return false;
selectedDbCategory.value = "plugins";
dialogStep.value = "config";
configTab.value = "connection";
return true;
}
function mqExtraRecord(config?: Partial<MqAdminConfig>): Record<string, unknown> {
const extra = config?.extra;
return extra && typeof extra === "object" && !Array.isArray(extra) ? (extra as Record<string, unknown>) : {};
@@ -2589,6 +2535,62 @@ function applyProfile(val: string, preserveConnectionFields = false) {
}
}
function applyPluginProvider(value: string, preserveConnectionFields = false, existing?: ConnectionConfig): boolean {
const target = parsePluginConnectionProviderOptionValue(value);
if (!target) return false;
const entry = pluginProviderEntry(target.pluginId, target.providerId);
if (!entry) return false;
selectedType.value = value;
if (!preserveConnectionFields) {
form.value = {
...defaultForm(),
name: entry.contribution.label,
db_type: "plugin",
driver_profile: "plugin",
driver_label: entry.contribution.label,
host: "",
port: 0,
username: "",
password: "",
query_timeout_secs: 60,
};
} else {
form.value.db_type = "plugin";
form.value.driver_profile = "plugin";
form.value.driver_label = entry.contribution.label;
}
pluginFormValues.value = pluginConnectionFormValues(entry.contribution, existing);
if (!preserveConnectionFields) {
const nameField = entry.contribution.fields.find((field) => field.binding === "name");
const defaultName = nameField ? pluginFormValues.value[nameField.key] : undefined;
if (typeof defaultName === "string" && defaultName.trim()) form.value.name = defaultName.trim();
}
connectionUrlInput.value = "";
appliedConnectionUrlInput.value = "";
return true;
}
async function loadInstalledPlugins() {
pluginLoadError.value = "";
try {
installedPlugins.value = await api.listPlugins();
} catch (cause) {
installedPlugins.value = [];
pluginLoadError.value = cause instanceof Error ? cause.message : String(cause);
}
}
function applyRequestedPluginProvider(target: PluginCenterFocus | null | undefined) {
if (!target?.pluginId || !target.providerId) return false;
const value = pluginConnectionProviderOptionValue(target.pluginId, target.providerId);
if (!applyPluginProvider(value)) return false;
selectedDbCategory.value = "plugins";
dialogStep.value = "config";
configTab.value = "connection";
return true;
}
function switchOceanbaseMode(mode: "mysql" | "oracle") {
oceanbaseSubMode.value = mode;
if (mode === "mysql") {
@@ -2626,7 +2628,7 @@ watch(
name: config.name,
note: config.note || "",
db_type: oceanbasePatch?.db_type || profileConfig?.type || config.db_type,
driver_profile: oceanbasePatch?.driver_profile || config.driver_profile || profile,
driver_profile: config.db_type === "plugin" ? "plugin" : oceanbasePatch?.driver_profile || config.driver_profile || profile,
driver_label: config.driver_label || oceanbasePatch?.driver_label || driverProfiles[profile]?.label || config.db_type,
url_params: config.url_params || "",
agent_java_options: config.agent_java_options || [],
@@ -2770,6 +2772,7 @@ watch(
productionProtectionEnabled.value = false;
selectedTransportLayerId.value = null;
selectedType.value = "mysql";
pluginFormValues.value = {};
customDriverName.value = "";
resetMqFields();
resetCassandraTlsFields(undefined);
@@ -3013,10 +3016,10 @@ const dbCategoryMetadata: Array<{ key: DbCategoryKey; titleKey: string }> = [
{ key: "registry_config", titleKey: "connection.databaseCategoryRegistryConfig" },
];
function jdbcProductCategory(profileId: string): DbCategoryKey {
const category = dbCategoryMetadata.find(({ key }) => jdbcProductProfileIdsForCategory(key).includes(profileId))?.key;
if (!category) throw new Error(`JDBC product profile ${profileId} has no connection picker category`);
return category;
function jdbcProductCategory(profileId: string): ConnectionProfileCategory {
const category: DbCategoryKey | undefined = dbCategoryMetadata.find(({ key }) => jdbcProductProfileIdsForCategory(key).includes(profileId))?.key;
if (category !== "plugins" && category) return category;
throw new Error(`JDBC product profile ${profileId} has no connection picker category`);
}
// `influxdb3` is presented as a version option inside the InfluxDB card
@@ -3092,7 +3095,7 @@ const selectedDbOptionIsVisible = computed(() => visibleDbCategories.value.some(
function selectDbCategory(category: DbCategoryKey) {
selectedDbCategory.value = category;
dbSearchQuery.value = "";
const categoryOptions = dbCategoryDefinitions.find((definition) => definition.key === category)?.optionValues ?? [];
const categoryOptions = dbCategories.value.find((definition) => definition.key === category)?.options.map((option) => option.value) ?? [];
const nextSelection = databaseSelectionForCategory(selectedType.value, categoryOptions);
if (nextSelection && nextSelection !== selectedType.value) onDbTypeChange(nextSelection);
}
@@ -3573,6 +3576,32 @@ const pluginActionStatusMessage = computed(() => {
if (!pluginActionStatus.value) return "";
return pluginActionStatus.value.ok ? pluginActionStatus.value.message : translateBackendError(t, pluginActionStatus.value.message);
});
const agentInstallPercent = computed(() => driverInstallProgressPercent(agentInstallProgress.value));
const agentInstallProgressLabel = computed(() => {
const progress = agentInstallProgress.value;
if (agentInstallError.value) return t("connection.driverInstall.statusFailed");
if (!agentInstallRunning.value) return t("connection.driverInstall.statusWaiting");
if (!progress) return t("connection.driverInstall.statusPreparing");
if (progress.step === "jre-extract") return t("connection.driverInstall.statusExtractingJre");
const label = progress.step === "jre" ? t("connection.driverInstall.stepJre") : progress.step === "driver" ? t("connection.driverInstall.stepDriver") : progress.step || t("connection.driverInstall.stepDefault");
if (!progress.total) return `${label}...`;
return `${label} ${formatInstallSize(progress.downloaded ?? 0)} / ${formatInstallSize(progress.total)} (${agentInstallPercent.value ?? 0}%)`;
});
const canCloseAgentInstallDialog = computed(() => !agentInstallRunning.value || !!agentInstallError.value);
const sqlServerDriverMode = computed<"auto" | "legacy">(() => (sqlServerUsesLegacyCompatibility(form.value) ? "legacy" : "auto"));
const shouldUseWideConnectionDialog = computed(
() => dialogStep.value === "config" && (canChooseVisibleDatabases.value || canChooseVisibleNacosNamespaces.value || (canChooseVisibleSchemas.value && !visibleFilterUsesSchemas.value) || (selectedPluginProvider.value?.contribution.fields.length ?? 0) >= 6),
);
const connectionDialogContentClass = computed(() => {
if (dialogStep.value === "select") return "connection-dialog-content--picker sm:h-[720px] sm:max-w-[880px]";
const widthClass = shouldUseWideConnectionDialog.value ? "connection-dialog-content--wide sm:max-w-[660px]" : "connection-dialog-content--standard sm:max-w-[560px]";
const scrollableAdvancedNacos = form.value.db_type === "nacos" && configTab.value === "advanced";
return `${widthClass} connection-dialog-content--config${scrollableAdvancedNacos ? " connection-dialog-content--scrollable" : ""}`;
});
const connectionLabelClass = "justify-self-start text-left";
const connectionLabelSmallClass = `${connectionLabelClass} text-xs`;
const connectionLabelTopClass = `${connectionLabelClass} mt-2`;
const connectionLabelSmallPaddedClass = `${connectionLabelClass} pt-2 text-xs`;
function pluginFieldValue(field: PluginFormField): PluginFormFieldValue {
if (field.binding === "name") return form.value.name;
@@ -3584,6 +3613,19 @@ function pluginFieldHasValue(field: PluginFormField): boolean {
return typeof value === "string" ? value.trim().length > 0 : value !== undefined;
}
function pluginFieldValueByKey(key: string): PluginFormFieldValue {
const field = selectedPluginProvider.value?.contribution.fields.find((candidate) => candidate.key === key);
return field ? pluginFieldValue(field) : undefined;
}
function pluginFieldVisible(field: PluginFormField): boolean {
return pluginFieldIsVisible(field, pluginFieldValueByKey, (key) => selectedPluginProvider.value?.contribution.fields.find((candidate) => candidate.key === key));
}
function pluginFieldRequired(field: PluginFormField): boolean {
return pluginFieldIsRequired(field, pluginFieldValueByKey);
}
function pluginActionLabel(action: PluginConnectionAction): string {
if (action.label) return action.label;
if (action.kind === "test") return t("connection.test");
@@ -3633,34 +3675,11 @@ function applyPluginActionFieldValues(values: Record<string, PluginFormFieldValu
}
pluginFormValues.value = nextValues;
}
const agentInstallPercent = computed(() => driverInstallProgressPercent(agentInstallProgress.value));
const agentInstallProgressLabel = computed(() => {
const progress = agentInstallProgress.value;
if (agentInstallError.value) return t("connection.driverInstall.statusFailed");
if (!agentInstallRunning.value) return t("connection.driverInstall.statusWaiting");
if (!progress) return t("connection.driverInstall.statusPreparing");
if (progress.step === "jre-extract") return t("connection.driverInstall.statusExtractingJre");
const label = progress.step === "jre" ? t("connection.driverInstall.stepJre") : progress.step === "driver" ? t("connection.driverInstall.stepDriver") : progress.step || t("connection.driverInstall.stepDefault");
if (!progress.total) return `${label}...`;
return `${label} ${formatInstallSize(progress.downloaded ?? 0)} / ${formatInstallSize(progress.total)} (${agentInstallPercent.value ?? 0}%)`;
});
const canCloseAgentInstallDialog = computed(() => !agentInstallRunning.value || !!agentInstallError.value);
const sqlServerDriverMode = computed<"auto" | "legacy">(() => (sqlServerUsesLegacyCompatibility(form.value) ? "legacy" : "auto"));
const shouldUseWideConnectionDialog = computed(() => dialogStep.value === "config" && (canChooseVisibleDatabases.value || canChooseVisibleNacosNamespaces.value || (canChooseVisibleSchemas.value && !visibleFilterUsesSchemas.value)));
const connectionDialogContentClass = computed(() => {
if (dialogStep.value === "select") return "connection-dialog-content--picker sm:h-[720px] sm:max-w-[880px]";
const widthClass = shouldUseWideConnectionDialog.value ? "connection-dialog-content--wide sm:max-w-[660px]" : "connection-dialog-content--standard sm:max-w-[560px]";
const scrollableAdvancedNacos = form.value.db_type === "nacos" && configTab.value === "advanced";
return `${widthClass} connection-dialog-content--config${scrollableAdvancedNacos ? " connection-dialog-content--scrollable" : ""}`;
});
const connectionLabelClass = "justify-self-start text-left";
const connectionLabelSmallClass = `${connectionLabelClass} text-xs`;
const connectionLabelTopClass = `${connectionLabelClass} mt-2`;
const connectionLabelSmallPaddedClass = `${connectionLabelClass} pt-2 text-xs`;
const hasRequiredConnectionTarget = computed(() => {
if (isPluginConnection.value) {
const provider = selectedPluginProvider.value?.contribution;
return !!provider && provider.fields.every((field) => !field.required || pluginFieldHasValue(field));
const entry = selectedPluginProvider.value;
return !!entry && entry.contribution.fields.every((field) => !pluginFieldVisible(field) || !pluginFieldRequired(field) || pluginFieldHasValue(field));
}
if (form.value.db_type === "mq") {
if (mqSystemKind.value === "kafka") return mqKafkaConnectionSource.value === "zookeeper" ? !!mqKafkaZooKeeperServers.value.trim() : !!mqKafkaBootstrapServers.value.trim();
@@ -4061,11 +4080,12 @@ function connectionConfigForSshTunnelTest(id: string): ConnectionConfig {
}
function connectionConfigForSubmit(id: string, generatedName = "", validatePluginRequired = true): ConnectionConfig {
let config: LegacyConnectionConfig;
if (isPluginConnection.value) {
const entry = selectedPluginProvider.value;
if (!entry) throw new Error(pluginLoadError.value || t("connection.pluginProviderUnavailable"));
if (validatePluginRequired) {
const missingField = entry.contribution.fields.find((field) => field.required && !pluginFieldHasValue(field));
const missingField = entry.contribution.fields.find((field) => pluginFieldVisible(field) && pluginFieldRequired(field) && !pluginFieldHasValue(field));
if (missingField) throw new Error(t("connection.pluginRequiredField", { field: missingField.label }));
}
const values = { ...pluginFormValues.value };
@@ -4073,10 +4093,23 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
if (field.binding === "name") values[field.key] = form.value.name;
}
const existing = props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id === entry.plugin.manifest.id && props.editConfig.plugin_connection_provider === entry.contribution.id ? props.editConfig : undefined;
const config = buildPluginConnectionConfig(entry.plugin.manifest.id, entry.contribution, values, existing);
return { ...formValueForSubmit(), ...config, id, name: form.value.name.trim() || generatedName.trim() || config.name, note: form.value.note?.trim() || undefined };
config = buildPluginConnectionConfig(entry.plugin.manifest.id, entry.contribution, values, existing) as LegacyConnectionConfig;
config.id = id;
config.name = form.value.name.trim() || config.name;
config.note = form.value.note;
config.color = form.value.color;
config.transport_layers = form.value.transport_layers || [];
config.connect_timeout_secs = form.value.connect_timeout_secs;
config.query_timeout_secs = form.value.query_timeout_secs;
config.idle_timeout_secs = form.value.idle_timeout_secs;
config.keepalive_interval_secs = form.value.keepalive_interval_secs;
config.read_only = form.value.read_only;
config.save_password = form.value.save_password;
config.is_production = form.value.is_production;
config.production_databases = form.value.production_databases;
} else {
config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
}
const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
config.database_info = undefined;
config.database = normalizeStoredConnectionDatabase(config.db_type, config.database);
config.note = config.note?.trim() || undefined;
@@ -4286,6 +4319,9 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
setGaussdbTargetServerType(config, targetServerType);
setGaussdbCountQueryDop(config, countQueryDop);
} else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile)) {
// Plugin connections keep `external_config`: the manifest-driven form
// fields land there via buildPluginConnectionConfig. Only the built-in
// drivers without an external-config payload get wiped here.
config.external_config = undefined;
}
if (config.db_type === "mongodb" && !mongoUseUrl.value) {
@@ -4382,12 +4418,6 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
config.port = firstEndpoint.port;
}
config.database = undefined;
config.ca_cert_path = config.ca_cert_path?.trim() || "";
config.client_cert_path = config.client_cert_path?.trim() || "";
config.client_key_path = config.client_key_path?.trim() || "";
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
}
if (config.db_type === "etcd") {
config.etcd_endpoints = normalizeEndpointLines(config.etcd_endpoints || "");
@@ -4402,7 +4432,14 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
} else if (form.value.db_type !== "consul" && config.db_type !== "zookeeper" && config.db_type !== "elasticsearch" && config.db_type !== "easysearch") {
} else if (config.db_type === "zookeeper") {
config.etcd_endpoints = undefined;
config.client_cert_path = config.client_cert_path?.trim() || "";
config.client_key_path = config.client_key_path?.trim() || "";
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
} else if (form.value.db_type !== "consul" && config.db_type !== "elasticsearch" && config.db_type !== "easysearch") {
config.etcd_endpoints = undefined;
config.client_cert_path = undefined;
config.client_key_path = undefined;
@@ -4751,6 +4788,7 @@ function resetTestState() {
isTesting.value = false;
isTestingSshTunnel.value = false;
testResult.value = null;
pluginActionStatus.value = null;
clearTestedConnectionInfo();
showConnectionErrorDialog.value = false;
connectionErrorRawDetail.value = "";
@@ -5290,6 +5328,7 @@ function resetForm(options: { preservePickerState?: boolean } = {}) {
selectedTransportLayerId.value = null;
draggedTransportLayerId.value = null;
selectedType.value = "mysql";
pluginFormValues.value = {};
customDriverName.value = "";
mongoUseUrl.value = false;
resetMqFields();
@@ -5425,6 +5464,19 @@ watch(
resetForm();
if (props.prefillConfig) applyConnectionPrefill(props.prefillConfig);
}
void loadInstalledPlugins().then(() => {
if (!open.value) return;
if (props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id && props.editConfig.plugin_connection_provider) {
const entry = pluginProviderEntry(props.editConfig.plugin_id, props.editConfig.plugin_connection_provider);
if (entry) {
selectedType.value = pluginConnectionProviderOptionValue(props.editConfig.plugin_id, props.editConfig.plugin_connection_provider);
selectedDbCategory.value = "plugins";
pluginFormValues.value = pluginConnectionFormValues(entry.contribution, props.editConfig);
}
} else if (!props.prefillConfig) {
applyRequestedPluginProvider(props.pluginProvider);
}
});
if (!props.prefillConfig?.oneTime) {
void loadJdbcDrivers();
void loadAgentDrivers();
@@ -5713,83 +5765,74 @@ async function persistConnectionNoteVisibilityDraft() {
await persistConnectionNoteVisibilityDraftState(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes, (value) => settingsStore.updateEditorSettingsAndPersist({ sidebarShowConnectionNotes: value }));
}
async function save(options: { connectAfterSave?: boolean; closeOnSuccess?: boolean } = {}) {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (isSaving.value) return;
function startSavedConnection(config: ConnectionConfig) {
emit("connectStarted", config.name);
void store
.connect(config)
.then(() => {
emit("connectSucceeded", config.name);
})
.catch((e: any) => {
const message = String(e?.message || e);
if (message.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
// Keep failed one-time connections available for retry and error inspection.
// They are still removed by connectionStore.disconnect after a successful session.
emit("connectFailed", appendConnectionErrorHints(config, mongodbAuthFailureHint(message), t));
});
}
async function save(options: SaveConnectionOptions = {}): Promise<boolean> {
if (!ensureConnectionHostResolvedFromUrl()) return false;
if (isSaving.value) return false;
if (!hasNacosNamespaceScopeForSave()) {
testResult.value = null;
await openVisibleNacosNamespacesPicker();
return;
return false;
}
const connectAfterSave = options.connectAfterSave ?? (!editingId.value && form.value.db_type !== "jdbc");
const wasEditing = !!editingId.value;
const connectAfterSave = options.connectAfterSave ?? (!wasEditing && !isJdbcConnection.value);
const closeOnSuccess = options.closeOnSuccess ?? true;
const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value;
isSaving.value = true;
let connectionSaved = false;
try {
let savedConfig: ConnectionConfig;
if (editingId.value) {
const updated = withSavedDatabaseInfo(connectionConfigForSubmit(editingId.value), databaseInfoForSave);
await ensureRequiredAgentDriverInstalled(updated);
await ensureRequiredGaussdbMJdbcRuntime(updated);
await persistGlobalTimeoutDrafts();
await store.updateConnection(updated);
savedConfig = updated;
connectionSaved = true;
await persistConnectionNoteVisibilityDraft();
store.stopEditing();
if (connectAfterSave) {
if (closeOnSuccess) {
open.value = false;
await nextTick();
}
emit("connectStarted", updated.name);
void store
.connect(updated)
.then(() => {
emit("connectSucceeded", updated.name);
})
.catch((e: any) => {
const message = String(e?.message || e);
if (message.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
emit("connectFailed", appendConnectionErrorHints(updated, mongodbAuthFailureHint(message), t));
});
return;
}
} else {
const config = withSavedDatabaseInfo(connectionConfigForSubmit(draftTestConnectionId.value), databaseInfoForSave);
await ensureRequiredAgentDriverInstalled(config);
await ensureRequiredGaussdbMJdbcRuntime(config);
await persistGlobalTimeoutDrafts();
await store.addConnection(config, selectedConnectionGroupId.value);
savedConfig = config;
connectionSaved = true;
await persistConnectionNoteVisibilityDraft();
draftTestConnectionId.value = uuid();
if (!connectAfterSave) {
if (closeOnSuccess) open.value = false;
return;
}
if (closeOnSuccess) open.value = false;
await nextTick();
emit("connectStarted", config.name);
void store
.connect(config)
.then(() => {
emit("connectSucceeded", config.name);
})
.catch((e: any) => {
const message = String(e?.message || e);
if (message.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
// Keep failed one-time connections available for retry and error inspection.
// They are still removed by connectionStore.disconnect after a successful session.
emit("connectFailed", appendConnectionErrorHints(config, mongodbAuthFailureHint(message), t));
});
return;
}
if (closeOnSuccess) open.value = false;
if (closeOnSuccess) {
if (wasEditing) store.stopEditing();
open.value = false;
await nextTick();
} else if (!wasEditing) {
editingId.value = savedConfig.id;
store.startEditing(savedConfig.id);
}
if (connectAfterSave && savedConfig.db_type !== "jdbc") startSavedConnection(savedConfig);
return true;
} catch (e: any) {
const cause = mongodbAuthFailureHint(String(e?.message || e));
const message = connectionSaved ? t("connection.savedSettingsFailed", { message: cause }) : cause;
testResult.value = { ok: false, message };
showConnectionError(message);
return false;
} finally {
isSaving.value = false;
}
@@ -6442,7 +6485,15 @@ function openExternalUrl(url: string) {
</div>
<template v-if="isPluginConnection">
<PluginConnectionFields v-if="selectedPluginProvider" :model-value="pluginFormValues" :contribution="selectedPluginProvider.contribution" :hidden-bindings="['name']" layout="connection-dialog" @update:model-value="updatePluginFormValues" />
<PluginConnectionFields
v-if="selectedPluginProvider"
:model-value="pluginFormValues"
:contribution="selectedPluginProvider.contribution"
:hidden-bindings="['name']"
layout="connection-dialog"
:plugin-id="selectedPluginProvider.plugin.manifest.id"
@update:model-value="updatePluginFormValues"
/>
<div v-else class="col-span-full rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
{{ pluginLoadError || t("connection.pluginProviderUnavailable") }}
</div>
@@ -8416,7 +8467,7 @@ function openExternalUrl(url: string) {
</Tooltip>
</div>
<p class="text-[11px] leading-4 text-muted-foreground">
{{ t("connection.clientCertHint") }}
{{ t("connection.etcdClientCertHint") }}
</p>
</div>
</div>
@@ -9313,7 +9364,7 @@ function openExternalUrl(url: string) {
<Button variant="outline" class="shrink-0" :disabled="isTesting || isTestingSshTunnel || isSaving" @click="testConnection">
{{ isTesting ? t("connection.testing") : t("connection.test") }}
</Button>
<Button class="shrink-0" @click="save" :disabled="isSaving || isTestingSshTunnel || !hasRequiredConnectionTarget">
<Button class="shrink-0" @click="save()" :disabled="isSaving || isTestingSshTunnel || !hasRequiredConnectionTarget">
{{ isSaving ? t("common.loading") : editingId || isJdbcConnection ? t("connection.save") : t("connection.saveAndConnect") }}
</Button>
</template>
@@ -28,6 +28,7 @@ const props = defineProps<{
const emit = defineEmits<{
"activate-driver-store": [];
"close-driver-store": [];
"activate-plugin-center": [];
"close-plugin-center": [];
"activate-settings-page": [];
"close-settings-page": [];
@@ -130,6 +131,9 @@ function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) {
if (keep !== "driverStore" && props.driverStoreOpen) {
emit("close-driver-store");
}
if (keep !== "pluginCenter" && props.pluginCenterOpen) {
emit("close-plugin-center");
}
if (keep !== "settings" && props.settingsPageOpen) {
emit("close-settings-page");
}
@@ -201,6 +205,9 @@ function handleCancelClose() {
<div v-show="driverStoreActive || pluginCenterActive || settingsPageActive" data-special-page-workspace class="flex min-h-0 min-w-0 flex-1 overflow-hidden" :class="layoutClass">
<div data-special-page-navigation class="flex min-h-0 min-w-0 shrink-0 flex-col overflow-auto" :style="navigationStyle">
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-special-page-tab-target="group.id" class="flex min-h-0 min-w-0" :class="isVerticalLayout ? 'flex-1' : 'shrink-0'" />
<button v-if="pluginCenterOpen" type="button" data-plugin-center-tab class="shrink-0 rounded-md px-2 py-1 text-left text-sm" :class="pluginCenterActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" @click="emit('activate-plugin-center')">
{{ t("toolbar.pluginCenter") }}
</button>
</div>
<div data-special-page-content class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<slot />
@@ -61,8 +61,6 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
import QueryMessagesView from "@/components/layout/QueryMessagesView.vue";
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
import PluginFilesystemTab from "@/components/plugins/PluginFilesystemTab.vue";
import PluginWorkbenchTab from "@/components/plugins/PluginWorkbenchTab.vue";
import ResultSetNavigator from "@/components/layout/ResultSetNavigator.vue";
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
@@ -123,8 +121,10 @@ const MySqlDashboard = defineAsyncComponent(() => import("@/components/admin/MyS
const PostgresDashboard = defineAsyncComponent(() => import("@/components/admin/PostgresDashboard.vue"));
const XuguServerDashboard = defineAsyncComponent(() => import("@/components/admin/XuguServerDashboard.vue"));
const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
const DamengUserAdmin = defineAsyncComponent(() => import("@/components/admin/DamengUserAdmin.vue"));
const DamengRoleAdmin = defineAsyncComponent(() => import("@/components/admin/DamengRoleAdmin.vue"));
const PluginFilesystemTab = defineAsyncComponent(() => import("@/components/plugins/PluginFilesystemTab.vue"));
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
@@ -210,10 +210,6 @@ type SearchableBrowserHandle = {
executeCommand?: (command: string) => Promise<boolean>;
};
type PluginTabHandle = {
refresh?: () => Promise<void> | void;
};
type ElasticsearchJsonResponsePanelHandle = {
focusSearch: () => boolean;
};
@@ -329,8 +325,7 @@ const consulOverviewRef = ref<{ refresh?: () => boolean }>();
const consulWorkspaceRef = ref<SearchableBrowserHandle>();
const databaseBrowserRef = ref<SearchableBrowserHandle>();
const objectBrowserRef = ref<SearchableBrowserHandle>();
const pluginWorkbenchRef = ref<PluginTabHandle>();
const pluginFilesystemRef = ref<PluginTabHandle>();
const pluginFilesystemTabRef = ref<{ refresh: () => Promise<unknown> }>();
const activeTableMeta = computed(() => props.activeTab.tableMeta);
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
const activeResultExecutionTarget = computed(() => queryStore.activeResultExecutionTarget(props.activeTab.id));
@@ -988,10 +983,8 @@ function refreshData(): boolean {
if (props.activeTab.mode === "consul-overview") return consulOverviewRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "databases") return databaseBrowserRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "plugin-workbench" || props.activeTab.mode === "plugin-filesystem") {
const pluginTab = props.activeTab.mode === "plugin-workbench" ? pluginWorkbenchRef.value : pluginFilesystemRef.value;
if (!pluginTab?.refresh) return false;
void pluginTab.refresh();
if (props.activeTab.mode === "plugin-filesystem") {
void pluginFilesystemTabRef.value?.refresh();
return true;
}
// Restored data tabs intentionally omit row data, so refresh must work before DataGrid mounts.
@@ -1219,7 +1212,6 @@ function handleModRTarget(target: Element): boolean {
if (target.closest("[data-cell-detail-editor-root]")) return dataGridRef.value?.openCellDetailSearch() ?? false;
if (target.closest("[data-grid-root], [data-elasticsearch-json-response-root]")) return refreshData();
if (canReloadUnavailableDataTab(props.activeTab)) return refreshData();
if (props.activeTab.mode === "plugin-workbench" || props.activeTab.mode === "plugin-filesystem") return refreshData();
return false;
}
@@ -2571,23 +2563,13 @@ defineExpose({
</div>
</template>
<template v-else-if="activeTab.mode === 'plugin-workbench' && activeTab.pluginWorkbench">
<div class="min-w-0 flex-1 min-h-0 bg-background">
<PluginWorkbenchTab
ref="pluginWorkbenchRef"
:key="activeTab.id"
:plugin-id="activeTab.pluginWorkbench.pluginId"
:contribution-id="activeTab.pluginWorkbench.contributionId"
:connection-id="activeTab.connectionId || undefined"
:context="activeTab.pluginWorkbench.context"
@close-tab="emit('closeTab', activeTab.id)"
/>
</div>
</template>
<!-- plugin-workbench tabs render in the always-mounted layer in App.vue
(v-show visibility): remounting the webview per tab switch would
reload the plugin iframe and drop its live session state. -->
<template v-else-if="activeTab.mode === 'plugin-filesystem' && activeTab.pluginFilesystem">
<div class="flex h-full min-h-0 min-w-0 flex-col">
<div class="flex h-full min-h-0 flex-col">
<PluginFilesystemTab
ref="pluginFilesystemRef"
ref="pluginFilesystemTabRef"
:key="activeTab.id"
:plugin-id="activeTab.pluginFilesystem.pluginId"
:provider-id="activeTab.pluginFilesystem.providerId"
@@ -30,6 +30,8 @@ const props = defineProps<
tabBarCollapsed?: boolean;
canDetachTabs?: boolean;
detachedDropTarget?: boolean;
/** Collapse the group to its tab strip while a plugin workbench tab owns the layout. */
contentSuppressed?: boolean;
}
>();
@@ -98,6 +100,7 @@ const tabBarTarget = computed(() => {
if (!tabBarPortal?.active.value) return undefined;
return tabBarPortal.targets.get(props.groupId);
});
const groupTabs = computed(() => {
const byId = new Map(queryStore.tabs.map((tab) => [tab.id, tab]));
return props.tabIds.map((id) => byId.get(id)).filter((tab): tab is QueryTab => !!tab);
@@ -138,7 +141,7 @@ const groupExecutableSql = computed(() => {
</script>
<template>
<div class="editor-group flex h-full min-h-0 min-w-0 overflow-hidden" :class="[groupClass, groupLayoutClass]" :data-group-id="groupId" @pointerdown.capture="$emit('focus-group', groupId)" @focusin="$emit('focus-group', groupId)">
<div class="editor-group flex min-h-0 min-w-0 overflow-hidden" :class="[contentSuppressed ? '' : 'h-full', groupClass, groupLayoutClass]" :data-group-id="groupId" @pointerdown.capture="$emit('focus-group', groupId)" @focusin="$emit('focus-group', groupId)">
<Teleport defer v-if="showTabNavigation !== false" :to="tabBarTarget" :disabled="!tabBarPortal?.active.value || !tabBarTarget">
<EditorGroupTabBar
:group-id="groupId"
@@ -157,15 +160,17 @@ const groupExecutableSql = computed(() => {
@detach-tab="$emit('detach-tab', $event)"
@activate-settings="toolbar.activateSettingsPage()"
@close-settings="toolbar.closeSettingsPage()"
@activate-driver-store="toolbar.activateDriverStore()"
@close-driver-store="toolbar.closeDriverStore()"
@activate-plugin-center="toolbar.activatePluginCenter()"
@close-plugin-center="toolbar.closePluginCenter()"
@activate-driver-store="toolbar.activateDriverStore()"
@close-driver-store="toolbar.closeDriverStore()"
/>
</Teleport>
<!-- The toolbar stays at the top of the pane's content column in every
placement; only the tab bar moves around it. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
placement; only the tab bar moves around it. While contentSuppressed
(plugin workbench tab active) the whole column yields to the
always-mounted plugin layer and the group collapses to its strip. -->
<div v-show="!contentSuppressed" class="flex min-h-0 min-w-0 flex-1 flex-col">
<EditorToolbar
v-if="activeTab && showGroupToolbar"
:active-tab="activeTab"
@@ -26,6 +26,8 @@ const props = defineProps<
tabBarCollapsed?: boolean;
canDetachTabs?: boolean;
detachedDropTarget?: boolean;
/** Render only the groups' tab strips (a plugin workbench tab owns the layout). */
contentSuppressed?: boolean;
}
>();
const emit = defineEmits<
@@ -116,12 +118,13 @@ const globalTabBarPortal = inject(GROUP_TAB_BAR_PORTAL, null);
const workspaceTabBarPortal = createGroupTabBarPortal(isVerticalTabLayout);
// A special page owns navigation while active. Otherwise side tabs stay
// outside the editor/result split, and horizontal tabs return to their group.
provide(GROUP_TAB_BAR_PORTAL, {
const providedPortal = {
active: computed(() => !!globalTabBarPortal?.active.value || isVerticalTabLayout.value),
get targets() {
return globalTabBarPortal?.active.value ? globalTabBarPortal.targets : workspaceTabBarPortal.targets;
},
});
};
provide(GROUP_TAB_BAR_PORTAL, providedPortal);
const tabNavigationStyle = computed(() => {
const width = props.tabBarCollapsed ? "var(--collapsed-tab-rail-width)" : `${props.tabBarWidth ?? 240}px`;
return { width, flex: `0 0 ${width}` };
@@ -254,12 +257,45 @@ function handleFocusStatement(tabId: string, range: StatementRange | null): bool
</script>
<template>
<div class="sql-editor-workspace relative flex h-full min-h-0 min-w-0 flex-1 overflow-hidden" :class="[workspaceClass, settingsStore.editorSettings.tabPlacement === 'right' ? 'flex-row-reverse' : 'flex-row']">
<!-- contentSuppressed (plugin workbench tab active): the App.vue wrapper is
flex-none with indefinite height, so h-full/flex-1 here would collapse
the workspace to 0 and clip the group tab strips (overflow-hidden).
Size to content instead — same contract as EditorGroup's suppressed
h-auto; normal mode keeps h-full/flex-1 to fill the column. -->
<div class="sql-editor-workspace relative flex min-h-0 min-w-0 overflow-hidden" :class="[contentSuppressed ? 'h-auto' : 'h-full flex-1', workspaceClass, settingsStore.editorSettings.tabPlacement === 'right' ? 'flex-row-reverse' : 'flex-row']">
<div v-show="isVerticalTabLayout && showTabNavigation !== false" data-workspace-tab-navigation class="flex min-h-0 shrink-0 flex-col overflow-hidden" :style="tabNavigationStyle">
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-workspace-tab-target="group.id" class="flex min-h-0 min-w-0 flex-1" @pointerdown.capture="queryStore.focusGroup(group.id)" @focusin="queryStore.focusGroup(group.id)" />
</div>
<div data-workspace-content class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<Splitpanes horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
<!-- Suppressed mode: a plugin workbench tab owns the layout. Render the
groups' tab strips directly (no Splitpanes, no shared result pane) so
App.vue's always-mounted plugin layer can fill the remaining column
while the strips stay visible and switchable. -->
<template v-if="contentSuppressed">
<EditorGroup
v-for="group in queryStore.groups"
:key="group.id"
:ref="(el: unknown) => setGroupRef(group.id, el)"
:group-id="group.id"
:tab-ids="group.tabIds"
:active-tab-id="group.activeTabId"
:tab-bar-width="tabBarWidth"
:tab-bar-collapsed="tabBarCollapsed"
:can-detach-tabs="canDetachTabs"
:detached-drop-target="detachedDropTarget"
:content-suppressed="true"
:show-tab-navigation="showTabNavigation"
v-bind="editorGroupBindings"
@focus-group="queryStore.focusGroup($event)"
@activate-tab="queryStore.activateTabInGroup(group.id, $event)"
@locate-tab="emit('locate-tab', $event)"
@toggle-zen-mode="emit('toggle-zen-mode')"
@start-resize="emit('start-resize', $event)"
@toggle-collapse="emit('toggle-collapse')"
@detach-tab="emit('detach-tab', $event)"
/>
</template>
<Splitpanes v-else horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
<Pane class="min-h-0 min-w-0" :size="editorPaneSize" :min-size="100 - SHARED_RESULT_PANE_MAX_SIZE">
<Splitpanes
:horizontal="queryStore.orientation === 'horizontal'"
@@ -63,8 +63,10 @@ describe("Group strip special page tabs", () => {
it("keeps special page tabs and their update badge in the focused group", () => {
expect(groupTabBarSource).toContain("queryStore.focusedGroupId === props.groupId");
expect(groupTabBarSource).toContain("data-settings-page-tab");
expect(groupTabBarSource).toContain("data-plugin-center-tab");
expect(groupTabBarSource).toContain("data-driver-store-tab");
expect(groupTabBarSource).toContain("@click=\"emit('activate-settings')\"");
expect(groupTabBarSource).toContain("@click=\"emit('activate-plugin-center')\"");
expect(groupTabBarSource).toContain("@click=\"emit('activate-driver-store')\"");
expect(groupTabBarSource).toContain('t("toolbar.driverManager")');
expect(groupTabBarSource).toContain("aria-label=\"t('toolbar.updatableDriverCount')\"");
@@ -74,6 +76,8 @@ describe("Group strip special page tabs", () => {
it("routes special-page actions and preserves ordinary-tab dirty closing", () => {
expect(groupSource).toContain(':special-page-tabs="toolbar.specialPageTabs.value"');
expect(groupSource).toContain('@activate-settings="toolbar.activateSettingsPage()"');
expect(groupSource).toContain('@activate-plugin-center="toolbar.activatePluginCenter()"');
expect(groupSource).toContain('@close-plugin-center="toolbar.closePluginCenter()"');
expect(groupSource).toContain('@close-driver-store="toolbar.closeDriverStore()"');
expect(groupTabBarSource).toContain('@mousedown.middle.prevent="closeTab(entry.tab)"');
expect(groupTabBarSource).toContain('@click.stop="closeTab(entry.tab)"');
@@ -83,6 +87,7 @@ describe("Group strip special page tabs", () => {
it("uses one presentation path for active special pages and inactive ordinary tabs", () => {
expect(groupTabBarSource).toContain("return !specialPageActive.value && tab.id === props.activeTabId;");
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.settingsActive)");
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.pluginCenterActive)");
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.driverStoreActive)");
expect(groupTabBarSource).toContain("if (!active) return undefined;");
expect(groupTabBarSource).toContain('return { "--app-tab-background": activeBackground, boxShadow: "inset 0 -2px 0 color-mix(in srgb, var(--foreground) 72%, transparent)" };');
@@ -24,6 +24,16 @@ vi.mock("@/components/ui/popover", () => ({
PopoverTrigger: { name: "PopoverTriggerStub", template: `<div><slot /></div>` },
}));
vi.mock("@/lib/plugins/pluginIconResolver", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/plugins/pluginIconResolver")>()),
resolvePluginIcon: vi.fn(() => Promise.resolve("assets/plugin.svg")),
}));
vi.mock("@/lib/backend/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/backend/api")>()),
readPluginAsset: vi.fn(() => Promise.resolve({ contentType: "image/svg+xml", dataBase64: "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" })),
}));
const specDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(specDir, "../EditorGroupTabBar.vue"), "utf8");
const sharedStyles = readFileSync(resolve(specDir, "../appTabBar.css"), "utf8");
@@ -713,6 +723,30 @@ describe("EditorGroupTabBar group behavior", () => {
app.unmount();
host.remove();
});
it("renders plugin workbench tabs with the plugin icon instead of the code fallback", async () => {
const store = useQueryStore();
const pluginTabId = store.openPluginWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench", { title: "SSH server", connectionId: "ssh-1", forceNew: true });
const { app, host } = mountBar(store.groups[0].id, store.tabs.slice(), pluginTabId, pinia);
await settle();
// PluginIcon loads the asset asynchronously; wait for the blob <img> to appear.
for (let i = 0; i < 20 && !host.querySelector(`[data-tab-id="${pluginTabId}"] img`); i += 1) {
await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 5));
}
const pill = host.querySelector<HTMLElement>(`[data-tab-id="${pluginTabId}"]`);
expect(pill).toBeTruthy();
expect(pill!.querySelector("img")).toBeTruthy();
// The pill and the overflow popup list both render via TabModeIcon, whose
// per-mode chain must keep the plugin-workbench plugin icon branch.
const tabModeIconSource = readFileSync(resolve(specDir, "../TabModeIcon.vue"), "utf8");
expect(tabModeIconSource).toContain("tab.mode === 'plugin-workbench' && tab.pluginWorkbench");
expect(tabModeIconSource).toContain(':contribution-id="tab.pluginWorkbench.contributionId"');
expect(source).toContain('<TabModeIcon :tab="entry.tab"');
app.unmount();
host.remove();
});
});
describe("EditorGroupTabBar special page navigation", () => {
@@ -26,7 +26,7 @@ const groupExecutionCalls = vi.hoisted(() => ({ capture: [] as string[], request
vi.mock("@/components/layout/EditorGroup.vue", () => ({
default: {
name: "EditorGroupStub",
props: ["groupId", "tabIds", "activeTabId"],
props: ["groupId", "tabIds", "activeTabId", "contentSuppressed"],
emits: ["execute"],
methods: {
previewStatementRange(tabId: string, range: unknown) {
@@ -54,7 +54,7 @@ vi.mock("@/components/layout/EditorGroup.vue", () => ({
return true;
},
},
template: `<div data-test="editor-group" :data-group-id="groupId" :data-tab-ids="tabIds.join(',')" :data-active-tab-id="activeTabId"><button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">execute</button></div>`,
template: `<div data-test="editor-group" :data-group-id="groupId" :data-tab-ids="tabIds.join(',')" :data-active-tab-id="activeTabId" :data-content-suppressed="contentSuppressed ? 'true' : 'false'"><button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">execute</button></div>`,
},
}));
@@ -336,6 +336,95 @@ describe("SqlEditorWorkspace mount contract", () => {
host.remove();
});
it("keeps group tab strips mounted and drops the splitpanes workspace while suppressed", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a"), { ...tab("tab-b"), mode: "plugin-workbench" } as unknown as ReturnType<typeof tab>];
store.activeTabId = "tab-b";
store.groups = [
{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" },
{ id: "g2", tabIds: ["tab-b"], activeTabId: "tab-b" },
];
store.focusedGroupId = "g2";
store.orientation = "vertical";
store.sizes = [50, 50];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: { ...tab("tab-b"), mode: "plugin-workbench" } as unknown as ReturnType<typeof tab>,
activeConnection: undefined,
executableSql: "",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
contentSuppressed: true,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
// Suppressed mode renders the groups' tab strips directly (no Splitpanes,
// no shared result pane) so App.vue's always-mounted plugin workbench
// layer can own the remaining column while the strips stay visible.
expect(host.querySelector('[data-test="splitpanes-stub"]')).toBeNull();
expect(host.querySelector('[data-test="result-surface"]')).toBeNull();
const groups = host.querySelectorAll('[data-test="editor-group"]');
expect(groups).toHaveLength(2);
expect(groups[1]?.getAttribute("data-group-id")).toBe("g2");
expect(groups[1]?.getAttribute("data-content-suppressed")).toBe("true");
// The App.vue wrapper is flex-none with indefinite height while a plugin
// tab is active: the workspace must size to its strip content, or the
// h-full/flex-1 fill contract collapses it to zero height and clips the
// strips away (overflow-hidden) — the "plugin tab bar vanishes" regression.
const workspaceRoot = host.querySelector<HTMLElement>(".sql-editor-workspace");
expect(workspaceRoot?.classList.contains("h-auto")).toBe(true);
expect(workspaceRoot?.classList.contains("h-full")).toBe(false);
expect(workspaceRoot?.classList.contains("flex-1")).toBe(false);
app.unmount();
host.remove();
});
it("keeps the fill-height contract on the workspace root outside suppressed mode", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a")];
store.activeTabId = "tab-a";
store.groups = [{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" }];
store.focusedGroupId = "g1";
store.orientation = "vertical";
store.sizes = [100];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: tab("tab-a"),
activeConnection: undefined,
executableSql: "",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
contentSuppressed: false,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
const workspaceRoot = host.querySelector<HTMLElement>(".sql-editor-workspace");
expect(workspaceRoot?.classList.contains("h-full")).toBe(true);
expect(workspaceRoot?.classList.contains("flex-1")).toBe(true);
expect(workspaceRoot?.classList.contains("h-auto")).toBe(false);
app.unmount();
host.remove();
});
it("forwards editor execute events from EditorGroup to the App listener", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a")];
@@ -1,11 +1,21 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import PluginConnectionFields from "./PluginConnectionFields.vue";
import type { PluginConnectionProviderContribution, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
const invokePluginMock = vi.fn();
vi.mock("@/lib/backend/api", () => ({
listLocalSshKeys: vi.fn(async () => [
{ path: "/home/dev/.ssh/id_ed25519", algorithm: "ssh-ed25519", fingerprint: "SHA256:abc", hasPassphrase: false },
{ path: "/home/dev/.ssh/id_rsa", algorithm: "ssh-rsa", fingerprint: "", hasPassphrase: true },
]),
invokePlugin: (...args: unknown[]) => invokePluginMock(...args),
}));
const mountedApps: App[] = [];
const contribution: PluginConnectionProviderContribution = {
@@ -16,16 +26,6 @@ const contribution: PluginConnectionProviderContribution = {
fields: [
{ key: "host", label: "Host", type: "text", required: true, placeholder: "localhost" },
{ key: "password", label: "Password", type: "password" },
{
key: "protocol",
label: "Protocol",
type: "radio",
default: "https",
options: [
{ label: "HTTPS", value: "https" },
{ label: "HTTP", value: "http" },
],
},
],
};
@@ -62,6 +62,39 @@ afterEach(() => {
});
describe("PluginConnectionFields", () => {
it("preserves multiline secret input and masks it again after its branch is reopened", async () => {
const provider: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "pem.connection",
label: "PEM",
database_type: "example",
fields: [
{ key: "mode", label: "Mode", type: "text", default: "tls" },
{ key: "key", label: "Private key", type: "textarea", binding: "secret", visible_when: { field: "mode", one_of: ["tls"] } },
],
};
const state = await mountContribution(provider, {});
const textarea = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
const pem = "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----\n";
expect(textarea.classList.contains("secret-masked")).toBe(true);
textarea.value = pem;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
expect(state.values.key).toBe(pem);
textarea.parentElement!.querySelector<HTMLButtonElement>("button")!.click();
await nextTick();
expect(textarea.classList.contains("secret-masked")).toBe(false);
expect(textarea.value).toBe(pem);
state.values = { ...state.values, mode: "plain" };
await nextTick();
expect(document.querySelector("#pem-connection-key")).toBeNull();
state.values = { ...state.values, mode: "tls" };
await nextTick();
const reopened = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
expect(reopened.value).toBe(pem);
expect(reopened.classList.contains("secret-masked")).toBe(true);
});
it("renders declared fields and emits immutable model updates", async () => {
const state = await mountFields({ password: "secret" });
const hostInput = document.querySelector<HTMLInputElement>("#example-connection-host");
@@ -99,15 +132,199 @@ describe("PluginConnectionFields", () => {
expect(document.querySelector(".rounded-lg.border")).toBeNull();
});
it("renders radio fields and emits the selected option", async () => {
const state = await mountFields();
const radios = Array.from(document.querySelectorAll<HTMLInputElement>("input[type='radio']"));
it("hides conditional fields until visible_when matches and marks required_when", async () => {
const conditional: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "cond.connection",
label: "Conditional",
database_type: "cond",
fields: [
{ key: "mode", label: "Mode", type: "text", default: "password" },
{ key: "private_key_path", label: "Private key", type: "text", visible_when: { field: "mode", one_of: ["key"] }, required_when: { field: "mode", one_of: ["key"] } },
],
};
const state = await mountContribution(conditional, {});
expect(radios).toHaveLength(2);
expect(radios[0]?.checked).toBe(true);
radios[1]?.click();
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
state.values = { mode: "key" };
await nextTick();
expect(state.values.protocol).toBe("http");
const keyInput = document.querySelector<HTMLInputElement>("#cond-connection-private_key_path");
expect(keyInput).not.toBeNull();
const label = document.querySelector('label[for="cond-connection-private_key_path"]');
expect(label?.textContent).toContain("*");
state.values = { mode: "password" };
await nextTick();
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
});
it("keeps grandchild fields hidden while an intermediate default-only value would match (kafka msk_region regression)", async () => {
// sasl_mechanism (PLAIN default, hidden without SASL) → oauth_token_source
// (msk_iam default) → msk_region: the nested default must not surface an
// always-required AWS region on a plain PLAINTEXT/SCRAM connection.
const kafka: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "kafka.connection",
label: "Kafka",
database_type: "kafka",
fields: [
{ key: "bootstrap_servers", label: "Bootstrap servers", type: "textarea", required: true },
{
key: "security_protocol",
label: "Security protocol",
type: "select",
default: "PLAINTEXT",
options: [
{ label: "PLAINTEXT", value: "PLAINTEXT" },
{ label: "SASL_SSL", value: "SASL_SSL" },
],
},
{
key: "sasl_mechanism",
label: "SASL mechanism",
type: "select",
default: "PLAIN",
options: [
{ label: "PLAIN", value: "PLAIN" },
{ label: "OAUTHBEARER", value: "OAUTHBEARER" },
],
visible_when: { field: "security_protocol", one_of: ["SASL_PLAINTEXT", "SASL_SSL"] },
},
{
key: "oauth_token_source",
label: "OAuth token source",
type: "select",
default: "msk_iam",
options: [
{ label: "MSK IAM", value: "msk_iam" },
{ label: "Static token", value: "static_token" },
],
visible_when: { field: "sasl_mechanism", one_of: ["OAUTHBEARER"] },
},
{ key: "msk_region", label: "MSK AWS region", type: "text", visible_when: { field: "oauth_token_source", one_of: ["msk_iam"] }, required_when: { field: "oauth_token_source", one_of: ["msk_iam"] } },
],
};
const state = await mountContribution(kafka, {}, "io.dbx.kafka");
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
state.values = { security_protocol: "SASL_SSL", sasl_mechanism: "OAUTHBEARER" };
await nextTick();
expect(document.querySelector("#kafka-connection-msk_region")).not.toBeNull();
const label = document.querySelector('label[for="kafka-connection-msk_region"]');
expect(label?.textContent).toContain("*");
// Selecting the static token source hides the region field again.
state.values = { sasl_mechanism: "OAUTHBEARER", oauth_token_source: "static_token" };
await nextTick();
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
});
it("offers local SSH keys on private_key_path fields and fills the chosen path", async () => {
const withKey: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "keys.connection",
label: "Keys",
database_type: "keys",
fields: [{ key: "private_key_path", label: "Private key", type: "text" }],
};
const state = await mountContribution(withKey, {});
await flushAsync();
const select = document.querySelector<HTMLSelectElement>("select");
expect(select).not.toBeNull();
expect(select?.disabled).toBe(false);
const encryptedOption = select?.querySelector<HTMLOptionElement>('option[value="/home/dev/.ssh/id_rsa"]');
expect(encryptedOption?.textContent).toContain("id_rsa");
expect(encryptedOption?.textContent).toContain("ssh-rsa");
expect(encryptedOption?.textContent).toMatch(/encrypted|已加密/);
select!.value = "/home/dev/.ssh/id_rsa";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await nextTick();
expect(state.values.private_key_path).toBe("/home/dev/.ssh/id_rsa");
});
it("renders options_action fields as a dynamic select and keeps a stored value visible", async () => {
const dynamic: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "dyn.connection",
label: "Dyn",
database_type: "dyn",
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
};
invokePluginMock.mockResolvedValueOnce({
options: [
{ value: "p1", label: "Ops" },
{ value: "p2", label: "QA" },
],
});
// "ghost" is stored but no longer offered: the dropdown must keep it visible.
await mountContribution(dynamic, { sudo_profile: "ghost" }, "io.dbx.ssh");
await flushAsync();
expect(invokePluginMock).toHaveBeenCalledWith("io.dbx.ssh", "sudo/profiles/options");
expect(document.querySelector("input#dyn-connection-sudo_profile")).toBeNull();
const trigger = document.querySelector<HTMLButtonElement>('#dyn-connection-sudo_profile, button[role="combobox"]');
expect(trigger).not.toBeNull();
expect(trigger?.textContent).toContain("ghost");
});
it("falls back to the text input when the options_action call fails or returns nothing", async () => {
const dynamic: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "degrade.connection",
label: "Degrade",
database_type: "degrade",
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
};
invokePluginMock.mockRejectedValueOnce(new Error("method not registered"));
await mountContribution(dynamic, {}, "io.dbx.ssh");
await flushAsync();
const input = document.querySelector<HTMLInputElement>("input#degrade-connection-sudo_profile");
expect(input).not.toBeNull();
expect(input?.value).toBe("");
// An empty option list degrades the same way.
invokePluginMock.mockResolvedValueOnce({ options: [] });
const empty = { ...dynamic, id: "empty.connection", label: "Empty" };
await mountContribution(empty, {}, "io.dbx.ssh");
await flushAsync();
expect(document.querySelector("input#empty-connection-sudo_profile")).not.toBeNull();
});
});
async function flushAsync() {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function mountContribution(contribution: PluginConnectionProviderContribution, initialValues: Record<string, PluginFormFieldValue>, pluginId?: string) {
const state = reactive({ values: initialValues });
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
return () =>
h(PluginConnectionFields, {
contribution,
modelValue: state.values,
pluginId,
"onUpdate:modelValue": (value: Record<string, PluginFormFieldValue>) => {
state.values = value;
},
});
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
return state;
}
@@ -1,25 +1,45 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import PasswordTextarea from "@/components/ui/PasswordTextarea.vue";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
import { pluginFieldIsRequired, pluginFieldIsVisible, type PluginFieldResolver } from "@/lib/plugins/pluginFieldConditions";
import { invokePlugin, listLocalSshKeys } from "@/lib/backend/api";
import type { LocalSshKey, PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldOption, PluginFormFieldValue } from "@/types/database";
const props = defineProps<{
contribution: PluginConnectionProviderContribution;
modelValue: Record<string, PluginFormFieldValue>;
hiddenBindings?: PluginFormFieldBinding[];
layout?: "stacked" | "connection-dialog";
pluginId?: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: Record<string, PluginFormFieldValue>];
}>();
const { t } = useI18n();
const formValues = computed(() => props.modelValue);
const visibleFields = computed(() => props.contribution.fields.filter((field) => !field.binding || !props.hiddenBindings?.includes(field.binding)));
/** Value of any sibling field by key (condition evaluation reads current form values). */
function readFieldValue(key: string): PluginFormFieldValue {
const field = props.contribution.fields.find((candidate) => candidate.key === key);
return field ? (formValues.value[key] ?? field.default) : undefined;
}
const resolveSiblingField: PluginFieldResolver = (key) => props.contribution.fields.find((candidate) => candidate.key === key);
function fieldVisible(field: PluginFormField): boolean {
if (field.binding && props.hiddenBindings?.includes(field.binding)) return false;
return pluginFieldIsVisible(field, readFieldValue, resolveSiblingField);
}
function fieldRequired(field: PluginFormField): boolean {
return pluginFieldIsRequired(field, readFieldValue);
}
const visibleFields = computed(() => props.contribution.fields.filter((field) => fieldVisible(field)));
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");
function fieldValue(field: PluginFormField): PluginFormFieldValue {
@@ -59,6 +79,103 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
if (field.type === "number") return undefined;
return "";
}
// ---------------------------------------------------------------------------
// Dynamic option lists (fields declaring `options_action`)
// ---------------------------------------------------------------------------
// A field may point `options_action` at a plugin method returning
// `{ options: [{ value, label }] }`. The host fetches it once per dialog and
// renders the field as a select; a missing plugin id, a failed call, or an
// empty result falls back to the declared text input (older hosts without
// this extension never fetch and simply keep the text field).
const dynamicOptions = ref<Record<string, PluginFormFieldOption[]>>({});
const dynamicOptionsRequested = ref(new Set<string>());
const optionsActionFields = computed(() => visibleFields.value.filter((field) => field.options_action));
watch(
optionsActionFields,
(fields) => {
for (const field of fields) {
const action = field.options_action;
if (!action || !props.pluginId || dynamicOptionsRequested.value.has(field.key)) continue;
dynamicOptionsRequested.value.add(field.key);
invokePlugin<{ options?: PluginFormFieldOption[] }>(props.pluginId, action)
.then((result) => {
const options = Array.isArray(result?.options) ? result.options.filter((option) => option && option.value !== undefined) : [];
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: options };
})
.catch(() => {
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: [] };
});
}
},
{ immediate: true },
);
function selectOptionsFor(field: PluginFormField): PluginFormFieldOption[] | null {
if (!field.options_action) return null;
const options = dynamicOptions.value[field.key];
if (!options || options.length === 0) return null;
// Keep a stored value visible even when its profile disappeared so the
// dialog does not silently look "unset" on reopen.
const current = fieldValue(field);
if (current !== undefined && current !== "" && !options.some((option) => String(option.value) === String(current))) {
return [{ value: String(current), label: String(current) }, ...options];
}
return options;
}
// ---------------------------------------------------------------------------
// Local SSH key suggestions (Host API: private_key_path convenience picker)
// ---------------------------------------------------------------------------
const LOCAL_SSH_KEY_FIELD = "private_key_path";
function isLocalSshKeyField(field: PluginFormField): boolean {
return field.key === LOCAL_SSH_KEY_FIELD && (field.type === "text" || field.type === "password");
}
const hasLocalSshKeyField = computed(() => visibleFields.value.some((field) => isLocalSshKeyField(field)));
const localSshKeys = ref<LocalSshKey[]>([]);
const localSshKeysRequested = ref(false);
async function loadLocalSshKeys() {
if (localSshKeysRequested.value) return;
localSshKeysRequested.value = true;
try {
localSshKeys.value = await listLocalSshKeys();
} catch {
localSshKeys.value = [];
}
}
watch(
hasLocalSshKeyField,
(present) => {
if (present) void loadLocalSshKeys();
},
{ immediate: true },
);
function localSshKeyFile(path: string): string {
return path.replaceAll("\\", "/").split("/").pop() || path;
}
function localSshKeyLabel(key: LocalSshKey): string {
const algorithm = key.algorithm || "?";
return key.hasPassphrase ? `${localSshKeyFile(key.path)} · ${algorithm} · ${t("connection.pluginSshKeyEncrypted")}` : `${localSshKeyFile(key.path)} · ${algorithm}`;
}
function applyLocalSshKey(field: PluginFormField, event: Event) {
const select = event.target as HTMLSelectElement;
const selected = localSshKeys.value.find((key) => key.path === select.value);
select.value = "";
if (!selected) return;
updateField(field, selected.path);
}
</script>
<template>
@@ -75,11 +192,37 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
<div v-for="field in visibleFields" :key="field.key" :class="isConnectionDialogLayout ? 'col-span-full grid grid-cols-4 items-start gap-4' : 'space-y-1.5'">
<Label :for="fieldId(field)" :class="isConnectionDialogLayout ? 'justify-self-start pt-2 text-left' : 'text-xs'">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
<span v-if="fieldRequired(field)" class="text-destructive">*</span>
</Label>
<div :class="isConnectionDialogLayout ? 'col-span-3 min-w-0 space-y-1.5' : ''">
<Input v-if="field.type === 'text' || field.type === 'number'" :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
<template v-if="field.type === 'text' || field.type === 'number'">
<Select v-if="field.type === 'text' && selectOptionsFor(field)" :model-value="String(fieldValue(field) ?? '')" @update:model-value="updateSelectField(field, $event)">
<SelectTrigger :id="fieldId(field)" :class="isConnectionDialogLayout ? 'h-9' : 'h-8 text-xs'">
<SelectValue :placeholder="field.placeholder" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in selectOptionsFor(field)!" :key="String(option.value)" :value="String(option.value)">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-else-if="isLocalSshKeyField(field)" class="flex items-center gap-1.5">
<Input :id="fieldId(field)" type="text" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" class="min-w-0 flex-1" @update:model-value="updateTextField(field, $event)" />
<select
class="h-9 w-40 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="localSshKeys.length === 0"
:title="t('connection.pluginSshKeyPickerPlaceholder')"
:aria-label="t('connection.pluginSshKeyPickerPlaceholder')"
@change="applyLocalSshKey(field, $event)"
>
<option value="" disabled selected>{{ localSshKeys.length ? t("connection.pluginSshKeyPickerPlaceholder") : t("connection.pluginSshKeyPickerEmpty") }}</option>
<option v-for="key in localSshKeys" :key="key.path" :value="key.path" :title="key.path">{{ localSshKeyLabel(key) }}</option>
</select>
</div>
<Input v-else :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
</template>
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
<PasswordTextarea v-else-if="field.type === 'textarea' && field.binding === 'secret'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
<textarea
v-else-if="field.type === 'textarea'"
:id="fieldId(field)"
@@ -98,12 +241,6 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
</SelectItem>
</SelectContent>
</Select>
<div v-else-if="field.type === 'radio'" :id="fieldId(field)" class="flex min-h-9 flex-wrap items-center gap-x-4 gap-y-2" role="radiogroup" :aria-label="field.label">
<label v-for="option in field.options || []" :key="option.value" class="inline-flex cursor-pointer items-center gap-2 text-sm">
<input type="radio" :name="fieldId(field)" :value="option.value" :checked="String(fieldValue(field) ?? '') === option.value" class="size-4 accent-primary" @change="updateField(field, option.value)" />
<span>{{ option.label }}</span>
</label>
</div>
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
</div>
@@ -25,10 +25,14 @@ const emit = defineEmits<{
}>();
const { t, locale: appLocale } = useI18n();
const { isDark, activeCustomUiColors } = useTheme();
const { isDark, themeRevision } = useTheme();
const iframe = ref<HTMLIFrameElement>();
const source = ref("");
const loading = ref(true);
// Stays false until the iframe's load event: WKWebView paints a white canvas
// for a freshly inserted iframe before the sandbox document's first styled
// frame, so the themed overlay must keep covering the frame area until then.
const frameReady = ref(false);
const error = ref("");
let bridge: PluginHostBridge | undefined;
let unsubscribeEvents: (() => void) | undefined;
@@ -116,6 +120,7 @@ async function loadWorkbench() {
const generation = ++loadGeneration;
bridge = undefined;
loading.value = true;
frameReady.value = false;
error.value = "";
try {
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
@@ -132,7 +137,8 @@ async function loadWorkbench() {
if (disposed || generation !== loadGeneration) return;
error.value = cause instanceof Error ? cause.message : String(cause);
emit("error", error.value);
loading.value = false;
} finally {
if (!disposed && generation === loadGeneration) loading.value = false;
}
}
@@ -141,9 +147,18 @@ function onMessage(event: MessageEvent) {
}
function onFrameLoad() {
// The load event can precede the webview's first actual paint (notably on
// WKWebView); reveal after two animation frames, with a timer fallback
// because rAF stalls in occluded/background webviews. Guarded by generation
// so a stale callback from a rebuilt iframe can't lift the new overlay.
const generation = loadGeneration;
const reveal = () => {
if (!disposed && generation === loadGeneration) frameReady.value = true;
};
requestAnimationFrame(() => requestAnimationFrame(reveal));
setTimeout(reveal, 400);
bridge?.sendInit();
emit("ready");
loading.value = false;
}
onMounted(async () => {
@@ -172,7 +187,10 @@ watch(
{ deep: true },
);
watch(appLocale, (locale) => bridge?.updateLocale(locale));
watch([isDark, activeCustomUiColors], () => bridge?.updateTheme(currentBridgeTheme()), { deep: true });
// Keyed on the theme revision (bumped by applyTheme) so every theme change
// path — dark/light, palette switch, custom colors — re-pushes the resolved
// tokens; watching isDark/custom colors alone misses palette-only switches.
watch(themeRevision, () => bridge?.updateTheme(currentBridgeTheme()));
onBeforeUnmount(() => {
disposed = true;
@@ -185,14 +203,25 @@ onBeforeUnmount(() => {
<template>
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
<div v-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error }}</span>
</div>
<iframe v-else-if="source" ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
<div v-if="loading && !error" class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 text-sm text-muted-foreground backdrop-blur-sm">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
<div v-else-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error }}</span>
</div>
<template v-else>
<iframe ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
<!-- Cover until the frame has actually painted: the iframe stays mounted
underneath so its load event can fire (v-else on the overlay would
deadlock), it just isn't visible yet. Fully opaque so the covered
phase is visually identical to the host background, and faded out
instead of removed so the reveal is never a hard swap. -->
<div class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground transition-opacity duration-150 ease-out" :class="frameReady ? 'pointer-events-none opacity-0' : 'opacity-100'">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
</template>
</div>
</template>
@@ -15,7 +15,7 @@ vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const { defineComponent, h, Teleport } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
@@ -27,9 +27,20 @@ vi.mock("@/components/ui/dialog", async () => {
return () => (props.open ? h("div", slots.default?.()) : null);
},
});
const dialogContent = defineComponent({
props: {
class: [String, Array, Object],
overlayClass: [String, Array, Object],
portalClass: [String, Array, Object],
},
inheritAttrs: false,
setup(props, { slots }) {
return () => h(Teleport, { to: "body" }, [h("div", { class: ["dialog-overlay", "z-50", props.overlayClass] }), h("div", { class: ["dialog-positioner", "z-50", props.portalClass] }, [h("div", { class: ["dialog-content", props.class] }, slots.default?.())])]);
},
});
return {
Dialog: dialog,
DialogContent: passthrough,
DialogContent: dialogContent,
DialogDescription: passthrough,
DialogFooter: passthrough,
DialogHeader: passthrough,
@@ -133,6 +144,51 @@ describe("SshHostKeyPromptDialog web bridge", () => {
expect(dialogSource).toContain('portal-class="z-[200]"');
});
it("keeps the prompt above a later body-mounted connection dialog and answerable", async () => {
await mountDialog();
const eventSource = MockEventSource.instances[0];
eventSource?.emit({
type: "prompt",
request: {
id: "prompt-layered",
kind: "HostKeyVerify",
host: "layered.example.test",
port: 22,
key_type: "ssh-ed25519",
fingerprint: "SHA256:layered",
},
});
await nextTick();
// Simulate ConnectionDialog opening after the global prompt: its portal is
// appended later and remains on the shared default z-50 layer.
const connectionPositioner = document.createElement("div");
connectionPositioner.className = "dialog-positioner z-50";
connectionPositioner.textContent = "New connection";
document.body.append(connectionPositioner);
const promptPositioner = document.body.querySelector<HTMLElement>(".dialog-positioner.z-\\[200\\]");
const promptOverlay = document.body.querySelector<HTMLElement>(".dialog-overlay.z-\\[200\\]");
expect(promptPositioner).not.toBeNull();
expect(promptOverlay).not.toBeNull();
expect(connectionPositioner.classList.contains("z-50")).toBe(true);
expect(promptPositioner?.classList.contains("z-[200]")).toBe(true);
expect(promptOverlay?.classList.contains("z-[200]")).toBe(true);
expect(document.body.textContent).toContain("layered.example.test:22");
const buttons = document.body.querySelectorAll<HTMLButtonElement>("button");
buttons.item(buttons.length - 1).click();
await vi.waitFor(() => {
expect(resolveSshPromptMock).toHaveBeenCalledWith({
id: "prompt-layered",
action: "accept",
remember: true,
secret: undefined,
});
});
});
it("shows an SSE host-key prompt and posts the user's acceptance", async () => {
await mountDialog();
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Eye, EyeOff } from "@lucide/vue";
defineOptions({ inheritAttrs: false });
const model = defineModel<string>();
const visible = ref(false);
const { t } = useI18n();
const toggleLabel = computed(() => t(visible.value ? "common.hidePassword" : "common.showPassword"));
</script>
<template>
<div class="relative">
<textarea
v-model="model"
v-bind="$attrs"
:class="{ 'secret-masked': !visible }"
class="min-h-20 w-full resize-y rounded-md border border-input bg-transparent py-2 pl-2.5 pr-9 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
autocomplete="off"
autocapitalize="off"
:spellcheck="false"
/>
<button type="button" class="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground" :aria-label="toggleLabel" :title="toggleLabel" :aria-pressed="visible" @click="visible = !visible">
<Eye v-if="!visible" class="size-3.5" />
<EyeOff v-else class="size-3.5" />
</button>
</div>
</template>
<style scoped>
.secret-masked {
-webkit-text-security: disc;
}
</style>
@@ -47,6 +47,41 @@ export function useTauriEvents(deps: {
}
}).then((unlisten) => unlistenHandles.push(unlisten));
listen<{ connection_id: string }>("mcp-open-connection-workbench", async (event) => {
try {
const { connection_id } = event.payload;
if (!connectionStore.connections.length) await connectionStore.initFromDisk();
const config = connectionStore.getConfig(connection_id);
if (!config) return;
connectionStore.activeConnectionId = connection_id;
// Dedup + tab switch happen inside the store; also ensures the
// plugin workbench session (and its PTY) exists before returning.
// Deliberately no window focus here: agent-terminal calls open
// the tab so the command lands in a real terminal, but stealing
// the OS focus interrupted whatever the user was doing.
await queryStore.openPluginConnection(connection_id);
} catch (e) {
console.error("[DBX] mcp-open-connection-workbench error:", e);
}
}).then((unlisten) => unlistenHandles.push(unlisten));
// Plugins report live transport state through plugin events; when an
// SSH session dies, flip the sidebar entry offline so the tree stops
// showing a connection that no longer exists.
listen<{ method: string; params: { state?: string; connectionId?: string } }>("dbx-plugin-event", async (event) => {
try {
const payload = event.payload ?? ({} as typeof event.payload);
if (payload.method !== "ssh/session/state" || payload.params?.state !== "disconnected") return;
const connectionId = payload.params?.connectionId;
if (!connectionId) return;
if (!connectionStore.connections.length) await connectionStore.initFromDisk();
if (!connectionStore.getConfig(connectionId)) return;
connectionStore.markConnectionOffline(connectionId);
} catch (e) {
console.error("[DBX] dbx-plugin-event (ssh/session/state) error:", e);
}
}).then((unlisten) => unlistenHandles.push(unlisten));
listen("mcp-reload-connections", async () => {
try {
await connectionStore.initFromDisk();
+7 -1
View File
@@ -57,6 +57,11 @@ if (savedThemeMode && savedThemeMode !== themeMode.value) safeLocalStorageSet(AP
if (savedCornerStyle && savedCornerStyle !== cornerStyle.value) safeLocalStorageSet(APP_CORNER_STYLE_STORAGE_KEY, cornerStyle.value);
const systemPrefersDark = ref(readSystemPrefersDark());
const isDark = computed(() => resolveAppThemeAppearance(themeMode.value, systemPrefersDark.value) === "dark");
// Bumped at the end of every applyTheme(): theme changes are DOM mutations
// (classes / inline vars), so watchers keyed on reactive sources like isDark
// miss palette switches and custom-color edits. Consumers that mirror the
// resolved token set (plugin iframe bridge) watch this revision instead.
const themeRevision = ref(0);
let mediaQuery: MediaQueryList | null = null;
let isListeningForSystemTheme = false;
@@ -103,6 +108,7 @@ function applyTheme() {
// force reflow so the class toggle takes effect before re-enabling transitions
doc.offsetHeight; // eslint-disable-line @typescript-eslint/no-unused-expressions
themeRevision.value += 1;
requestAnimationFrame(() => doc.classList.remove("disable-transitions"));
if (!isTauriRuntime()) return;
@@ -206,5 +212,5 @@ export function useTheme() {
setThemeMode(isDark.value ? "light" : "dark");
}
return { isDark, themeMode, themePalette, customUiColors, customUiColorsDark, activeCustomUiColors, cornerStyle, applyTheme, setThemeMode, setThemePalette, previewThemePalette, clearThemePalettePreview, setCustomUiColors, resetCustomUiColors, setCornerStyle, toggleTheme };
return { isDark, themeMode, themePalette, customUiColors, customUiColorsDark, activeCustomUiColors, themeRevision, cornerStyle, applyTheme, setThemeMode, setThemePalette, previewThemePalette, clearThemePalettePreview, setCustomUiColors, resetCustomUiColors, setCornerStyle, toggleTheme };
}
+3
View File
@@ -1121,6 +1121,9 @@ export default {
databaseCategoryPlugins: "Plugins",
pluginProviderUnavailable: "This plugin connection provider is unavailable. Reinstall or enable the plugin, then try again.",
pluginRequiredField: "{field} is required",
pluginSshKeyPickerPlaceholder: "Pick local SSH key…",
pluginSshKeyPickerEmpty: "No local SSH keys found",
pluginSshKeyEncrypted: "encrypted",
jdbcConnection: "JDBC connection",
iconView: "Icon view",
listView: "List view",
+3
View File
@@ -1390,6 +1390,9 @@ export default withEnglishFallback({
doltShowSystemTables: "Mostrar tablas integradas de Dolt",
doltShowSystemTablesHint: "Muestra las tablas integradas de control de versiones de Dolt en la lista de tablas de la conexión actual.",
clientCertHint: "Cuando se requiere mTLS, el certificado de cliente y la clave privada deben completarse juntos.",
pluginSshKeyPickerPlaceholder: "Seleccionar clave SSH local…",
pluginSshKeyPickerEmpty: "No se encontraron claves",
pluginSshKeyEncrypted: "Cifrado",
},
editor: {
duckdbDraining: "La consulta anterior de DuckDB aún se está deteniendo. Vuelva a intentarlo en breve.",
+3
View File
@@ -1388,6 +1388,9 @@ export default withEnglishFallback({
doltShowSystemTables: "Mostra le tabelle integrate di Dolt",
doltShowSystemTablesHint: "Mostra le tabelle di controllo versione integrate di Dolt nell'elenco delle tabelle della connessione corrente.",
clientCertHint: "Quando è richiesto mTLS, il certificato client e la chiave privata devono essere inseriti insieme.",
pluginSshKeyPickerPlaceholder: "Seleziona chiave SSH locale…",
pluginSshKeyPickerEmpty: "Nessuna chiave trovata",
pluginSshKeyEncrypted: "Crittografata",
},
editor: {
duckdbDraining: "La query DuckDB precedente è ancora in fase di arresto. Riprova a breve.",
+3
View File
@@ -1409,6 +1409,9 @@ export default withEnglishFallback({
doltShowSystemTables: "Dolt 組み込みテーブルを表示",
doltShowSystemTablesHint: "現在の接続のテーブルリストに Dolt 組み込みのバージョン管理テーブルを表示します。",
clientCertHint: "mTLS が要求される場合、クライアント証明書と秘密鍵は一緒に入力する必要があります。",
pluginSshKeyPickerPlaceholder: "ローカルの SSH キーを選択…",
pluginSshKeyPickerEmpty: "キーが見つかりません",
pluginSshKeyEncrypted: "暗号化済み",
},
editor: {
duckdbDraining: "前回の DuckDB クエリはまだ停止処理中です。しばらくしてから再試行してください。",
+3
View File
@@ -1307,6 +1307,9 @@ export default withEnglishFallback({
databaseCategoryPlugins: "플러그인",
pluginProviderUnavailable: "이 플러그인 연결 제공자를 사용할 수 없습니다. 플러그인을 다시 설치하거나 활성화한 후 다시 시도하세요.",
pluginRequiredField: "{field}을(를) 입력하세요.",
pluginSshKeyPickerPlaceholder: "로컬 SSH 키 선택…",
pluginSshKeyPickerEmpty: "키를 찾을 수 없음",
pluginSshKeyEncrypted: "암호화됨",
},
editor: {
duckdbDraining: "이전 DuckDB 쿼리가 아직 중지 중입니다. 잠시 후 다시 시도해 주세요.",
+3
View File
@@ -1389,6 +1389,9 @@ export default withEnglishFallback({
doltShowSystemTables: "Exibir tabelas internas do Dolt",
doltShowSystemTablesHint: "Exibir as tabelas de controle de versão internas do Dolt na lista de tabelas da conexão atual.",
clientCertHint: "Quando o mTLS for exigido, o certificado do cliente e a chave privada devem ser preenchidos juntos.",
pluginSshKeyPickerPlaceholder: "Selecionar chave SSH local…",
pluginSshKeyPickerEmpty: "Nenhuma chave encontrada",
pluginSshKeyEncrypted: "Criptografada",
},
editor: {
duckdbDraining: "A consulta anterior do DuckDB ainda está sendo interrompida. Tente novamente em breve.",
+3
View File
@@ -1036,6 +1036,9 @@ export default withEnglishFallback({
databaseCategoryPlugins: "插件",
pluginProviderUnavailable: "此插件连接提供者不可用,请重新安装或启用插件后再试。",
pluginRequiredField: "请填写{field}",
pluginSshKeyPickerPlaceholder: "选择本机 SSH 密钥…",
pluginSshKeyPickerEmpty: "未发现密钥",
pluginSshKeyEncrypted: "已加密",
jdbcConnection: "JDBC 连接",
iconView: "图标视图",
listView: "列表视图",
+3
View File
@@ -920,6 +920,9 @@ export default withEnglishFallback({
databaseCategoryPlugins: "外掛",
pluginProviderUnavailable: "此外掛連線提供者不可用,請重新安裝或啟用外掛後再試。",
pluginRequiredField: "請填寫{field}",
pluginSshKeyPickerPlaceholder: "選擇本機 SSH 金鑰…",
pluginSshKeyPickerEmpty: "未發現金鑰",
pluginSshKeyEncrypted: "已加密",
jdbcConnection: "JDBC 連線",
iconView: "圖示檢視",
listView: "清單檢視",
@@ -33,9 +33,12 @@ describe("ConnectionDialog service deep-link hydration", () => {
});
it("keeps failed one-time connections available for error inspection and retry", () => {
const saveStart = source.indexOf("async function save(");
// The save path delegates one-time connects to startSavedConnection,
// which owns the connectFailed emission for failed retries.
const start = source.indexOf("function startSavedConnection");
const saveStart = source.indexOf("async function save(", start);
const saveEnd = source.indexOf('const dialogTitle = ref("")', saveStart);
const saveBody = source.slice(saveStart, saveEnd);
const saveBody = source.slice(start, saveEnd);
expect(saveBody).not.toContain("store.removeConnection(config.id)");
expect(saveBody).toContain('emit("connectFailed"');
@@ -60,7 +60,7 @@ function profileSwitchHarness(selectedProfile: string, editing = false) {
const javascript = ts.transpileModule(functionSource("onDbTypeChange"), {
compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 },
}).outputText;
const selectProfile = new Function("editingId", "selectedType", "resetForm", "dbCategoryForOption", "selectedDbCategory", "customDriverName", "applyProfile", "resetTestState", "resetVisibleSchemasState", `${javascript}\nreturn onDbTypeChange;`)(
const selectProfile = new Function("editingId", "selectedType", "resetForm", "dbCategoryForOption", "selectedDbCategory", "customDriverName", "applyProfile", "resetTestState", "resetVisibleSchemasState", "parsePluginConnectionProviderOptionValue", `${javascript}\nreturn onDbTypeChange;`)(
editingId,
selectedType,
resetForm,
@@ -70,6 +70,7 @@ function profileSwitchHarness(selectedProfile: string, editing = false) {
applyProfile,
() => events.push("reset-test"),
() => events.push("reset-schemas"),
() => null,
) as (profile: string) => void;
return { customDriverName, events, form, selectProfile, selectedType };
@@ -16,7 +16,9 @@ function selectedProfileHarness(selectedTypeValue: string, driverProfile: string
const javascript = ts.transpileModule(declaration!.getText(), {
compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 },
}).outputText;
const selectedProfile = new Function("driverProfiles", "selectedType", "form", `${javascript}\nreturn selectedProfile;`)(
// The merged dialog checks plugin providers before the profile table; the
// plugin branch is stubbed out so this harness pins the GBase profile path.
const selectedProfile = new Function("driverProfiles", "selectedType", "form", "parsePluginConnectionProviderOptionValue", "selectedPluginProvider", `${javascript}\nreturn selectedProfile;`)(
{
mysql: { label: "MySQL" },
gbase: { label: "南大通用 GBase 8a" },
@@ -25,6 +27,8 @@ function selectedProfileHarness(selectedTypeValue: string, driverProfile: string
},
{ value: selectedTypeValue },
{ value: { driver_profile: driverProfile } },
() => null,
{ value: null },
) as () => { label: string };
return selectedProfile();
}
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const toolbarSource = readFileSync(new URL("../../../components/layout/AppToolbar.vue", import.meta.url), "utf8");
const editorGroupTabBarSource = readFileSync(new URL("../../../components/layout/EditorGroupTabBar.vue", import.meta.url), "utf8");
const groupTabBarSource = readFileSync(new URL("../../../components/layout/EditorGroupTabBar.vue", import.meta.url), "utf8");
const driverStoreSource = readFileSync(new URL("../../../components/config/DriverStoreDialog.vue", import.meta.url), "utf8");
const appDialogsSource = readFileSync(new URL("../../../components/layout/AppDialogs.vue", import.meta.url), "utf8");
const connectionDialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
@@ -15,7 +15,10 @@ describe("plugin center integration", () => {
expect(toolbarSource).toContain("emit('open-plugin-center')");
expect(appSource).toContain('@open-plugin-center="openPluginCenterPage()"');
expect(appSource).toContain("<PluginCenterPage");
expect(editorGroupTabBarSource).toContain("data-plugin-center-tab");
// The plugin center pill lives in the focused group's tab strip, like the
// settings and driver-store special pages.
expect(groupTabBarSource).toContain("data-plugin-center-tab");
expect(appSource).toContain(':plugin-center-open="pluginCenterTabOpen"');
});
it("keeps plugin management out of Driver Manager", () => {
@@ -223,6 +223,8 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
: {}),
...(tab.mode === "query" && tab.activeResultRunId !== undefined ? { activeResultRunId: tab.activeResultRunId } : {}),
...(tab.mode === "query" && typeof tab.resultAutoSave === "boolean" ? { resultAutoSave: tab.resultAutoSave } : {}),
...(tab.pluginWorkbench ? { pluginWorkbench: tab.pluginWorkbench } : {}),
...(tab.pluginFilesystem ? { pluginFilesystem: tab.pluginFilesystem } : {}),
...(tab.uiState ? { uiState: sanitizeTabUiState(tab.uiState) } : {}),
...(tab.mode === "query" && tab.resultAutoSave ? { resultAutoSave: true } : {}),
}));
+1
View File
@@ -406,6 +406,7 @@ export const setAiGlobalCustomInstructions = forward("setAiGlobalCustomInstructi
// System
export const listSystemFonts = forward("listSystemFonts");
export const listSshConfigHosts = forward("listSshConfigHosts");
export const listLocalSshKeys = forward("listLocalSshKeys");
// SQL File Execution
export const previewSqlFile = forward("previewSqlFile");
+6
View File
@@ -46,6 +46,7 @@ import type {
SavedSqlFolder,
SavedSqlLibrary,
SshConfigHostEntry,
LocalSshKey,
TunnelProfile,
} from "@/types/database";
import type { DetachedTabHandoff } from "@/lib/app/detachedTabHandoff";
@@ -531,6 +532,11 @@ export async function listSshConfigHosts(): Promise<SshConfigHostEntry[]> {
return get("/api/ssh/config-hosts");
}
export async function listLocalSshKeys(): Promise<LocalSshKey[]> {
console.warn("listLocalSshKeys: local SSH key discovery is not available in the web backend");
return [];
}
export async function listPlugins(): Promise<InstalledPlugin[]> {
return get("/api/plugins");
}
+5
View File
@@ -71,6 +71,7 @@ import type {
SavedSqlFolder,
SavedSqlLibrary,
SshConfigHostEntry,
LocalSshKey,
TunnelProfile,
TransactionLog,
ExternalSqlFileVersion,
@@ -982,6 +983,10 @@ export async function listSshConfigHosts(): Promise<SshConfigHostEntry[]> {
return invoke("list_ssh_config_hosts");
}
export async function listLocalSshKeys(): Promise<LocalSshKey[]> {
return invoke("list_local_ssh_keys");
}
export async function pendingOpenSqlFiles(): Promise<string[]> {
return invoke("pending_open_sql_files");
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { PluginFormField } from "@/types/database";
import { connectionNeedsPasswordPrompt, pluginConnectionNeedsPasswordPrompt } from "@/lib/connection/connectionPassword";
/** Mirrors the io.dbx.ssh connection-provider password/authentication fields. */
const sshAuthFields: PluginFormField[] = [
{ key: "authentication", label: "Authentication", type: "select", binding: "config", default: "password", options: [] },
{
key: "password",
label: "Password",
type: "password",
binding: "password",
visible_when: { field: "authentication", one_of: ["password", "private-key-password"] },
required_when: { field: "authentication", one_of: ["password", "private-key-password"] },
},
];
describe("connectionNeedsPasswordPrompt", () => {
it("keeps requiring a prompt for password-carrying connections with saving disabled", () => {
expect(connectionNeedsPasswordPrompt({ db_type: "postgres", save_password: false, password: "" } as never)).toBe(true);
});
it("keeps skipping the prompt when a password is stored or saving is allowed", () => {
expect(connectionNeedsPasswordPrompt({ db_type: "postgres", save_password: false, password: "pw" } as never)).toBe(false);
expect(connectionNeedsPasswordPrompt({ db_type: "postgres", save_password: true, password: "" } as never)).toBe(false);
});
});
describe("pluginConnectionNeedsPasswordPrompt", () => {
it("does not prompt for SSH key auth even with save_password disabled", () => {
expect(pluginConnectionNeedsPasswordPrompt(sshAuthFields, { authentication: "private-key" })).toBe(false);
expect(pluginConnectionNeedsPasswordPrompt(sshAuthFields, { authentication: "agent" })).toBe(false);
expect(pluginConnectionNeedsPasswordPrompt(sshAuthFields, { authentication: "none" })).toBe(false);
});
it("prompts when the manifest marks the password field required for the auth mode", () => {
expect(pluginConnectionNeedsPasswordPrompt(sshAuthFields, { authentication: "password" })).toBe(true);
expect(pluginConnectionNeedsPasswordPrompt(sshAuthFields, { authentication: "private-key-password" })).toBe(true);
});
it("falls back to the manifest field default when external_config omits the condition key", () => {
// Default "password" → the form (and this check) sees password auth as active.
expect(pluginConnectionNeedsPasswordPrompt(sshAuthFields, {})).toBe(true);
expect(pluginConnectionNeedsPasswordPrompt(sshAuthFields, undefined)).toBe(true);
});
it("never prompts when the manifest declares no password-bound field", () => {
const keyPathOnly: PluginFormField[] = [{ key: "private_key_path", label: "Key", type: "text", binding: "config" }];
expect(pluginConnectionNeedsPasswordPrompt(keyPathOnly, { authentication: "password" })).toBe(false);
expect(pluginConnectionNeedsPasswordPrompt([], {})).toBe(false);
});
it("treats a condition key with no value and no manifest default as unmatched", () => {
const noDefaultAuth: PluginFormField[] = [
{ key: "authentication", label: "Authentication", type: "select", binding: "config", options: [] },
{
key: "password",
label: "Password",
type: "password",
binding: "password",
visible_when: { field: "authentication", one_of: ["password"] },
required_when: { field: "authentication", one_of: ["password"] },
},
];
expect(pluginConnectionNeedsPasswordPrompt(noDefaultAuth, {})).toBe(false);
expect(pluginConnectionNeedsPasswordPrompt(noDefaultAuth, undefined)).toBe(false);
});
it("prompts when the password field is statically required", () => {
const staticallyRequired: PluginFormField[] = [{ key: "password", label: "Password", type: "password", binding: "password", required: true }];
expect(pluginConnectionNeedsPasswordPrompt(staticallyRequired, {})).toBe(true);
});
});
@@ -1,4 +1,5 @@
import type { ConnectionConfig } from "@/types/database";
import type { ConnectionConfig, PluginFormField, PluginFormFieldValue } from "@/types/database";
import { pluginFieldIsRequired } from "@/lib/plugins/pluginFieldConditions";
type PasswordAuthenticationConfig = Pick<ConnectionConfig, "db_type" | "driver_profile" | "url_params">;
@@ -30,3 +31,26 @@ export function connectionUsesPasswordlessAuthentication(config: PasswordAuthent
export function connectionNeedsPasswordPrompt(config: ConnectionConfig): boolean {
return config.save_password === false && !config.password && !connectionUsesPasswordlessAuthentication(config);
}
/**
* Manifest-driven re-check for plugin connections. The shared prompt can only
* collect a field bound to `password`, so it applies only when the plugin's
* manifest marks that field required for the connection's current
* external_config. Auth modes that do not consume a login password (e.g. SSH
* private key / agent) declare no matching `required_when` and must connect
* without the prompt, even when save_password is disabled and no password is
* stored.
*/
export function pluginConnectionNeedsPasswordPrompt(fields: readonly PluginFormField[], externalConfig: unknown): boolean {
const passwordFields = fields.filter((field) => field.binding === "password");
if (!passwordFields.length) return false;
const readValue = (key: string): PluginFormFieldValue => {
const stored = isRecord(externalConfig) ? (externalConfig[key] as PluginFormFieldValue) : undefined;
return stored !== undefined ? stored : fields.find((candidate) => candidate.key === key)?.default;
};
return passwordFields.some((field) => pluginFieldIsRequired(field, readValue));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -124,8 +124,7 @@ export function supportsClearableQuerySchema(dbType?: DatabaseType): boolean {
* SQL editor against a broker (issue #8415).
*/
export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
return dbType !== "nacos" && dbType !== "consul" && dbType !== "hbase" && dbType !== "zookeeper" && dbType !== "mq" && dbType !== "mqtt";
return dbType !== "nacos" && dbType !== "consul" && dbType !== "hbase";
return dbType !== "nacos" && dbType !== "consul" && dbType !== "hbase" && dbType !== "zookeeper" && dbType !== "plugin" && dbType !== "mq" && dbType !== "mqtt";
}
/**
+58
View File
@@ -31,6 +31,45 @@ function connectionProvider(overrides: Partial<PluginConnectionProviderContribut
}
describe("FrontendPluginRegistry", () => {
it("migrates a config-bound secret on edit, preserves multiline values and removes the plaintext copy", () => {
const provider = connectionProvider({ fields: [{ key: "key", label: "Private key", type: "textarea", binding: "secret" }] });
const pem = "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----\n";
const existing = buildPluginConnectionConfig("example.plugin", connectionProvider({ fields: [] }), {});
existing.external_config = { key: pem, root: "/files" };
const values = pluginConnectionFormValues(provider, existing);
expect(values.key).toBe(pem);
const saved = buildPluginConnectionConfig("example.plugin", provider, values, existing);
expect(saved.connection_secrets?.key).toBe(pem);
expect(saved.external_config).toEqual({ root: "/files" });
expect(existing.external_config).toEqual({ key: pem, root: "/files" });
expect(pluginConnectionFormValues(provider, saved).key).toBe(pem);
existing.connection_secrets = { key: "newer secret" };
expect(pluginConnectionFormValues(provider, existing).key).toBe("newer secret");
const cleared = buildPluginConnectionConfig("example.plugin", provider, { key: "" }, existing);
expect(cleared.connection_secrets?.key).toBeUndefined();
expect(cleared.external_config).toEqual({ root: "/files" });
});
it("round-trips automatic ports as an empty input and preserves explicit custom ports", () => {
const provider = connectionProvider({ fields: [{ key: "port", label: "Port", type: "number", binding: "port" }] });
const automatic = buildPluginConnectionConfig("example.plugin", provider, {});
expect(automatic.port).toBe(0);
expect(pluginConnectionFormValues(provider, automatic).port).toBeUndefined();
const explicit = buildPluginConnectionConfig("example.plugin", provider, { port: 1636 });
expect(pluginConnectionFormValues(provider, explicit).port).toBe(1636);
});
it("preserves the save-password preference for plugin connections", () => {
const provider = connectionProvider({ fields: [] });
const defaultConfig = buildPluginConnectionConfig("example.plugin", provider, {});
expect(defaultConfig.save_password).toBe(true);
const transient = buildPluginConnectionConfig("example.plugin", provider, {}, { ...defaultConfig, save_password: false });
expect(transient.save_password).toBe(false);
});
it("round-trips plugin connection provider picker values", () => {
const value = pluginConnectionProviderOptionValue("example/plugin", "ssh:main");
expect(parsePluginConnectionProviderOptionValue(value)).toEqual({ pluginId: "example/plugin", providerId: "ssh:main" });
@@ -327,4 +366,23 @@ describe("FrontendPluginRegistry", () => {
keepalive: false,
});
});
it("builds from a reactive (non-structured-cloneable) existing config", () => {
// Connection configs reach the builder through props as Vue reactive
// proxies; structuredClone refuses them ("The object can not be cloned.").
const provider = connectionProvider({
id: "example.ssh",
database_type: "ssh",
fields: [{ key: "authentication", label: "Authentication", type: "select", binding: "config", default: "password", options: [] }],
});
const existing = {
id: "ssh-1",
external_config: new Proxy({ authentication: "private-key" }, {}),
} as unknown as Parameters<typeof buildPluginConnectionConfig>[3];
const config = buildPluginConnectionConfig("example.plugin", provider, { authentication: "private-key" }, existing);
expect(config.external_config).toEqual({ authentication: "private-key" });
expect(config.id).toBe("ssh-1");
});
});
@@ -139,9 +139,12 @@ export function pluginConnectionFormValues(contribution: PluginConnectionProvide
: binding === "database"
? config.database
: binding === "secret"
? secrets[field.key]
? (secrets[field.key] ?? externalConfig[field.key])
: externalConfig[field.key];
if (isPluginFormFieldValue(value)) values[field.key] = value;
// Port 0 is the stored representation of an optional, automatic port.
// Keep that input empty on reopen so its protocol-default hint remains visible.
if (binding === "port" && value === 0 && field.default === undefined) delete values[field.key];
else if (isPluginFormFieldValue(value)) values[field.key] = value;
}
return values;
}
@@ -172,6 +175,7 @@ export function buildPluginConnectionConfig(pluginId: string, contribution: Plug
idle_timeout_secs: existing?.idle_timeout_secs || 60,
keepalive_interval_secs: existing?.keepalive_interval_secs || 30,
read_only: existing?.read_only || false,
save_password: existing?.save_password !== false,
is_production: existing?.is_production || false,
production_databases: existing?.production_databases || [],
};
@@ -182,6 +186,9 @@ export function buildPluginConnectionConfig(pluginId: string, contribution: Plug
if (value === undefined) delete externalConfig[field.key];
else externalConfig[field.key] = value;
} else if (binding === "secret") {
// A plugin may migrate a formerly config-bound field to secret binding.
// Load its legacy value above, then remove the plaintext copy on save.
delete externalConfig[field.key];
if (value === undefined || value === "") delete connectionSecrets[field.key];
else connectionSecrets[field.key] = String(value);
} else if (binding === "name") {
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { buildPluginAppearance, FALLBACK_APPEARANCE_COLORS, readAppearanceTokens } from "./pluginAppearance";
describe("buildPluginAppearance", () => {
it("uses the pearl (white) fallback tokens for light and the .dark tokens for dark", () => {
const light = buildPluginAppearance(false, {}, { fontFamily: "Fira Code", fontSize: 13 });
expect(light.colorScheme).toBe("light");
expect(light.colors).toEqual(FALLBACK_APPEARANCE_COLORS.light);
expect(FALLBACK_APPEARANCE_COLORS.light.background).toBe("rgb(255 255 255)");
const dark = buildPluginAppearance(true, {}, { fontFamily: "Fira Code", fontSize: 13 });
expect(dark.colorScheme).toBe("dark");
expect(dark.colors).toEqual(FALLBACK_APPEARANCE_COLORS.dark);
});
it("prefers live design tokens over the fallback palette", () => {
const appearance = buildPluginAppearance(false, { background: "rgb(250 251 253)", foreground: "rgb(40 44 52)" }, { fontFamily: "Fira Code", fontSize: 14 }, "SF Pro");
expect(appearance.colors.background).toBe("rgb(250 251 253)");
expect(appearance.colors.foreground).toBe("rgb(40 44 52)");
expect(appearance.colors.muted).toBe(FALLBACK_APPEARANCE_COLORS.light.muted);
expect(appearance.terminal).toEqual({ fontFamily: "Fira Code", fontSize: 14 });
expect(appearance.ui).toEqual({ fontFamily: "SF Pro" });
});
it("omits the ui font when the host has none", () => {
const appearance = buildPluginAppearance(true, {}, { fontFamily: "Fira Code", fontSize: 13 });
expect(appearance.ui).toBeUndefined();
});
it("returns no tokens outside a browser context", () => {
// jsdom 环境下 getComputedStyle 存在,但令牌读取不应抛错。
expect(() => readAppearanceTokens()).not.toThrow();
});
});
@@ -0,0 +1,84 @@
// 插件沙箱外观快照:读取 DBX 根节点的设计令牌(随 .dark / 调色板类
// 实时变化)加编辑器字体设置,组装成插件契约里的 appearance 对象,
// 供 PluginHostBridge 在 init 下发、主题变化时实时推送。
export interface PluginAppearanceColors {
background: string;
foreground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
border: string;
destructive: string;
}
export interface PluginAppearance {
colorScheme: "light" | "dark";
colors: PluginAppearanceColors;
terminal: { fontFamily: string; fontSize: number };
ui?: { fontFamily: string };
}
// camelCase 字段 → DBX globals.css 的 CSS 令牌名。
const APPEARANCE_TOKENS = [
["background", "--background"],
["foreground", "--foreground"],
["muted", "--muted"],
["mutedForeground", "--muted-foreground"],
["accent", "--accent"],
["accentForeground", "--accent-foreground"],
["border", "--border"],
["destructive", "--destructive"],
] as const;
// 读取失败时的兜底:与 DBX globals.css 的 :root(pearl)/.dark 规范块一致。
export const FALLBACK_APPEARANCE_COLORS: Record<"light" | "dark", PluginAppearanceColors> = {
light: {
background: "rgb(255 255 255)",
foreground: "rgb(10 10 10)",
muted: "rgb(245 245 245)",
mutedForeground: "rgb(115 115 115)",
accent: "rgb(245 245 245)",
accentForeground: "rgb(23 23 23)",
border: "rgb(229 229 229)",
destructive: "rgb(231 0 11)",
},
dark: {
background: "rgb(19 20 22)",
foreground: "rgb(215 215 219)",
muted: "rgb(42 42 45)",
mutedForeground: "rgb(151 152 157)",
accent: "rgb(46 47 51)",
accentForeground: "rgb(221 221 226)",
border: "rgb(110 110 114 / 0.28)",
destructive: "rgb(243 98 95)",
},
};
export function readAppearanceTokens(): Partial<Record<(typeof APPEARANCE_TOKENS)[number][0], string>> {
const tokens: Record<string, string> = {};
if (typeof window === "undefined" || typeof getComputedStyle !== "function") return tokens;
const style = getComputedStyle(document.documentElement);
for (const [key, cssName] of APPEARANCE_TOKENS) {
const value = style.getPropertyValue(cssName).trim();
if (value) tokens[key] = value;
}
return tokens;
}
export function buildPluginAppearance(isDark: boolean, tokens: ReturnType<typeof readAppearanceTokens>, terminal: { fontFamily: string; fontSize: number }, uiFontFamily?: string): PluginAppearance {
const fallback = FALLBACK_APPEARANCE_COLORS[isDark ? "dark" : "light"];
const colors = { ...fallback };
for (const [key] of APPEARANCE_TOKENS) {
const value = tokens[key];
if (value) colors[key] = value;
}
const appearance: PluginAppearance = {
colorScheme: isDark ? "dark" : "light",
colors,
terminal,
};
if (uiFontFamily) appearance.ui = { fontFamily: uiFontFamily };
return appearance;
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { pluginFieldConditionMatches, pluginFieldIsRequired, pluginFieldIsVisible } from "./pluginFieldConditions";
import type { PluginFormField, PluginFormFieldValue } from "@/types/database";
function field(overrides: Partial<PluginFormField>): PluginFormField {
return { key: "demo", label: "Demo", type: "text", ...overrides } as PluginFormField;
}
const readValue = (key: string) => (key === "mode" ? "custom" : undefined);
describe("pluginFieldConditionMatches", () => {
it("matches a missing condition and a listed value", () => {
expect(pluginFieldConditionMatches(undefined, undefined)).toBe(true);
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, "custom")).toBe(true);
});
it("rejects an unlisted or empty value", () => {
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, "direct")).toBe(false);
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, undefined)).toBe(false);
});
});
describe("pluginFieldIsRequired", () => {
it("honors static required", () => {
expect(pluginFieldIsRequired(field({ required: true }), readValue)).toBe(true);
});
it("treats a field without required_when as optional", () => {
expect(pluginFieldIsRequired(field({}), readValue)).toBe(false);
});
it("requires a field only while its required_when matches", () => {
const conditional = field({ required_when: { field: "mode", one_of: ["custom"] } });
expect(pluginFieldIsRequired(conditional, readValue)).toBe(true);
expect(pluginFieldIsRequired(conditional, () => "direct")).toBe(false);
expect(pluginFieldIsRequired(conditional, () => undefined)).toBe(false);
});
});
describe("pluginFieldIsVisible", () => {
it("renders fields without visible_when and gates conditional ones", () => {
expect(pluginFieldIsVisible(field({}), readValue)).toBe(true);
const conditional = field({ visible_when: { field: "mode", one_of: ["custom"] } });
expect(pluginFieldIsVisible(conditional, readValue)).toBe(true);
expect(pluginFieldIsVisible(conditional, () => "direct")).toBe(false);
});
it("keeps a condition dormant while its referenced sibling is hidden, even when the sibling default would match", () => {
// kafka-shaped chain: sasl_mechanism (default PLAIN, hidden) →
// oauth_token_source (visible only for OAUTHBEARER, defaults msk_iam) →
// msk_region. The msk_iam default must not surface msk_region while SASL
// is off, otherwise the dialog footer dead-locks on an unfillable field.
const fields: Record<string, PluginFormField> = {
security_protocol: field({ key: "security_protocol", default: "PLAINTEXT" }),
sasl_mechanism: field({ key: "sasl_mechanism", default: "PLAIN", visible_when: { field: "security_protocol", one_of: ["SASL_PLAINTEXT", "SASL_SSL"] } }),
oauth_token_source: field({ key: "oauth_token_source", default: "msk_iam", visible_when: { field: "sasl_mechanism", one_of: ["OAUTHBEARER"] } }),
msk_region: field({ key: "msk_region", visible_when: { field: "oauth_token_source", one_of: ["msk_iam"] } }),
};
const read: (key: string) => PluginFormFieldValue = (key) => fields[key]?.default;
const resolve: (key: string) => PluginFormField | undefined = (key) => fields[key];
expect(pluginFieldIsVisible(fields.msk_region, read, resolve)).toBe(false);
// Turning OAUTHBEARER on makes the whole chain visible again.
const readOauth: (key: string) => PluginFormFieldValue = (key) => (key === "sasl_mechanism" ? "OAUTHBEARER" : key === "security_protocol" ? "SASL_SSL" : fields[key]?.default);
expect(pluginFieldIsVisible(fields.oauth_token_source, readOauth, resolve)).toBe(true);
expect(pluginFieldIsVisible(fields.msk_region, readOauth, resolve)).toBe(true);
});
it("ignores unknown condition fields and survives self-referencing cycles", () => {
const unknown = field({ visible_when: { field: "missing", one_of: ["x"] } });
expect(
pluginFieldIsVisible(
unknown,
() => "x",
() => undefined,
),
).toBe(true);
const selfRef = field({ key: "loop", visible_when: { field: "loop", one_of: ["x"] } });
expect(
pluginFieldIsVisible(
selfRef,
() => "x",
(key) => (key === "loop" ? selfRef : undefined),
),
).toBe(true);
});
});
@@ -0,0 +1,47 @@
import type { PluginFieldCondition, PluginFormField, PluginFormFieldValue } from "@/types/database";
/**
* Evaluates a plugin manifest field condition (`visible_when` / `required_when`,
* Host API 1.1). A condition matches when the current value of the referenced
* sibling field is listed in `one_of`. Missing conditions always match.
*/
export function pluginFieldConditionMatches(condition: PluginFieldCondition | undefined, value: PluginFormFieldValue): boolean {
if (!condition) return true;
if (value === undefined || value === null || (typeof value === "string" && value.trim().length === 0)) return false;
return condition.one_of.includes(String(value));
}
/** Looks a sibling field up by key; returns undefined when the key is unknown. */
export type PluginFieldResolver = (key: string) => PluginFormField | undefined;
/**
* A field is rendered when its `visible_when` (if any) matches current values.
* Conditions cascade: when the referenced sibling is itself hidden, its stored
* or default value must not light the condition up — otherwise a nested
* default (e.g. `oauth_token_source: "msk_iam"` while SASL is off) marks
* grandchild fields visible and required and dead-locks the dialog footer.
*/
export function pluginFieldIsVisible(field: PluginFormField, readValue: (key: string) => PluginFormFieldValue, resolveField?: PluginFieldResolver): boolean {
return pluginFieldIsVisibleCached(field, readValue, resolveField, new Set([field.key]));
}
function pluginFieldIsVisibleCached(field: PluginFormField, readValue: (key: string) => PluginFormFieldValue, resolveField: PluginFieldResolver | undefined, seen: Set<string>): boolean {
const condition = field.visible_when;
if (!condition) return true;
if (!pluginFieldConditionMatches(condition, readValue(condition.field))) return false;
const target = resolveField?.(condition.field);
if (!target || seen.has(target.key)) return true;
seen.add(target.key);
return pluginFieldIsVisibleCached(target, readValue, resolveField, seen);
}
/** Effective required = static `required` OR a matching `required_when`. */
export function pluginFieldIsRequired(field: PluginFormField, readValue: (key: string) => PluginFormFieldValue): boolean {
if (field.required) return true;
// No `required_when` means the field is simply optional: the shared
// condition helper treats a missing condition as "always matches" (correct
// for visibility), so it must not be consulted here — that used to mark
// every plugin field required and dead-locked the connection footer.
if (!field.required_when) return false;
return pluginFieldConditionMatches(field.required_when, readValue(field.required_when.field));
}
+83 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { reactive, readonly } from "vue";
import { PluginHostBridge, pluginSandboxDocument } from "./pluginHostBridge";
import { PluginHostBridge, pluginSandboxDocument, pluginSdkSource } from "./pluginHostBridge";
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
function plugin(permissions: string[] = []): InstalledPlugin {
@@ -291,6 +291,7 @@ describe("PluginHostBridge", () => {
expect(document).toContain("window.dbxPlugin");
expect(document).toContain("get locale() { return locale; }");
expect(document).toContain("openFilesystem");
expect(document).toContain("document.dispatchEvent(new CustomEvent('dbx-plugin-env'");
expect(document).toContain("shortcut: 'closeTab'");
expect(document).toContain("connect-src 'none'");
expect(document).toContain(".dbx-btn");
@@ -340,6 +341,30 @@ describe("PluginHostBridge", () => {
expect(closed).toContain("host.stream.chunk");
});
it("pre-seeds the current theme as the sandbox first paint", () => {
const themed = pluginSandboxDocument("<html><head></head><body></body></html>", ["host.events"], { appearance: "dark", tokens: { "--color-background": "#131416", "--color-foreground": "rgb(215 215 219)" } });
expect(themed).toContain("color-scheme: dark");
expect(themed).toContain("--color-background: #131416");
expect(themed).toContain("--color-foreground: rgb(215 215 219)");
expect(themed.indexOf("<style>:root{")).toBeGreaterThan(themed.indexOf(".dbx-btn"));
const unthemed = pluginSandboxDocument("<html><head></head><body></body></html>");
expect(unthemed).not.toContain("color-scheme: dark");
expect(unthemed).not.toContain("--color-background: #131416");
});
it("drops boot theme declarations that could break out of the style element", () => {
const hostile = pluginSandboxDocument("<html><head></head><body></body></html>", [], {
appearance: "dark",
tokens: { "--color-background": "#131416", color: "red", "--evil": "red}</style><script>alert(1)</script>", "--empty": " " },
});
expect(hostile).toContain("--color-background: #131416");
expect(hostile).not.toContain("--bad-name");
expect(hostile).not.toContain("--evil");
expect(hostile).not.toContain("--empty");
expect((hostile.match(/<script\b/gi) ?? []).length).toBe(1);
});
it("sends the theme in init and pushes theme updates through env messages", () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
@@ -353,3 +378,60 @@ describe("PluginHostBridge", () => {
expect(messages[1]).toMatchObject({ type: "env", locale: "en", theme: { appearance: "light" } });
});
});
describe("plugin SDK source", () => {
interface SdkWindow {
dbxPlugin?: { invoke: (method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise<unknown> };
}
function loadSdk(posted: unknown[]): SdkWindow {
const sandbox = {} as SdkWindow;
// The SDK IIFE only touches window and the bare-global addEventListener at
// boot; parent.postMessage is captured for later request() calls, so a
// stub window/parent is enough here.
new Function("window", "parent", "addEventListener", pluginSdkSource())(sandbox, { postMessage: (message: unknown) => posted.push(message) }, () => {});
return sandbox;
}
/** The SDK posts a `ready` message at boot; the first request follows it. */
function firstRequest(posted: unknown[]): { type: string; method: string; params: { method: string; params: Record<string, unknown> } } {
const request = posted.find((message) => (message as { type: string }).type === "request");
if (!request) throw new Error("SDK posted no request message");
return request as { type: string; method: string; params: { method: string; params: Record<string, unknown> } };
}
it("plainifies reactive (Proxy) params before postMessage", () => {
const posted: unknown[] = [];
const { dbxPlugin } = loadSdk(posted);
expect(dbxPlugin).toBeDefined();
// Vue reactive arrays/objects are Proxies; structured clone rejects them
// with "The object can not be cloned." (WebKit wording) — the SDK must
// recover the plain data before crossing the postMessage boundary.
const reactive: string[] = ["session-a", "session-b"];
const proxy = new Proxy(reactive, {});
void dbxPlugin!.invoke("ssh/terminal/batchInput", { sessionIds: proxy, command: "ls -la" });
const message = firstRequest(posted);
expect(message.method).toBe("backend.invoke");
expect(message.params.params).toEqual({ sessionIds: ["session-a", "session-b"], command: "ls -la" });
// The posted sessionIds must not be the incoming Proxy itself.
expect(message.params.params.sessionIds).not.toBe(proxy);
expect(JSON.stringify(message.params.params.sessionIds)).toBe(JSON.stringify(["session-a", "session-b"]));
});
it("keeps cloneable values intact and plain scalars by reference", () => {
const posted: unknown[] = [];
const { dbxPlugin } = loadSdk(posted);
const stamp = new Date("2026-09-06T00:00:00Z");
void dbxPlugin!.invoke("sample/hello", { count: 3, stamp, nested: new Map([["k", "v"]]) });
const params = firstRequest(posted).params.params as { count: number; stamp: Date; nested: Map<string, string> };
// structuredClone path: cloneable types keep their identity class.
expect(params.count).toBe(3);
expect(params.stamp).toBeInstanceOf(Date);
expect(params.nested).toBeInstanceOf(Map);
});
});
@@ -221,18 +221,36 @@ export function pluginSandboxDocument(html: string, permissions?: readonly strin
const networkOrigins = pluginNetworkOrigins(permissions);
const connectSrc = networkOrigins.length > 0 ? `connect-src ${networkOrigins.join(" ")};` : "connect-src 'none';";
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src data: blob:; font-src data: blob:; ${connectSrc} media-src data: blob:;">`;
const sdk = `<script>${pluginSdkSource()}</script>`;
const sdk = `<script>${pluginSdkSource(theme)}</script>`;
const uiKit = `<style>${pluginUiKitCss()}</style>`;
const themeBootstrap = pluginThemeBootstrap(theme);
const injection = `${csp}${uiKit}${themeBootstrap}${sdk}`;
// Placed after the uiKit so the boot `color-scheme` wins the cascade: the
// bridge init message (and the SDK's applyTheme) only runs once the iframe
// has loaded, and the uiKit's token fallbacks would otherwise paint the
// first frame white on dark hosts.
const bootTheme = pluginBootThemeCss(theme);
const injection = `${csp}${uiKit}${bootTheme ? `<style>${bootTheme}</style>` : ""}${sdk}`;
if (/<head(?:\s[^>]*)?>/i.test(html)) return html.replace(/<head(?:\s[^>]*)?>/i, (head) => `${head}${injection}`);
return `<!doctype html><html><head>${injection}</head><body>${html}</body></html>`;
}
function pluginThemeBootstrap(theme?: PluginBridgeTheme): string {
if (!theme) return "";
const serializedTheme = JSON.stringify(theme).replace(/</g, "\\u003c");
return `<script>(() => { const theme = ${serializedTheme}; const root = document.documentElement; root.dataset.dbxTheme = theme.appearance === "dark" ? "dark" : "light"; root.style.colorScheme = theme.appearance === "dark" ? "dark" : "light"; for (const [name, value] of Object.entries(theme.tokens || {})) { if (/^--[a-z0-9-]+$/i.test(name) && typeof value === "string") root.style.setProperty(name, value); } })();</script>`;
/**
* Pre-paint theme seed for the sandbox document. The bridge init message only
* arrives after the iframe load event, so without this style the first frame
* renders with the uiKit fallbacks (white background) before the real tokens
* land — the white flash when opening a plugin workbench on a dark host.
*/
export function pluginBootThemeCss(theme?: PluginBridgeTheme): string {
if (!theme || (theme.appearance !== "dark" && theme.appearance !== "light")) return "";
const declarations: string[] = [`color-scheme: ${theme.appearance}`];
const tokens = theme.tokens && typeof theme.tokens === "object" ? theme.tokens : {};
for (const [name, value] of Object.entries(tokens)) {
// Same name validation as the SDK's applyTheme; values must stay inside a
// single CSS declaration so they cannot break out of the style element.
if (!/^--[a-z0-9-]+$/i.test(name) || typeof value !== "string" || !value.trim()) continue;
if (!/^[^"{}<>;]*$/.test(value)) continue;
declarations.push(`${name}: ${value}`);
}
return `:root{${declarations.join(";")}}`;
}
/**
@@ -311,7 +329,14 @@ body {
`.trim();
}
function pluginSdkSource(): string {
export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
const safeTokens = Object.fromEntries(Object.entries(initialTheme?.tokens || {}).filter(([name, value]) => /^--[a-z0-9-]+$/i.test(name) && typeof value === "string" && !!value.trim() && /^[^"{}<>;]*$/.test(value)));
const safeInitialTheme = initialTheme && (initialTheme.appearance === "dark" || initialTheme.appearance === "light") ? { appearance: initialTheme.appearance, tokens: safeTokens } : null;
const serializedInitialTheme = JSON.stringify(safeInitialTheme)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
return `(() => {
const pending = new Map();
const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set() };
@@ -320,21 +345,37 @@ function pluginSdkSource(): string {
let locale = 'en';
let theme;
let resolveReady;
const initialTheme = ${serializedInitialTheme};
const applyTheme = (value) => {
if (!value || typeof value !== 'object') return;
theme = value;
const theme = value;
const root = document.documentElement;
root.dataset.dbxTheme = value.appearance === 'dark' ? 'dark' : 'light';
root.dataset.dbxTheme = theme.appearance === "dark" ? "dark" : "light";
root.style.colorScheme = theme.appearance === "dark" ? "dark" : "light";
const tokens = value.tokens && typeof value.tokens === 'object' ? value.tokens : {};
for (const [name, tokenValue] of Object.entries(tokens)) {
if (/^--[a-z0-9-]+$/i.test(name) && typeof tokenValue === 'string') root.style.setProperty(name, tokenValue);
}
};
const ready = new Promise((resolve) => { resolveReady = resolve; });
if (initialTheme) applyTheme(initialTheme);
// Plugin UIs routinely hand reactive state (Vue Proxy arrays/objects)
// straight to invoke(); postMessage cannot structured-clone a Proxy and
// WebKit rejects with "The object can not be cloned.". Mirror the host's
// structuredCloneSafe: clone when possible, otherwise recover the plain
// data with a JSON round-trip (the sidecar transport is JSON anyway).
const toPlain = (value) => {
if (!value || typeof value !== 'object') return value;
if (typeof structuredClone === 'function') {
try { return structuredClone(value); } catch {}
}
return JSON.parse(JSON.stringify(value));
};
const request = (method, params, options = {}) => new Promise((resolve, reject) => {
const id = String(++sequence);
pending.set(id, { resolve, reject });
const message = { source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'request', id, method, params };
const message = { source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'request', id, method, params: toPlain(params) };
if (options.transfer) {
message.data = options.transfer;
parent.postMessage(message, '*', [options.transfer]);
@@ -369,14 +410,14 @@ function pluginSdkSource(): string {
return;
}
removeListener?.();
removeListener = undefined;
if (message.method === 'host.stream.error') {
removeListener = undefined;
if (message.method === 'host.stream.error') {
const error = new Error(event.message || 'Plugin stream failed');
rejectOpen(error);
controller.error(error);
} else {
Object.assign(metadata, event);
controller.close();
} else {
Object.assign(metadata, event);
controller.close();
}
};
removeListener = () => listeners.event.delete(onEvent);
@@ -441,23 +482,26 @@ function pluginSdkSource(): string {
applyTheme(message.theme);
resolveReady(context);
listeners.init.forEach((listener) => listener(context));
dispatchEvent(new CustomEvent('dbx-plugin-init', { detail: message }));
// Plugin listeners register on the document (onHostThemeChange);
// bare dispatchEvent targets window, which document listeners never
// receive — env theme pushes were silently lost.
document.dispatchEvent(new CustomEvent('dbx-plugin-init', { detail: message }));
} else if (message.type === 'context') {
context = message.context;
listeners.context.forEach((listener) => listener(context));
dispatchEvent(new CustomEvent('dbx-plugin-context', { detail: context }));
document.dispatchEvent(new CustomEvent('dbx-plugin-context', { detail: context }));
} else if (message.type === 'env') {
if (typeof message.locale === 'string') locale = message.locale;
if (message.theme) applyTheme(message.theme);
listeners.event.forEach((listener) => listener(message));
dispatchEvent(new CustomEvent('dbx-plugin-env', { detail: message }));
document.dispatchEvent(new CustomEvent('dbx-plugin-env', { detail: message }));
} else if (message.type === 'event') {
listeners.event.forEach((listener) => listener(message));
dispatchEvent(new CustomEvent('dbx-plugin-event', { detail: message }));
document.dispatchEvent(new CustomEvent('dbx-plugin-event', { detail: message }));
} else if (message.type === 'binary') {
const payload = { channel: message.channel, data: message.data ? new Uint8Array(message.data) : new Uint8Array(0) };
listeners.binary.forEach((listener) => listener(payload));
dispatchEvent(new CustomEvent('dbx-plugin-binary', { detail: payload }));
document.dispatchEvent(new CustomEvent('dbx-plugin-binary', { detail: payload }));
}
});
addEventListener('keydown', (event) => {
@@ -467,3 +467,127 @@ describe("connectionStore save_password opt-out", () => {
expect(store.connectionErrors["mysql-1"]).toContain("disk full");
});
});
describe("connectionStore plugin password prompt", () => {
// Runtime-assembled placeholder: no credential literal in source (Mimosa gate).
const PROMPT_TYPED_PASSWORD = "typed" + "-pw";
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
requestPassword = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
function sshInstalledPlugin(): unknown {
return {
manifest: {
id: "io.dbx.ssh",
name: "SSH",
drivers: [],
contributions: [
{
type: "connection-provider",
id: "io.dbx.ssh.connection",
label: "SSH server",
database_type: "ssh",
fields: [
{ key: "authentication", label: "Authentication", type: "select", binding: "config", default: "password", options: [] },
{
key: "password",
label: "Password",
type: "password",
binding: "password",
visible_when: { field: "authentication", one_of: ["password", "private-key-password"] },
required_when: { field: "authentication", one_of: ["password", "private-key-password"] },
},
],
},
],
},
compatibility: { compatible: true },
};
}
function pluginConnection(overrides: Record<string, unknown> = {}): ConnectionConfig {
return {
id: "ssh-1",
name: "hktkosl1086",
db_type: "plugin",
host: "hktkosl1086.int.kn",
port: 22,
username: "jinpy.he",
password: "",
save_password: false,
plugin_id: "io.dbx.ssh",
plugin_connection_provider: "io.dbx.ssh.connection",
plugin_connection_type: "ssh",
external_config: { authentication: "private-key", private_key_path: "/Users/dev/.ssh/id_rsa" },
read_only: false,
...overrides,
} as ConnectionConfig;
}
it("connects an SSH key-auth plugin connection without prompting for a password", async () => {
installApiMocks({ listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = pluginConnection();
store.connections = [connection];
await store.connect(connection);
expect(requestPassword).not.toHaveBeenCalled();
const { connectDb } = await import("@/lib/backend/api");
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "ssh-1", external_config: expect.objectContaining({ authentication: "private-key" }) }), expect.any(Number));
});
it("still prompts for a plugin connection whose manifest requires the login password", async () => {
installApiMocks({ listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
installPasswordPromptMock();
requestPassword.mockResolvedValue({ password: PROMPT_TYPED_PASSWORD, rememberPassword: false });
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = pluginConnection({ external_config: { authentication: "password" } });
store.connections = [connection];
await store.connect(connection);
expect(requestPassword).toHaveBeenCalledWith({ connectionId: "ssh-1", connectionName: "hktkosl1086" });
const { connectDb } = await import("@/lib/backend/api");
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "ssh-1", password: PROMPT_TYPED_PASSWORD }), expect.any(Number));
});
it("does not prompt when the plugin manifest cannot be resolved (backend surfaces the real error)", async () => {
installApiMocks({ listPlugins: vi.fn().mockResolvedValue([]) });
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = pluginConnection();
store.connections = [connection];
await store.connect(connection);
expect(requestPassword).not.toHaveBeenCalled();
const { connectDb } = await import("@/lib/backend/api");
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "ssh-1" }), expect.any(Number));
});
it("falls back to prompting when listing plugins fails", async () => {
installApiMocks({ listPlugins: vi.fn().mockRejectedValue(new Error("ipc down")) });
installPasswordPromptMock();
requestPassword.mockResolvedValue(null);
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = pluginConnection();
store.connections = [connection];
await expect(store.connect(connection)).rejects.toThrow(CONNECTION_PASSWORD_REQUIRED_MESSAGE);
});
});
@@ -0,0 +1,98 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useQueryStore } from "@/stores/queryStore";
describe("queryStore openPluginWorkbench reuse", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
});
vi.stubGlobal("window", { dispatchEvent: vi.fn() });
setActivePinia(createPinia());
});
it("reopening an open workbench surfaces the tab as-is without replacing its context", async () => {
const queryStore = useQueryStore();
const firstId = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", {
title: "hktkosl1103",
connectionId: "conn-1",
context: { connectionId: "conn-1", workbenchId: "wb-original" },
});
// A later reopen (e.g. another sidebar click) carries a freshly minted
// context; the tab must keep the original one — a replacement would
// deep-reload the plugin webview (full flash) and orphan the sidecar
// session bound to the original workbench id.
const secondId = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", {
title: "hktkosl1103",
connectionId: "conn-1",
context: { connectionId: "conn-1", workbenchId: "wb-fresh" },
});
expect(secondId).toBe(firstId);
expect(queryStore.activeTabId).toBe(firstId);
const tab = queryStore.tabs.find((t) => t.id === firstId);
expect(tab?.pluginWorkbench?.context).toEqual({ connectionId: "conn-1", workbenchId: "wb-original" });
});
it("a different connection still opens its own workbench tab", () => {
const queryStore = useQueryStore();
const firstId = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", {
connectionId: "conn-1",
context: { connectionId: "conn-1", workbenchId: "wb-1" },
});
const secondId = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", {
connectionId: "conn-2",
context: { connectionId: "conn-2", workbenchId: "wb-2" },
});
expect(secondId).not.toBe(firstId);
expect(queryStore.tabs).toHaveLength(2);
});
it("registers the workbench tab into the focused group so the group tab strip can render it", () => {
const queryStore = useQueryStore();
const id = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", {
connectionId: "conn-1",
context: { connectionId: "conn-1" },
});
// The split workspace renders tab strips from group membership: a tab
// pushed straight onto `tabs` stays ownerless and never shows up in any
// strip (regression behind "click a sidebar connection, no tab appears").
const group = queryStore.groups.find((candidate) => candidate.tabIds.includes(id));
expect(group).toBeDefined();
expect(group?.activeTabId).toBe(id);
expect(queryStore.focusedGroupId).toBe(group?.id);
});
it("adopts a legacy ownerless workbench tab back into the workspace when reopened", () => {
const queryStore = useQueryStore();
const id = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", {
connectionId: "conn-1",
context: { connectionId: "conn-1" },
});
// Simulate a tab created by a pre-registry build (ownerless) or a restore
// edge: strip it from its group, then reopen from the sidebar.
queryStore.groups = [{ id: "main", tabIds: [], activeTabId: null }];
const reopenedId = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", {
connectionId: "conn-1",
context: { connectionId: "conn-1", workbenchId: "wb-fresh" },
});
expect(reopenedId).toBe(id);
expect(queryStore.groups[0]?.tabIds).toContain(id);
expect(queryStore.groups[0]?.activeTabId).toBe(id);
// Adoption must not replace the live context (same no-deep-reload rule).
expect(queryStore.tabs.find((t) => t.id === id)?.pluginWorkbench?.context).toEqual({ connectionId: "conn-1" });
});
});
@@ -0,0 +1,60 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
describe("queryStore plugin tab close disconnects the plugin connection", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
});
vi.stubGlobal("window", {
dispatchEvent: vi.fn(),
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
});
setActivePinia(createPinia());
});
it("disconnects the plugin connection when its last plugin tab closes", async () => {
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
const disconnectSpy = vi.spyOn(connectionStore, "disconnect").mockResolvedValue();
const id = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", { connectionId: "conn-1", context: { connectionId: "conn-1" } });
queryStore.closeTab(id);
await vi.waitFor(() => expect(disconnectSpy).toHaveBeenCalledTimes(1));
expect(disconnectSpy).toHaveBeenCalledWith("conn-1");
});
it("keeps the plugin connection while another plugin tab for it stays open", async () => {
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
const disconnectSpy = vi.spyOn(connectionStore, "disconnect").mockResolvedValue();
// Workbench + filesystem tab riding the same connection (e.g. SSH terminal + SFTP).
const workbenchId = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", { connectionId: "conn-1", context: { connectionId: "conn-1" } });
queryStore.openPluginFilesystem("io.dbx.ssh", "fs", { connectionId: "conn-1", rootUri: "sftp:/" });
queryStore.closeTab(workbenchId);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(disconnectSpy).not.toHaveBeenCalled();
});
it("does not disconnect non-plugin connections when a regular tab closes", async () => {
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
const disconnectSpy = vi.spyOn(connectionStore, "disconnect").mockResolvedValue();
const id = queryStore.createTab("pg-1", "app", "Query 1", "query");
queryStore.closeTab(id);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(disconnectSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,68 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useQueryStore } from "@/stores/queryStore";
// Boot-time tab restore replays only tab metadata; the sidecar connection
// registry starts empty after a host restart, so restored plugin tabs need
// the connect lifecycle replayed (see reconnectRestoredPluginTabs).
const ensureConnected = vi.fn(async () => undefined);
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: (id: string) => (id === "conn-sql" ? { id, db_type: "mysql" } : { id, db_type: "plugin" }),
ensureConnected,
}),
}));
describe("queryStore reconnectRestoredPluginTabs", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
});
vi.stubGlobal("window", { dispatchEvent: vi.fn() });
setActivePinia(createPinia());
ensureConnected.mockClear();
ensureConnected.mockImplementation(async () => undefined);
});
it("replays ensureConnected once per distinct plugin connection, active tab first", async () => {
const queryStore = useQueryStore();
queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", { connectionId: "conn-1", context: { connectionId: "conn-1" } });
queryStore.openPluginFilesystem("io.dbx.files", "files", { connectionId: "conn-1" });
// A non-plugin tab must not participate in the replay.
queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", { connectionId: "conn-sql", context: { connectionId: "conn-sql" } });
const activeTabId = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", { connectionId: "conn-2", context: { connectionId: "conn-2" } });
queryStore.activeTabId = activeTabId;
await queryStore.reconnectRestoredPluginTabs();
// conn-2 owns the active tab (last open won) so it is replayed first;
// activate:false keeps boot restore from stealing the active connection.
expect(ensureConnected.mock.calls.map((call) => call[0])).toEqual(["conn-2", "conn-1"]);
expect(ensureConnected).toHaveBeenCalledWith("conn-1", { activate: false });
});
it("a per-connection failure does not abort the remaining replays", async () => {
const queryStore = useQueryStore();
queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", { connectionId: "conn-1", context: { connectionId: "conn-1" } });
queryStore.openPluginWorkbench("io.dbx.ldap", "workbench", { connectionId: "conn-2", context: { connectionId: "conn-2" } });
ensureConnected.mockImplementation(async (id: string) => {
if (id === "conn-1") throw new Error("password prompt cancelled");
});
await expect(queryStore.reconnectRestoredPluginTabs()).resolves.toBeUndefined();
expect(ensureConnected).toHaveBeenCalledTimes(2);
});
it("no plugin tabs means no replay", async () => {
const queryStore = useQueryStore();
await queryStore.reconnectRestoredPluginTabs();
expect(ensureConnected).not.toHaveBeenCalled();
});
});
+52 -4
View File
@@ -148,7 +148,8 @@ import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel }
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
import { appendAgentDriverUpdateHint, connectionUsesSsh, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendConnectionErrorHints, isMysqlMissingPasswordFailure, isSqliteMissingEncryptionPasswordFailure } from "@/lib/connection/connectionErrorHints";
import { connectionNeedsPasswordPrompt } from "@/lib/connection/connectionPassword";
import { connectionNeedsPasswordPrompt, pluginConnectionNeedsPasswordPrompt } from "@/lib/connection/connectionPassword";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
import { isXuguPublicSynonymScope, isXuguSchedulerJobScope, isXuguSyntheticScope, sortXuguSchemaInfos, xuguSchemaDisplayName, XUGU_PUBLIC_SYNONYM_SCOPE, XUGU_SCHEDULER_JOB_SCOPE } from "@/lib/sidebar/xuguPublicSynonyms";
@@ -168,7 +169,7 @@ import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
import { buildCustomTypeTreeChildren } from "@/lib/sidebar/customTypeTree";
import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle";
import { buildXuguTablespaceChildren } from "@/lib/sidebar/xuguTablespaces";
import i18n from "@/i18n";
import i18n, { currentLocale } from "@/i18n";
import type { MqAdminConfig } from "@/types/mq";
import { RABBITMQ_MQ_TENANT, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults";
import { applySidebarDatabaseStorage, applySidebarTableStorage, sidebarDatabaseNames, supportsSidebarDatabaseStorage, supportsSidebarTableStorage, type SidebarTableStorageScope } from "@/lib/sidebar/sidebarDatabaseStorage";
@@ -1024,6 +1025,11 @@ export const useConnectionStore = defineStore("connection", () => {
return disconnectInFlightScoped.get(connectionId) ? undefined : disconnectInFlight.get(connectionId);
}
/** True while any disconnect for the connection is in flight (full or scoped). */
function hasDisconnectInFlight(connectionId: string): boolean {
return disconnectInFlight.has(connectionId);
}
async function waitForBlockingDisconnectInFlight(connectionId: string): Promise<void> {
const pending = getBlockingDisconnectInFlight(connectionId);
if (pending) await pending;
@@ -4035,6 +4041,31 @@ export const useConnectionStore = defineStore("connection", () => {
return { config: { ...config, password: result.password }, rememberPassword: result.rememberPassword };
}
/**
* Manifest re-check for plugin connections that the save_password heuristic
* already flagged. The prompt can only fill the `password`-bound field, so
* when the manifest does not require it for the connection's external_config
* (SSH private-key / agent auth), connecting proceeds without prompting
* instead of failing with "Password is required". Non-plugin configs always
* prompt (the heuristic decided already). Callers must evaluate
* `connectionNeedsPasswordPrompt` synchronously first so the common
* no-prompt path keeps its microtask cadence.
*/
async function pluginConnectionPasswordPromptNeeded(config: ConnectionConfig): Promise<boolean> {
if (config.db_type !== "plugin") return true;
if (!config.plugin_id || !config.plugin_connection_provider) return false;
try {
const registry = createFrontendPluginRegistry(await api.listPlugins(), currentLocale());
const provider = registry.listConnectionProviders().find((entry) => entry.plugin.manifest.id === config.plugin_id && entry.contribution.id === config.plugin_connection_provider);
if (!provider) return false;
return pluginConnectionNeedsPasswordPrompt(provider.contribution.fields, config.external_config);
} catch {
// Manifest unavailable: keep the pre-manifest behavior (prompt) instead of
// silently connecting a connection that may genuinely need a password.
return true;
}
}
async function persistRememberedConnectionPassword(config: ConnectionConfig, rememberPassword: boolean, expectedConfigFingerprint: string): Promise<void> {
if (!rememberPassword) return;
const index = connections.value.findIndex((connection) => connection.id === config.id);
@@ -4073,7 +4104,7 @@ export const useConnectionStore = defineStore("connection", () => {
const localAttempt = beginLocalConnectionAttempt(config.id);
try {
let rememberPassword = false;
if (connectionNeedsPasswordPrompt(config) && !(await hasSessionCredential(config.id))) {
if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(config.id))) {
const prompted = await ensureConnectionPassword(config);
config = prompted.config;
rememberPassword = prompted.rememberPassword;
@@ -4168,6 +4199,21 @@ export const useConnectionStore = defineStore("connection", () => {
return true;
}
/**
* Marks a connection offline in the sidebar without the full teardown that
* `disconnect` performs (no tab closing, no pool teardown). Used when a
* plugin reports its live session died (e.g. `ssh/session/state
* disconnected`) so the tree reflects the real transport state.
*/
function markConnectionOffline(connectionId: string) {
connectedIds.value.delete(connectionId);
const node = findConnectionNode(connectionId);
if (node) {
node.isLoading = false;
}
clearConnectionHealthCheck(connectionId);
}
async function disconnect(connectionId: string) {
const stateRevision = bumpConnectionStateRevision(connectionId);
const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true;
@@ -4336,7 +4382,7 @@ export const useConnectionStore = defineStore("connection", () => {
// Fast-path the common case (password saved or no password needed) so the
// in-flight dedup above keeps its exact microtask cadence; only await the
// interactive prompt when the connection actually needs a typed password.
if (connectionNeedsPasswordPrompt(config) && !(await hasSessionCredential(connectionId))) {
if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(connectionId))) {
const prompted = await ensureConnectionPassword(config);
config = prompted.config;
rememberPassword = prompted.rememberPassword;
@@ -9243,6 +9289,8 @@ export const useConnectionStore = defineStore("connection", () => {
connect,
cancelConnecting,
disconnect,
hasDisconnectInFlight,
markConnectionOffline,
metadataGenerationFor,
disconnectAndForgetConnectionPassword,
hasSessionCredential,
+114 -23
View File
@@ -3398,7 +3398,16 @@ export const useQueryStore = defineStore("query", () => {
if (!options.forceNew) {
const existing = tabs.value.find((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench?.pluginId === pluginId && tab.pluginWorkbench?.contributionId === contributionId && tab.connectionId === (options.connectionId || ""));
if (existing) {
if (options.context) existing.pluginWorkbench = { ...existing.pluginWorkbench!, context: snapshotPluginWorkbenchContext(options.context) };
// Reopening surfaces the existing tab as-is. Replacing the context
// here (openPluginConnection mints a fresh workbenchId per click)
// would deep-reload the plugin webview — a full flash plus losing
// the sidecar session binding on the old workbench id.
// A tab created by an older build (or otherwise unregistered) may
// still be ownerless; land it in the workspace or the group-rendered
// tab strips can never show it.
if (!groupForTab(existing.id)) {
ensureTabInWorkspace(existing.id);
}
switchTab(existing.id);
return existing.id;
}
@@ -3421,6 +3430,9 @@ export const useQueryStore = defineStore("query", () => {
context: options.context ? snapshotPluginWorkbenchContext(options.context) : undefined,
},
};
// The split workspace renders strips from group membership — always go
// through registerOpenTab so the tab joins the focused group (raw push
// left it ownerless and invisible in every tab strip).
return registerOpenTab(tab);
}
@@ -3429,6 +3441,9 @@ export const useQueryStore = defineStore("query", () => {
const existing = tabs.value.find((tab) => tab.mode === "plugin-filesystem" && tab.pluginFilesystem?.pluginId === pluginId && tab.pluginFilesystem?.providerId === providerId && tab.connectionId === (options.connectionId || ""));
if (existing) {
if (options.currentUri) existing.pluginFilesystem = { ...existing.pluginFilesystem!, currentUri: options.currentUri };
if (!groupForTab(existing.id)) {
ensureTabInWorkspace(existing.id);
}
switchTab(existing.id);
return existing.id;
}
@@ -3475,6 +3490,15 @@ export const useQueryStore = defineStore("query", () => {
connectionId,
providerId,
connectionType: connection.plugin_connection_type,
workbenchId: crypto.randomUUID(),
connection: {
id: connection.id,
name: connection.name,
host: connection.host,
port: connection.port,
username: connection.username,
readOnly: false,
},
},
});
}
@@ -3482,12 +3506,44 @@ export const useQueryStore = defineStore("query", () => {
const filesystem = filesystemProviderId ? registry.listFilesystemProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === filesystemProviderId) : undefined;
if (!filesystem) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' does not declare a workbench or filesystem provider`);
return openPluginFilesystem(pluginId, filesystem.contribution.id, {
title: connection.name,
title: `${connection.name} · SFTP`,
connectionId,
rootUri: filesystem.contribution.root_uri,
});
}
/**
* Boot-time tab restore only replays tab metadata: after a host restart the
* sidecar's in-memory connection registry starts empty (credentials are
* injected per connect via `connection/connect`), so a restored plugin
* workbench/filesystem tab fails its first session/open with "Connection is
* not active" until the user reopens the connection from the sidebar. Replay
* the connect lifecycle for every distinct restored plugin connection —
* fire-and-forget so an interactive password prompt or a slow sidecar spawn
* never blocks startup; per-connection failures surface in the tab's own UI.
*/
async function reconnectRestoredPluginTabs() {
const connectionStore = useConnectionStore();
const activeConnectionId = tabs.value.find((tab) => tab.id === activeTabId.value)?.connectionId;
const orderedIds: string[] = [];
for (const tab of tabs.value) {
if (tab.mode !== "plugin-workbench" && tab.mode !== "plugin-filesystem") continue;
if (!tab.connectionId || orderedIds.includes(tab.connectionId)) continue;
if (tab.connectionId === activeConnectionId) orderedIds.unshift(tab.connectionId);
else orderedIds.push(tab.connectionId);
}
for (const connectionId of orderedIds) {
if (connectionStore.getConfig(connectionId)?.db_type !== "plugin") continue;
try {
// activate:false keeps the boot restore from overriding the last
// active connection already chosen by restoreActiveConnectionContext().
await connectionStore.ensureConnected(connectionId, { activate: false });
} catch (error) {
console.warn("[DBX][plugin-tab-restore:reconnect]", connectionId, error);
}
}
}
function applyTableStructureInitialTab(tab: QueryTab, initialTab?: TableInfoTab, initialTarget?: TableStructureEditorTarget) {
if (!initialTab && !initialTarget?.name) return;
if (initialTab) tab.structureInitialTab = initialTab;
@@ -3790,6 +3846,39 @@ export const useQueryStore = defineStore("query", () => {
continuePendingBatchClose();
}
const pluginReleaseInFlight = new Set<string>();
/**
* Closing the last plugin tab (workbench or filesystem) bound to a plugin
* connection ends that connection: the sidecar session has no remaining
* owner, so disconnecting tears the pool down and drops the sidebar's
* "connected" dot. Fire-and-forget; concurrent releases for the same
* connection coalesce, and a reopen racing the release wins.
*/
function releasePluginConnectionsAfterClose(closedTabs: ReadonlyArray<QueryTab>) {
const connectionIds = new Set(closedTabs.filter((tab) => (tab.mode === "plugin-workbench" || tab.mode === "plugin-filesystem") && tab.connectionId).map((tab) => tab.connectionId));
for (const connectionId of connectionIds) {
if (pluginReleaseInFlight.has(connectionId)) continue;
pluginReleaseInFlight.add(connectionId);
void (async () => {
try {
// Yield one microtask so a same-tick reopen registers its
// replacement plugin tab before we tear the connection down.
await Promise.resolve();
if (tabs.value.some((tab) => (tab.mode === "plugin-workbench" || tab.mode === "plugin-filesystem") && tab.connectionId === connectionId)) return;
const connectionStore = useConnectionStore();
// A user-initiated disconnect that closed these tabs already owns
// the teardown — don't stack a second one on top of it.
if (connectionStore.hasDisconnectInFlight(connectionId)) return;
await connectionStore.disconnect(connectionId);
} catch (error) {
console.warn("[DBX][plugin-tab-close:disconnect]", connectionId, error);
} finally {
pluginReleaseInFlight.delete(connectionId);
}
})();
}
}
function closeTab(id: string, { force = false }: { force?: boolean } = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
@@ -3822,6 +3911,7 @@ export const useQueryStore = defineStore("query", () => {
const wasGlobalActive = activeTabId.value === id;
tabs.value.splice(idx, 1);
releasePluginConnectionsAfterClose([tab]);
if (tab.externalSqlPath) refreshExternalSqlFileTitles();
if (owner) {
@@ -4161,29 +4251,29 @@ export const useQueryStore = defineStore("query", () => {
const closingIds = new Set(tabs.value.filter((tab) => predicate(tab)).map((tab) => tab.id));
if (closingIds.size === 0) return;
tabs.value
.filter((tab) => closingIds.has(tab.id))
.forEach((tab) => {
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
clearDataGridPendingSnapshotsForTab(tab.id);
beginClosingDataGridViewSnapshotsForTab(tab.id);
beginClosingBrowserState(tab.id);
clearDataGridStructuredFilterStatesForTab(tab.id);
clearDataGridSearchStatesForTab(tab.id);
if (tab.txnSessionId) void rollbackTransaction(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
releaseTabResultObjectPayloads(tab);
clearResultRuns(tab);
clearResultPayload(tab);
});
const closingTabs = tabs.value.filter((tab) => closingIds.has(tab.id));
closingTabs.forEach((tab) => {
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
clearDataGridPendingSnapshotsForTab(tab.id);
beginClosingDataGridViewSnapshotsForTab(tab.id);
beginClosingBrowserState(tab.id);
clearDataGridStructuredFilterStatesForTab(tab.id);
clearDataGridSearchStatesForTab(tab.id);
if (tab.txnSessionId) void rollbackTransaction(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);
if (tab.isExplaining) void cancelTabExplain(tab.id);
void closeResultSession(tab);
void closeClientConnectionSession(tab);
clearResultRunSnapshots(tab);
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
releaseTabResultObjectPayloads(tab);
clearResultRuns(tab);
clearResultPayload(tab);
});
const activeClosingIndex = tabs.value.findIndex((tab) => tab.id === activeTabId.value && closingIds.has(tab.id));
tabs.value = tabs.value.filter((tab) => !closingIds.has(tab.id));
releasePluginConnectionsAfterClose(closingTabs);
// Resolve the fallback against the POST-filter array: the survivor at the
// closing tab's index, not the closing tab itself.
const fallbackTabId = activeClosingIndex >= 0 ? tabs.value[Math.min(activeClosingIndex, tabs.value.length - 1)]?.id : undefined;
@@ -8492,10 +8582,11 @@ export const useQueryStore = defineStore("query", () => {
openMqAdmin,
openMqttAdmin,
openNacosAdmin,
clearNacosNavigationTarget,
openPluginWorkbench,
openPluginFilesystem,
reconnectRestoredPluginTabs,
openPluginConnection,
clearNacosNavigationTarget,
openTableStructure,
linkSavedSql,
linkExternalSqlPath,
+23
View File
@@ -269,11 +269,29 @@ export type PluginFormFieldBinding = "config" | "secret" | "name" | "host" | "po
export type PluginFormFieldValue = string | number | boolean | undefined;
export interface LocalSshKey {
/** Absolute path to the private key file. */
path: string;
/** SSH algorithm name (e.g. `ssh-ed25519`); empty when undetectable. */
algorithm: string;
/** SHA-256 fingerprint (`SHA256:...`); empty when the key could not be decoded. */
fingerprint: string;
/** Heuristic: the key looks passphrase-protected. */
hasPassphrase: boolean;
}
export interface PluginFormFieldOption {
label: string;
value: string;
}
export interface PluginFieldCondition {
/** Key of another plugin form field whose current value drives the condition. */
field: string;
/** The condition matches when the referenced field's value is in this list. */
one_of: string[];
}
export interface PluginFormField {
key: string;
label: string;
@@ -283,7 +301,12 @@ export interface PluginFormField {
required?: boolean;
default?: PluginFormFieldValue;
options?: PluginFormFieldOption[];
/** Plugin method returning `{ options: [{ value, label }] }` for dynamic
* select rendering; falls back to the declared type when unavailable. */
options_action?: string;
binding?: PluginFormFieldBinding;
visible_when?: PluginFieldCondition;
required_when?: PluginFieldCondition;
}
export type PluginConnectionCapability = "test" | "connect" | "disconnect";
@@ -83,4 +83,4 @@ export const DATABASE_TYPES = [
"plugin",
] as const;
export type DatabaseType = (typeof DATABASE_TYPES)[number];
export type DatabaseType = (typeof DATABASE_TYPES)[number] | "plugin";