fix(plugins): stabilize plugin integration
This commit is contained in:
1 parent
b70d6da227
commit
3d1b84831f
76 files changed
+3846
-431
No files matched your search
@@ -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)" };');
|
||||
|
||||
+34
@@ -24,6 +24,16 @@ vi.mock("@/components/ui/popover", () => ({
|
||||
PopoverTrigger: { name: "PopoverTriggerStub", template: `<div><slot /></div>` },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/plugins/pluginIconResolver", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/plugins/pluginIconResolver")>()),
|
||||
resolvePluginIcon: vi.fn(() => Promise.resolve("assets/plugin.svg")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/backend/api")>()),
|
||||
readPluginAsset: vi.fn(() => Promise.resolve({ contentType: "image/svg+xml", dataBase64: "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" })),
|
||||
}));
|
||||
|
||||
const specDir = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(specDir, "../EditorGroupTabBar.vue"), "utf8");
|
||||
const sharedStyles = readFileSync(resolve(specDir, "../appTabBar.css"), "utf8");
|
||||
@@ -713,6 +723,30 @@ describe("EditorGroupTabBar group behavior", () => {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("renders plugin workbench tabs with the plugin icon instead of the code fallback", async () => {
|
||||
const store = useQueryStore();
|
||||
const pluginTabId = store.openPluginWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench", { title: "SSH server", connectionId: "ssh-1", forceNew: true });
|
||||
const { app, host } = mountBar(store.groups[0].id, store.tabs.slice(), pluginTabId, pinia);
|
||||
await settle();
|
||||
// PluginIcon loads the asset asynchronously; wait for the blob <img> to appear.
|
||||
for (let i = 0; i < 20 && !host.querySelector(`[data-tab-id="${pluginTabId}"] img`); i += 1) {
|
||||
await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 5));
|
||||
}
|
||||
|
||||
const pill = host.querySelector<HTMLElement>(`[data-tab-id="${pluginTabId}"]`);
|
||||
expect(pill).toBeTruthy();
|
||||
expect(pill!.querySelector("img")).toBeTruthy();
|
||||
// The pill and the overflow popup list both render via TabModeIcon, whose
|
||||
// per-mode chain must keep the plugin-workbench plugin icon branch.
|
||||
const tabModeIconSource = readFileSync(resolve(specDir, "../TabModeIcon.vue"), "utf8");
|
||||
expect(tabModeIconSource).toContain("tab.mode === 'plugin-workbench' && tab.pluginWorkbench");
|
||||
expect(tabModeIconSource).toContain(':contribution-id="tab.pluginWorkbench.contributionId"');
|
||||
expect(source).toContain('<TabModeIcon :tab="entry.tab"');
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditorGroupTabBar special page navigation", () => {
|
||||
|
||||
+91
-2
@@ -26,7 +26,7 @@ const groupExecutionCalls = vi.hoisted(() => ({ capture: [] as string[], request
|
||||
vi.mock("@/components/layout/EditorGroup.vue", () => ({
|
||||
default: {
|
||||
name: "EditorGroupStub",
|
||||
props: ["groupId", "tabIds", "activeTabId"],
|
||||
props: ["groupId", "tabIds", "activeTabId", "contentSuppressed"],
|
||||
emits: ["execute"],
|
||||
methods: {
|
||||
previewStatementRange(tabId: string, range: unknown) {
|
||||
@@ -54,7 +54,7 @@ vi.mock("@/components/layout/EditorGroup.vue", () => ({
|
||||
return true;
|
||||
},
|
||||
},
|
||||
template: `<div data-test="editor-group" :data-group-id="groupId" :data-tab-ids="tabIds.join(',')" :data-active-tab-id="activeTabId"><button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">execute</button></div>`,
|
||||
template: `<div data-test="editor-group" :data-group-id="groupId" :data-tab-ids="tabIds.join(',')" :data-active-tab-id="activeTabId" :data-content-suppressed="contentSuppressed ? 'true' : 'false'"><button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">execute</button></div>`,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -336,6 +336,95 @@ describe("SqlEditorWorkspace mount contract", () => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("keeps group tab strips mounted and drops the splitpanes workspace while suppressed", async () => {
|
||||
const store = useQueryStore();
|
||||
store.tabs = [tab("tab-a"), { ...tab("tab-b"), mode: "plugin-workbench" } as unknown as ReturnType<typeof tab>];
|
||||
store.activeTabId = "tab-b";
|
||||
store.groups = [
|
||||
{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" },
|
||||
{ id: "g2", tabIds: ["tab-b"], activeTabId: "tab-b" },
|
||||
];
|
||||
store.focusedGroupId = "g2";
|
||||
store.orientation = "vertical";
|
||||
store.sizes = [50, 50];
|
||||
|
||||
const host = createHost();
|
||||
const app = createApp(SqlEditorWorkspace, {
|
||||
activeTab: { ...tab("tab-b"), mode: "plugin-workbench" } as unknown as ReturnType<typeof tab>,
|
||||
activeConnection: undefined,
|
||||
executableSql: "",
|
||||
activeOutputView: "result",
|
||||
formatSqlRequest: null,
|
||||
compressSqlRequest: null,
|
||||
selectedSql: "",
|
||||
cursorPos: 0,
|
||||
blockDangerousRedisCommands: false,
|
||||
contentSuppressed: true,
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
|
||||
// Suppressed mode renders the groups' tab strips directly (no Splitpanes,
|
||||
// no shared result pane) so App.vue's always-mounted plugin workbench
|
||||
// layer can own the remaining column while the strips stay visible.
|
||||
expect(host.querySelector('[data-test="splitpanes-stub"]')).toBeNull();
|
||||
expect(host.querySelector('[data-test="result-surface"]')).toBeNull();
|
||||
const groups = host.querySelectorAll('[data-test="editor-group"]');
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[1]?.getAttribute("data-group-id")).toBe("g2");
|
||||
expect(groups[1]?.getAttribute("data-content-suppressed")).toBe("true");
|
||||
|
||||
// The App.vue wrapper is flex-none with indefinite height while a plugin
|
||||
// tab is active: the workspace must size to its strip content, or the
|
||||
// h-full/flex-1 fill contract collapses it to zero height and clips the
|
||||
// strips away (overflow-hidden) — the "plugin tab bar vanishes" regression.
|
||||
const workspaceRoot = host.querySelector<HTMLElement>(".sql-editor-workspace");
|
||||
expect(workspaceRoot?.classList.contains("h-auto")).toBe(true);
|
||||
expect(workspaceRoot?.classList.contains("h-full")).toBe(false);
|
||||
expect(workspaceRoot?.classList.contains("flex-1")).toBe(false);
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("keeps the fill-height contract on the workspace root outside suppressed mode", async () => {
|
||||
const store = useQueryStore();
|
||||
store.tabs = [tab("tab-a")];
|
||||
store.activeTabId = "tab-a";
|
||||
store.groups = [{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" }];
|
||||
store.focusedGroupId = "g1";
|
||||
store.orientation = "vertical";
|
||||
store.sizes = [100];
|
||||
|
||||
const host = createHost();
|
||||
const app = createApp(SqlEditorWorkspace, {
|
||||
activeTab: tab("tab-a"),
|
||||
activeConnection: undefined,
|
||||
executableSql: "",
|
||||
activeOutputView: "result",
|
||||
formatSqlRequest: null,
|
||||
compressSqlRequest: null,
|
||||
selectedSql: "",
|
||||
cursorPos: 0,
|
||||
blockDangerousRedisCommands: false,
|
||||
contentSuppressed: false,
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
|
||||
const workspaceRoot = host.querySelector<HTMLElement>(".sql-editor-workspace");
|
||||
expect(workspaceRoot?.classList.contains("h-full")).toBe(true);
|
||||
expect(workspaceRoot?.classList.contains("flex-1")).toBe(true);
|
||||
expect(workspaceRoot?.classList.contains("h-auto")).toBe(false);
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("forwards editor execute events from EditorGroup to the App listener", async () => {
|
||||
const store = useQueryStore();
|
||||
store.tabs = [tab("tab-a")];
|
||||
|
||||
+235
-18
@@ -1,11 +1,21 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import PluginConnectionFields from "./PluginConnectionFields.vue";
|
||||
import type { PluginConnectionProviderContribution, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const invokePluginMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listLocalSshKeys: vi.fn(async () => [
|
||||
{ path: "/home/dev/.ssh/id_ed25519", algorithm: "ssh-ed25519", fingerprint: "SHA256:abc", hasPassphrase: false },
|
||||
{ path: "/home/dev/.ssh/id_rsa", algorithm: "ssh-rsa", fingerprint: "", hasPassphrase: true },
|
||||
]),
|
||||
invokePlugin: (...args: unknown[]) => invokePluginMock(...args),
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
const contribution: PluginConnectionProviderContribution = {
|
||||
@@ -16,16 +26,6 @@ const contribution: PluginConnectionProviderContribution = {
|
||||
fields: [
|
||||
{ key: "host", label: "Host", type: "text", required: true, placeholder: "localhost" },
|
||||
{ key: "password", label: "Password", type: "password" },
|
||||
{
|
||||
key: "protocol",
|
||||
label: "Protocol",
|
||||
type: "radio",
|
||||
default: "https",
|
||||
options: [
|
||||
{ label: "HTTPS", value: "https" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -62,6 +62,39 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("PluginConnectionFields", () => {
|
||||
it("preserves multiline secret input and masks it again after its branch is reopened", async () => {
|
||||
const provider: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "pem.connection",
|
||||
label: "PEM",
|
||||
database_type: "example",
|
||||
fields: [
|
||||
{ key: "mode", label: "Mode", type: "text", default: "tls" },
|
||||
{ key: "key", label: "Private key", type: "textarea", binding: "secret", visible_when: { field: "mode", one_of: ["tls"] } },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(provider, {});
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
|
||||
const pem = "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----\n";
|
||||
expect(textarea.classList.contains("secret-masked")).toBe(true);
|
||||
textarea.value = pem;
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(state.values.key).toBe(pem);
|
||||
textarea.parentElement!.querySelector<HTMLButtonElement>("button")!.click();
|
||||
await nextTick();
|
||||
expect(textarea.classList.contains("secret-masked")).toBe(false);
|
||||
expect(textarea.value).toBe(pem);
|
||||
state.values = { ...state.values, mode: "plain" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#pem-connection-key")).toBeNull();
|
||||
state.values = { ...state.values, mode: "tls" };
|
||||
await nextTick();
|
||||
const reopened = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
|
||||
expect(reopened.value).toBe(pem);
|
||||
expect(reopened.classList.contains("secret-masked")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders declared fields and emits immutable model updates", async () => {
|
||||
const state = await mountFields({ password: "secret" });
|
||||
const hostInput = document.querySelector<HTMLInputElement>("#example-connection-host");
|
||||
@@ -99,15 +132,199 @@ describe("PluginConnectionFields", () => {
|
||||
expect(document.querySelector(".rounded-lg.border")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders radio fields and emits the selected option", async () => {
|
||||
const state = await mountFields();
|
||||
const radios = Array.from(document.querySelectorAll<HTMLInputElement>("input[type='radio']"));
|
||||
it("hides conditional fields until visible_when matches and marks required_when", async () => {
|
||||
const conditional: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "cond.connection",
|
||||
label: "Conditional",
|
||||
database_type: "cond",
|
||||
fields: [
|
||||
{ key: "mode", label: "Mode", type: "text", default: "password" },
|
||||
{ key: "private_key_path", label: "Private key", type: "text", visible_when: { field: "mode", one_of: ["key"] }, required_when: { field: "mode", one_of: ["key"] } },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(conditional, {});
|
||||
|
||||
expect(radios).toHaveLength(2);
|
||||
expect(radios[0]?.checked).toBe(true);
|
||||
radios[1]?.click();
|
||||
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
|
||||
|
||||
state.values = { mode: "key" };
|
||||
await nextTick();
|
||||
|
||||
expect(state.values.protocol).toBe("http");
|
||||
const keyInput = document.querySelector<HTMLInputElement>("#cond-connection-private_key_path");
|
||||
expect(keyInput).not.toBeNull();
|
||||
const label = document.querySelector('label[for="cond-connection-private_key_path"]');
|
||||
expect(label?.textContent).toContain("*");
|
||||
|
||||
state.values = { mode: "password" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps grandchild fields hidden while an intermediate default-only value would match (kafka msk_region regression)", async () => {
|
||||
// sasl_mechanism (PLAIN default, hidden without SASL) → oauth_token_source
|
||||
// (msk_iam default) → msk_region: the nested default must not surface an
|
||||
// always-required AWS region on a plain PLAINTEXT/SCRAM connection.
|
||||
const kafka: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "kafka.connection",
|
||||
label: "Kafka",
|
||||
database_type: "kafka",
|
||||
fields: [
|
||||
{ key: "bootstrap_servers", label: "Bootstrap servers", type: "textarea", required: true },
|
||||
{
|
||||
key: "security_protocol",
|
||||
label: "Security protocol",
|
||||
type: "select",
|
||||
default: "PLAINTEXT",
|
||||
options: [
|
||||
{ label: "PLAINTEXT", value: "PLAINTEXT" },
|
||||
{ label: "SASL_SSL", value: "SASL_SSL" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sasl_mechanism",
|
||||
label: "SASL mechanism",
|
||||
type: "select",
|
||||
default: "PLAIN",
|
||||
options: [
|
||||
{ label: "PLAIN", value: "PLAIN" },
|
||||
{ label: "OAUTHBEARER", value: "OAUTHBEARER" },
|
||||
],
|
||||
visible_when: { field: "security_protocol", one_of: ["SASL_PLAINTEXT", "SASL_SSL"] },
|
||||
},
|
||||
{
|
||||
key: "oauth_token_source",
|
||||
label: "OAuth token source",
|
||||
type: "select",
|
||||
default: "msk_iam",
|
||||
options: [
|
||||
{ label: "MSK IAM", value: "msk_iam" },
|
||||
{ label: "Static token", value: "static_token" },
|
||||
],
|
||||
visible_when: { field: "sasl_mechanism", one_of: ["OAUTHBEARER"] },
|
||||
},
|
||||
{ key: "msk_region", label: "MSK AWS region", type: "text", visible_when: { field: "oauth_token_source", one_of: ["msk_iam"] }, required_when: { field: "oauth_token_source", one_of: ["msk_iam"] } },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(kafka, {}, "io.dbx.kafka");
|
||||
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
|
||||
|
||||
state.values = { security_protocol: "SASL_SSL", sasl_mechanism: "OAUTHBEARER" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).not.toBeNull();
|
||||
const label = document.querySelector('label[for="kafka-connection-msk_region"]');
|
||||
expect(label?.textContent).toContain("*");
|
||||
|
||||
// Selecting the static token source hides the region field again.
|
||||
state.values = { sasl_mechanism: "OAUTHBEARER", oauth_token_source: "static_token" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers local SSH keys on private_key_path fields and fills the chosen path", async () => {
|
||||
const withKey: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "keys.connection",
|
||||
label: "Keys",
|
||||
database_type: "keys",
|
||||
fields: [{ key: "private_key_path", label: "Private key", type: "text" }],
|
||||
};
|
||||
const state = await mountContribution(withKey, {});
|
||||
await flushAsync();
|
||||
|
||||
const select = document.querySelector<HTMLSelectElement>("select");
|
||||
expect(select).not.toBeNull();
|
||||
expect(select?.disabled).toBe(false);
|
||||
const encryptedOption = select?.querySelector<HTMLOptionElement>('option[value="/home/dev/.ssh/id_rsa"]');
|
||||
expect(encryptedOption?.textContent).toContain("id_rsa");
|
||||
expect(encryptedOption?.textContent).toContain("ssh-rsa");
|
||||
expect(encryptedOption?.textContent).toMatch(/encrypted|已加密/);
|
||||
|
||||
select!.value = "/home/dev/.ssh/id_rsa";
|
||||
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(state.values.private_key_path).toBe("/home/dev/.ssh/id_rsa");
|
||||
});
|
||||
|
||||
it("renders options_action fields as a dynamic select and keeps a stored value visible", async () => {
|
||||
const dynamic: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "dyn.connection",
|
||||
label: "Dyn",
|
||||
database_type: "dyn",
|
||||
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
|
||||
};
|
||||
invokePluginMock.mockResolvedValueOnce({
|
||||
options: [
|
||||
{ value: "p1", label: "Ops" },
|
||||
{ value: "p2", label: "QA" },
|
||||
],
|
||||
});
|
||||
// "ghost" is stored but no longer offered: the dropdown must keep it visible.
|
||||
await mountContribution(dynamic, { sudo_profile: "ghost" }, "io.dbx.ssh");
|
||||
await flushAsync();
|
||||
|
||||
expect(invokePluginMock).toHaveBeenCalledWith("io.dbx.ssh", "sudo/profiles/options");
|
||||
expect(document.querySelector("input#dyn-connection-sudo_profile")).toBeNull();
|
||||
const trigger = document.querySelector<HTMLButtonElement>('#dyn-connection-sudo_profile, button[role="combobox"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.textContent).toContain("ghost");
|
||||
});
|
||||
|
||||
it("falls back to the text input when the options_action call fails or returns nothing", async () => {
|
||||
const dynamic: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "degrade.connection",
|
||||
label: "Degrade",
|
||||
database_type: "degrade",
|
||||
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
|
||||
};
|
||||
invokePluginMock.mockRejectedValueOnce(new Error("method not registered"));
|
||||
await mountContribution(dynamic, {}, "io.dbx.ssh");
|
||||
await flushAsync();
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>("input#degrade-connection-sudo_profile");
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.value).toBe("");
|
||||
|
||||
// An empty option list degrades the same way.
|
||||
invokePluginMock.mockResolvedValueOnce({ options: [] });
|
||||
const empty = { ...dynamic, id: "empty.connection", label: "Empty" };
|
||||
await mountContribution(empty, {}, "io.dbx.ssh");
|
||||
await flushAsync();
|
||||
expect(document.querySelector("input#empty-connection-sudo_profile")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function flushAsync() {
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function mountContribution(contribution: PluginConnectionProviderContribution, initialValues: Record<string, PluginFormFieldValue>, pluginId?: string) {
|
||||
const state = reactive({ values: initialValues });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(PluginConnectionFields, {
|
||||
contribution,
|
||||
modelValue: state.values,
|
||||
pluginId,
|
||||
"onUpdate:modelValue": (value: Record<string, PluginFormFieldValue>) => {
|
||||
state.values = value;
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
return state;
|
||||
}
|
||||
@@ -1,25 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
import PasswordTextarea from "@/components/ui/PasswordTextarea.vue";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
import { pluginFieldIsRequired, pluginFieldIsVisible, type PluginFieldResolver } from "@/lib/plugins/pluginFieldConditions";
|
||||
import { invokePlugin, listLocalSshKeys } from "@/lib/backend/api";
|
||||
import type { LocalSshKey, PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldOption, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
contribution: PluginConnectionProviderContribution;
|
||||
modelValue: Record<string, PluginFormFieldValue>;
|
||||
hiddenBindings?: PluginFormFieldBinding[];
|
||||
layout?: "stacked" | "connection-dialog";
|
||||
pluginId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: Record<string, PluginFormFieldValue>];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formValues = computed(() => props.modelValue);
|
||||
const visibleFields = computed(() => props.contribution.fields.filter((field) => !field.binding || !props.hiddenBindings?.includes(field.binding)));
|
||||
/** Value of any sibling field by key (condition evaluation reads current form values). */
|
||||
function readFieldValue(key: string): PluginFormFieldValue {
|
||||
const field = props.contribution.fields.find((candidate) => candidate.key === key);
|
||||
return field ? (formValues.value[key] ?? field.default) : undefined;
|
||||
}
|
||||
const resolveSiblingField: PluginFieldResolver = (key) => props.contribution.fields.find((candidate) => candidate.key === key);
|
||||
function fieldVisible(field: PluginFormField): boolean {
|
||||
if (field.binding && props.hiddenBindings?.includes(field.binding)) return false;
|
||||
return pluginFieldIsVisible(field, readFieldValue, resolveSiblingField);
|
||||
}
|
||||
function fieldRequired(field: PluginFormField): boolean {
|
||||
return pluginFieldIsRequired(field, readFieldValue);
|
||||
}
|
||||
const visibleFields = computed(() => props.contribution.fields.filter((field) => fieldVisible(field)));
|
||||
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");
|
||||
|
||||
function fieldValue(field: PluginFormField): PluginFormFieldValue {
|
||||
@@ -59,6 +79,103 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
|
||||
if (field.type === "number") return undefined;
|
||||
return "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic option lists (fields declaring `options_action`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A field may point `options_action` at a plugin method returning
|
||||
// `{ options: [{ value, label }] }`. The host fetches it once per dialog and
|
||||
// renders the field as a select; a missing plugin id, a failed call, or an
|
||||
// empty result falls back to the declared text input (older hosts without
|
||||
// this extension never fetch and simply keep the text field).
|
||||
const dynamicOptions = ref<Record<string, PluginFormFieldOption[]>>({});
|
||||
const dynamicOptionsRequested = ref(new Set<string>());
|
||||
|
||||
const optionsActionFields = computed(() => visibleFields.value.filter((field) => field.options_action));
|
||||
|
||||
watch(
|
||||
optionsActionFields,
|
||||
(fields) => {
|
||||
for (const field of fields) {
|
||||
const action = field.options_action;
|
||||
if (!action || !props.pluginId || dynamicOptionsRequested.value.has(field.key)) continue;
|
||||
dynamicOptionsRequested.value.add(field.key);
|
||||
invokePlugin<{ options?: PluginFormFieldOption[] }>(props.pluginId, action)
|
||||
.then((result) => {
|
||||
const options = Array.isArray(result?.options) ? result.options.filter((option) => option && option.value !== undefined) : [];
|
||||
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: options };
|
||||
})
|
||||
.catch(() => {
|
||||
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: [] };
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function selectOptionsFor(field: PluginFormField): PluginFormFieldOption[] | null {
|
||||
if (!field.options_action) return null;
|
||||
const options = dynamicOptions.value[field.key];
|
||||
if (!options || options.length === 0) return null;
|
||||
// Keep a stored value visible even when its profile disappeared so the
|
||||
// dialog does not silently look "unset" on reopen.
|
||||
const current = fieldValue(field);
|
||||
if (current !== undefined && current !== "" && !options.some((option) => String(option.value) === String(current))) {
|
||||
return [{ value: String(current), label: String(current) }, ...options];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local SSH key suggestions (Host API: private_key_path convenience picker)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOCAL_SSH_KEY_FIELD = "private_key_path";
|
||||
|
||||
function isLocalSshKeyField(field: PluginFormField): boolean {
|
||||
return field.key === LOCAL_SSH_KEY_FIELD && (field.type === "text" || field.type === "password");
|
||||
}
|
||||
|
||||
const hasLocalSshKeyField = computed(() => visibleFields.value.some((field) => isLocalSshKeyField(field)));
|
||||
|
||||
const localSshKeys = ref<LocalSshKey[]>([]);
|
||||
const localSshKeysRequested = ref(false);
|
||||
|
||||
async function loadLocalSshKeys() {
|
||||
if (localSshKeysRequested.value) return;
|
||||
localSshKeysRequested.value = true;
|
||||
try {
|
||||
localSshKeys.value = await listLocalSshKeys();
|
||||
} catch {
|
||||
localSshKeys.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
hasLocalSshKeyField,
|
||||
(present) => {
|
||||
if (present) void loadLocalSshKeys();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function localSshKeyFile(path: string): string {
|
||||
return path.replaceAll("\\", "/").split("/").pop() || path;
|
||||
}
|
||||
|
||||
function localSshKeyLabel(key: LocalSshKey): string {
|
||||
const algorithm = key.algorithm || "?";
|
||||
return key.hasPassphrase ? `${localSshKeyFile(key.path)} · ${algorithm} · ${t("connection.pluginSshKeyEncrypted")}` : `${localSshKeyFile(key.path)} · ${algorithm}`;
|
||||
}
|
||||
|
||||
function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const selected = localSshKeys.value.find((key) => key.path === select.value);
|
||||
select.value = "";
|
||||
if (!selected) return;
|
||||
updateField(field, selected.path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,11 +192,37 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
|
||||
<div v-for="field in visibleFields" :key="field.key" :class="isConnectionDialogLayout ? 'col-span-full grid grid-cols-4 items-start gap-4' : 'space-y-1.5'">
|
||||
<Label :for="fieldId(field)" :class="isConnectionDialogLayout ? 'justify-self-start pt-2 text-left' : 'text-xs'">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-destructive">*</span>
|
||||
<span v-if="fieldRequired(field)" class="text-destructive">*</span>
|
||||
</Label>
|
||||
<div :class="isConnectionDialogLayout ? 'col-span-3 min-w-0 space-y-1.5' : ''">
|
||||
<Input v-if="field.type === 'text' || field.type === 'number'" :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
|
||||
<template v-if="field.type === 'text' || field.type === 'number'">
|
||||
<Select v-if="field.type === 'text' && selectOptionsFor(field)" :model-value="String(fieldValue(field) ?? '')" @update:model-value="updateSelectField(field, $event)">
|
||||
<SelectTrigger :id="fieldId(field)" :class="isConnectionDialogLayout ? 'h-9' : 'h-8 text-xs'">
|
||||
<SelectValue :placeholder="field.placeholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in selectOptionsFor(field)!" :key="String(option.value)" :value="String(option.value)">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else-if="isLocalSshKeyField(field)" class="flex items-center gap-1.5">
|
||||
<Input :id="fieldId(field)" type="text" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" class="min-w-0 flex-1" @update:model-value="updateTextField(field, $event)" />
|
||||
<select
|
||||
class="h-9 w-40 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="localSshKeys.length === 0"
|
||||
:title="t('connection.pluginSshKeyPickerPlaceholder')"
|
||||
:aria-label="t('connection.pluginSshKeyPickerPlaceholder')"
|
||||
@change="applyLocalSshKey(field, $event)"
|
||||
>
|
||||
<option value="" disabled selected>{{ localSshKeys.length ? t("connection.pluginSshKeyPickerPlaceholder") : t("connection.pluginSshKeyPickerEmpty") }}</option>
|
||||
<option v-for="key in localSshKeys" :key="key.path" :value="key.path" :title="key.path">{{ localSshKeyLabel(key) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input v-else :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
|
||||
</template>
|
||||
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
<PasswordTextarea v-else-if="field.type === 'textarea' && field.binding === 'secret'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
<textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:id="fieldId(field)"
|
||||
@@ -98,12 +241,6 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else-if="field.type === 'radio'" :id="fieldId(field)" class="flex min-h-9 flex-wrap items-center gap-x-4 gap-y-2" role="radiogroup" :aria-label="field.label">
|
||||
<label v-for="option in field.options || []" :key="option.value" class="inline-flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="radio" :name="fieldId(field)" :value="option.value" :checked="String(fieldValue(field) ?? '') === option.value" class="size-4 accent-primary" @change="updateField(field, option.value)" />
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
|
||||
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
|
||||
</div>
|
||||
|
||||
@@ -25,10 +25,14 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const { isDark, activeCustomUiColors } = useTheme();
|
||||
const { isDark, themeRevision } = useTheme();
|
||||
const iframe = ref<HTMLIFrameElement>();
|
||||
const source = ref("");
|
||||
const loading = ref(true);
|
||||
// Stays false until the iframe's load event: WKWebView paints a white canvas
|
||||
// for a freshly inserted iframe before the sandbox document's first styled
|
||||
// frame, so the themed overlay must keep covering the frame area until then.
|
||||
const frameReady = ref(false);
|
||||
const error = ref("");
|
||||
let bridge: PluginHostBridge | undefined;
|
||||
let unsubscribeEvents: (() => void) | undefined;
|
||||
@@ -116,6 +120,7 @@ async function loadWorkbench() {
|
||||
const generation = ++loadGeneration;
|
||||
bridge = undefined;
|
||||
loading.value = true;
|
||||
frameReady.value = false;
|
||||
error.value = "";
|
||||
try {
|
||||
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
|
||||
@@ -132,7 +137,8 @@ async function loadWorkbench() {
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
emit("error", error.value);
|
||||
loading.value = false;
|
||||
} finally {
|
||||
if (!disposed && generation === loadGeneration) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,9 +147,18 @@ function onMessage(event: MessageEvent) {
|
||||
}
|
||||
|
||||
function onFrameLoad() {
|
||||
// The load event can precede the webview's first actual paint (notably on
|
||||
// WKWebView); reveal after two animation frames, with a timer fallback
|
||||
// because rAF stalls in occluded/background webviews. Guarded by generation
|
||||
// so a stale callback from a rebuilt iframe can't lift the new overlay.
|
||||
const generation = loadGeneration;
|
||||
const reveal = () => {
|
||||
if (!disposed && generation === loadGeneration) frameReady.value = true;
|
||||
};
|
||||
requestAnimationFrame(() => requestAnimationFrame(reveal));
|
||||
setTimeout(reveal, 400);
|
||||
bridge?.sendInit();
|
||||
emit("ready");
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -172,7 +187,10 @@ watch(
|
||||
{ deep: true },
|
||||
);
|
||||
watch(appLocale, (locale) => bridge?.updateLocale(locale));
|
||||
watch([isDark, activeCustomUiColors], () => bridge?.updateTheme(currentBridgeTheme()), { deep: true });
|
||||
// Keyed on the theme revision (bumped by applyTheme) so every theme change
|
||||
// path — dark/light, palette switch, custom colors — re-pushes the resolved
|
||||
// tokens; watching isDark/custom colors alone misses palette-only switches.
|
||||
watch(themeRevision, () => bridge?.updateTheme(currentBridgeTheme()));
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
@@ -185,14 +203,25 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
|
||||
<div v-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<iframe v-else-if="source" ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
|
||||
<div v-if="loading && !error" class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 text-sm text-muted-foreground backdrop-blur-sm">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
<Loader2 class="mr-2 size-4 animate-spin" />
|
||||
{{ t("pluginPlatform.loadingTitle", { title }) }}
|
||||
</div>
|
||||
<div v-else-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<iframe ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
|
||||
<!-- Cover until the frame has actually painted: the iframe stays mounted
|
||||
underneath so its load event can fire (v-else on the overlay would
|
||||
deadlock), it just isn't visible yet. Fully opaque so the covered
|
||||
phase is visually identical to the host background, and faded out
|
||||
instead of removed so the reveal is never a hard swap. -->
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground transition-opacity duration-150 ease-out" :class="frameReady ? 'pointer-events-none opacity-0' : 'opacity-100'">
|
||||
<Loader2 class="mr-2 size-4 animate-spin" />
|
||||
{{ t("pluginPlatform.loadingTitle", { title }) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,7 +15,7 @@ vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const { defineComponent, h, Teleport } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup(_props, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
@@ -27,9 +27,20 @@ vi.mock("@/components/ui/dialog", async () => {
|
||||
return () => (props.open ? h("div", slots.default?.()) : null);
|
||||
},
|
||||
});
|
||||
const dialogContent = defineComponent({
|
||||
props: {
|
||||
class: [String, Array, Object],
|
||||
overlayClass: [String, Array, Object],
|
||||
portalClass: [String, Array, Object],
|
||||
},
|
||||
inheritAttrs: false,
|
||||
setup(props, { slots }) {
|
||||
return () => h(Teleport, { to: "body" }, [h("div", { class: ["dialog-overlay", "z-50", props.overlayClass] }), h("div", { class: ["dialog-positioner", "z-50", props.portalClass] }, [h("div", { class: ["dialog-content", props.class] }, slots.default?.())])]);
|
||||
},
|
||||
});
|
||||
return {
|
||||
Dialog: dialog,
|
||||
DialogContent: passthrough,
|
||||
DialogContent: dialogContent,
|
||||
DialogDescription: passthrough,
|
||||
DialogFooter: passthrough,
|
||||
DialogHeader: passthrough,
|
||||
@@ -133,6 +144,51 @@ describe("SshHostKeyPromptDialog web bridge", () => {
|
||||
expect(dialogSource).toContain('portal-class="z-[200]"');
|
||||
});
|
||||
|
||||
it("keeps the prompt above a later body-mounted connection dialog and answerable", async () => {
|
||||
await mountDialog();
|
||||
|
||||
const eventSource = MockEventSource.instances[0];
|
||||
eventSource?.emit({
|
||||
type: "prompt",
|
||||
request: {
|
||||
id: "prompt-layered",
|
||||
kind: "HostKeyVerify",
|
||||
host: "layered.example.test",
|
||||
port: 22,
|
||||
key_type: "ssh-ed25519",
|
||||
fingerprint: "SHA256:layered",
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// Simulate ConnectionDialog opening after the global prompt: its portal is
|
||||
// appended later and remains on the shared default z-50 layer.
|
||||
const connectionPositioner = document.createElement("div");
|
||||
connectionPositioner.className = "dialog-positioner z-50";
|
||||
connectionPositioner.textContent = "New connection";
|
||||
document.body.append(connectionPositioner);
|
||||
|
||||
const promptPositioner = document.body.querySelector<HTMLElement>(".dialog-positioner.z-\\[200\\]");
|
||||
const promptOverlay = document.body.querySelector<HTMLElement>(".dialog-overlay.z-\\[200\\]");
|
||||
expect(promptPositioner).not.toBeNull();
|
||||
expect(promptOverlay).not.toBeNull();
|
||||
expect(connectionPositioner.classList.contains("z-50")).toBe(true);
|
||||
expect(promptPositioner?.classList.contains("z-[200]")).toBe(true);
|
||||
expect(promptOverlay?.classList.contains("z-[200]")).toBe(true);
|
||||
expect(document.body.textContent).toContain("layered.example.test:22");
|
||||
|
||||
const buttons = document.body.querySelectorAll<HTMLButtonElement>("button");
|
||||
buttons.item(buttons.length - 1).click();
|
||||
await vi.waitFor(() => {
|
||||
expect(resolveSshPromptMock).toHaveBeenCalledWith({
|
||||
id: "prompt-layered",
|
||||
action: "accept",
|
||||
remember: true,
|
||||
secret: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an SSE host-key prompt and posts the user's acceptance", async () => {
|
||||
await mountDialog();
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Eye, EyeOff } from "@lucide/vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
const model = defineModel<string>();
|
||||
const visible = ref(false);
|
||||
const { t } = useI18n();
|
||||
const toggleLabel = computed(() => t(visible.value ? "common.hidePassword" : "common.showPassword"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
v-model="model"
|
||||
v-bind="$attrs"
|
||||
:class="{ 'secret-masked': !visible }"
|
||||
class="min-h-20 w-full resize-y rounded-md border border-input bg-transparent py-2 pl-2.5 pr-9 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
:spellcheck="false"
|
||||
/>
|
||||
<button type="button" class="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground" :aria-label="toggleLabel" :title="toggleLabel" :aria-pressed="visible" @click="visible = !visible">
|
||||
<Eye v-if="!visible" class="size-3.5" />
|
||||
<EyeOff v-else class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.secret-masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
</style>
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1409,6 +1409,9 @@ export default withEnglishFallback({
|
||||
doltShowSystemTables: "Dolt 組み込みテーブルを表示",
|
||||
doltShowSystemTablesHint: "現在の接続のテーブルリストに Dolt 組み込みのバージョン管理テーブルを表示します。",
|
||||
clientCertHint: "mTLS が要求される場合、クライアント証明書と秘密鍵は一緒に入力する必要があります。",
|
||||
pluginSshKeyPickerPlaceholder: "ローカルの SSH キーを選択…",
|
||||
pluginSshKeyPickerEmpty: "キーが見つかりません",
|
||||
pluginSshKeyEncrypted: "暗号化済み",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "前回の DuckDB クエリはまだ停止処理中です。しばらくしてから再試行してください。",
|
||||
|
||||
@@ -1307,6 +1307,9 @@ export default withEnglishFallback({
|
||||
databaseCategoryPlugins: "플러그인",
|
||||
pluginProviderUnavailable: "이 플러그인 연결 제공자를 사용할 수 없습니다. 플러그인을 다시 설치하거나 활성화한 후 다시 시도하세요.",
|
||||
pluginRequiredField: "{field}을(를) 입력하세요.",
|
||||
pluginSshKeyPickerPlaceholder: "로컬 SSH 키 선택…",
|
||||
pluginSshKeyPickerEmpty: "키를 찾을 수 없음",
|
||||
pluginSshKeyEncrypted: "암호화됨",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "이전 DuckDB 쿼리가 아직 중지 중입니다. 잠시 후 다시 시도해 주세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1036,6 +1036,9 @@ export default withEnglishFallback({
|
||||
databaseCategoryPlugins: "插件",
|
||||
pluginProviderUnavailable: "此插件连接提供者不可用,请重新安装或启用插件后再试。",
|
||||
pluginRequiredField: "请填写{field}",
|
||||
pluginSshKeyPickerPlaceholder: "选择本机 SSH 密钥…",
|
||||
pluginSshKeyPickerEmpty: "未发现密钥",
|
||||
pluginSshKeyEncrypted: "已加密",
|
||||
jdbcConnection: "JDBC 连接",
|
||||
iconView: "图标视图",
|
||||
listView: "列表视图",
|
||||
|
||||
@@ -920,6 +920,9 @@ export default withEnglishFallback({
|
||||
databaseCategoryPlugins: "外掛",
|
||||
pluginProviderUnavailable: "此外掛連線提供者不可用,請重新安裝或啟用外掛後再試。",
|
||||
pluginRequiredField: "請填寫{field}",
|
||||
pluginSshKeyPickerPlaceholder: "選擇本機 SSH 金鑰…",
|
||||
pluginSshKeyPickerEmpty: "未發現金鑰",
|
||||
pluginSshKeyEncrypted: "已加密",
|
||||
jdbcConnection: "JDBC 連線",
|
||||
iconView: "圖示檢視",
|
||||
listView: "清單檢視",
|
||||
|
||||
+5
-2
@@ -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"');
|
||||
|
||||
+2
-1
@@ -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 } : {}),
|
||||
}));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
});
|
||||
});
|
||||
+60
@@ -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();
|
||||
});
|
||||
});
|
||||
+68
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user