From 4d150fc9099aa0a4d69c458773b18a5f0b2e8130 Mon Sep 17 00:00:00 2001 From: zipg Date: Sun, 20 Sep 2026 18:43:02 +0800 Subject: [PATCH] fix(updates): sync update center state after manual component changes --- apps/desktop/src/App.vue | 13 +++++++-- .../components/config/DriverStoreDialog.vue | 12 +++++++++ .../connection/ConnectionDialog.vue | 6 +++++ .../editor/EditorSettingsDialog.vue | 3 +++ .../plugins/PluginContributionsPanel.vue | 9 ++++++- .../PluginContributionsPanelBatch.spec.ts | 20 +++++++++++++- .../__tests__/useComponentUpdates.spec.ts | 20 ++++++++++++++ .../src/composables/useComponentUpdates.ts | 27 ++++++++++++++----- .../plugins/pluginCenterIntegration.spec.ts | 11 ++++++++ .../__tests__/componentUpdateEvents.spec.ts | 22 ++++++++++++++- .../src/lib/updates/componentUpdateEvents.ts | 7 +++++ .../connectionStore.sqlserverLegacy.spec.ts | 2 ++ apps/desktop/src/stores/connectionStore.ts | 2 ++ 13 files changed, 143 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 2e334f41fd..5a1800b025 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -27,7 +27,7 @@ import { useTheme } from "@/composables/useTheme"; import { canDownloadAndInstallUpdate, useAppUpdater } from "@/composables/useAppUpdater"; import { useMcpUpdateBadge } from "@/composables/useMcpUpdateBadge"; import { useComponentUpdates, type ComponentUpdateCategory } from "@/composables/useComponentUpdates"; -import { notifyComponentPluginsUpdated } from "@/lib/updates/componentUpdateEvents"; +import { COMPONENT_UPDATES_CHANGED_EVENT, notifyComponentPluginsUpdated, notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents"; import { driverStoreUpdateBadgeCount, showMcpUpdateBadge } from "@/lib/updates/updateBadges"; import { markPendingComponentUpdatesAfterAppUpdate, resolveUpdateAllAction, runPendingComponentUpdatePlan, shouldCloseUpdateCenterAfterComponentUpdate, takePendingComponentUpdatesAfterAppRestart, type PendingComponentUpdatePlan } from "@/lib/updates/componentUpdateOrchestration"; import { isUpdatePreviewMockEnabled } from "@/lib/updates/updatePreviewMock"; @@ -200,13 +200,14 @@ const savedSqlStore = useSavedSqlStore(); const promptTemplateStore = usePromptTemplateStore(); const recentConnectionIds = ref(parseRecentConnectionIds(safeLocalStorageGet(RECENT_CONNECTION_IDS_STORAGE_KEY))); connectionStore.setBeforeConnectHandler(async (config) => { - await ensureJdbcxRuntimeDrivers(config, api); + const jdbcxRuntime = await ensureJdbcxRuntimeDrivers(config, api); const jdbcProductRuntimeBefore = JSON.stringify({ connectionString: config.connection_string ?? null, driverClass: config.jdbc_driver_class ?? null, driverPaths: config.jdbc_driver_paths ?? [], }); const jdbcProductRuntime = await ensureRegisteredJdbcProductRuntimeDrivers(config, api); + if (jdbcxRuntime || jdbcProductRuntime) notifyComponentUpdatesChanged(); const jdbcProductRuntimeAfter = JSON.stringify({ connectionString: config.connection_string ?? null, driverClass: config.jdbc_driver_class ?? null, @@ -1234,6 +1235,12 @@ function syncToolbarComponentUpdateState() { applyMcpStatus(componentUpdates.mcpUpdateAvailable.value); } +function handleComponentUpdatesChanged() { + void componentUpdates.refresh({ force: true }).then((refreshed) => { + if (refreshed) syncToolbarComponentUpdateState(); + }); +} + function reportComponentUpdateResult(result: Awaited>) { const updatedComponents = [result.drivers > 0 ? t("settings.updateDrivers") : "", result.jdbc ? t("settings.updateJdbc") : "", result.mcp ? t("settings.updateMcp") : "", result.plugins > 0 ? t("settings.updatePlugins") : ""].filter(Boolean); // Only a clean refresh is authoritative; a failed registry check must not clear stale toolbar state. @@ -3840,6 +3847,7 @@ onMounted(async () => { window.addEventListener("blur", handleTabSwitcherWindowBlur); document.addEventListener("visibilitychange", handleTabSwitcherVisibilityChange); window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent); + window.addEventListener(COMPONENT_UPDATES_CHANGED_EVENT, handleComponentUpdatesChanged); window.addEventListener("dbx:activate-query-surface", activateQuerySurface); window.addEventListener(OBJECT_BROWSER_SEARCH_FOCUS_EVENT, focusRequestedObjectBrowserSearch); window.addEventListener("dbx-mcp-status-changed", handleMcpStatusChanged); @@ -3925,6 +3933,7 @@ onUnmounted(() => { document.removeEventListener("visibilitychange", handleTabSwitcherVisibilityChange); tabSwitcherKeyboard.reset(); window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent); + window.removeEventListener(COMPONENT_UPDATES_CHANGED_EVENT, handleComponentUpdatesChanged); window.removeEventListener("dbx:activate-query-surface", activateQuerySurface); window.removeEventListener(OBJECT_BROWSER_SEARCH_FOCUS_EVENT, focusRequestedObjectBrowserSearch); window.removeEventListener("dbx-mcp-status-changed", handleMcpStatusChanged); diff --git a/apps/desktop/src/components/config/DriverStoreDialog.vue b/apps/desktop/src/components/config/DriverStoreDialog.vue index 1f94edfc11..39c28449c4 100644 --- a/apps/desktop/src/components/config/DriverStoreDialog.vue +++ b/apps/desktop/src/components/config/DriverStoreDialog.vue @@ -40,6 +40,7 @@ import { translateBackendError } from "@/i18n/backend-errors"; import { runAgentOfflineExportAction } from "@/lib/driverStore/agentOfflineExportFlow"; import { DRIVER_CATEGORIES, getCategoryForAgentDriver, assertAgentDriverCategoriesComplete } from "@/lib/connection/driver-category-definitions"; import { hasAnyUpdatableDriverMatching, countInstalledDrivers, countAvailableDrivers, partitionDriversByInstallStatus, upgradeAllDriverTypes, upgradeAllMatchesFullUpdateSet, type DriverInstallStatusFilter } from "@/lib/connection/driverListFilter"; +import { notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents"; const { t } = useI18n(); const { toast } = useToast(); @@ -497,6 +498,7 @@ async function runDriverInstall(dbType: string) { if (managedResult.drivers) jdbcDrivers.value = managedResult.drivers; jdbcMavenBundles.value = managedResult.bundles; void loadDriverStoreUsage(); + notifyComponentUpdatesChanged(); toast(t("driverStore.driverInstallSuccess", { label })); return; } @@ -509,6 +511,7 @@ async function runDriverInstall(dbType: string) { activeAgentOperationId.value = operationId; cancellableAgentInstallDbType.value = dbType; await api.installAgent(dbType, operationId); + notifyComponentUpdatesChanged(); await refreshAgents(); toast(t("driverStore.driverInstallSuccess", { label })); } catch (e: any) { @@ -557,6 +560,7 @@ async function upgradeAll() { queuedDriverInstalls.value = []; resetAgentInstallProgress(); const result = await api.upgradeAllAgents(activeAgentOperationId.value); + notifyComponentUpdatesChanged(); await refreshAgents(); if (result.cancelled > 0 && result.failed.length === 0) { toast(t("driverStore.upgradeAllCancelled", { count: result.cancelled })); @@ -613,6 +617,7 @@ async function uninstallDriver(dbType: string) { if (managedResult.drivers) jdbcDrivers.value = managedResult.drivers; jdbcMavenBundles.value = managedResult.bundles; void loadDriverStoreUsage(); + notifyComponentUpdatesChanged(); toast(t("driverStore.driverUninstallSuccess", { label })); return; } @@ -622,6 +627,7 @@ async function uninstallDriver(dbType: string) { return; } await api.uninstallAgent(dbType); + notifyComponentUpdatesChanged(); await refreshAgents(); toast(t("driverStore.driverUninstallSuccess", { label })); } catch (e: any) { @@ -744,6 +750,7 @@ async function importOfflineZip() { resetAgentInstallProgress(); try { const result = await api.importAgentsFromZip(selected, activeAgentOperationId.value); + notifyComponentUpdatesChanged(); await Promise.all([refreshAgents(), loadJdbcDrivers(), loadJdbcPluginStatus()]); toastOfflineImportResult(result); } catch (e: any) { @@ -796,6 +803,7 @@ async function importDriverFile(driver: AgentDriverInfo) { resetAgentInstallProgress(); try { const result = await api.importAgentsFromZip(selected, activeAgentOperationId.value); + notifyComponentUpdatesChanged(); await Promise.all([refreshAgents(), loadJdbcDrivers(), loadJdbcPluginStatus()]); toastOfflineImportResult(result); } finally { @@ -805,6 +813,7 @@ async function importDriverFile(driver: AgentDriverInfo) { } else { await api.importAgentDriver(dbType, selected); await refreshAgents(); + notifyComponentUpdatesChanged(); toast(t("driverStore.driverImportSuccess", { label })); } }; @@ -1288,6 +1297,7 @@ async function installJdbcPlugin() { try { jdbcPluginStatus.value = await api.installJdbcPlugin(); emitDriverUpdateCount(); + notifyComponentUpdatesChanged(); toast(t("settings.jdbcPluginInstallSuccess")); await loadJdbcDrivers(); } catch (e: any) { @@ -1318,6 +1328,7 @@ async function installJdbcPluginLocal() { try { jdbcPluginStatus.value = await api.installJdbcPluginLocal(selected); emitDriverUpdateCount(); + notifyComponentUpdatesChanged(); toast(t("settings.jdbcPluginInstallSuccess")); await loadJdbcDrivers(); } catch (e: any) { @@ -1334,6 +1345,7 @@ async function uninstallJdbcPlugin() { try { jdbcPluginStatus.value = await api.uninstallJdbcPlugin(); emitDriverUpdateCount(); + notifyComponentUpdatesChanged(); toast(t("settings.jdbcPluginUninstallSuccess")); await loadJdbcDrivers(); } catch (e: any) { diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index a12c6ee5d2..c99c355c32 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -92,6 +92,7 @@ import { configuredDatabaseProductName, connectionConfigFingerprint, databaseInf import { agentDriverInstallKey, appendAgentDriverUpdateHint, connectionUsesSsh, hasAgentDriverUpdate, showAgentDriverInstallHint, type AgentDriverInstallState, type DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint"; import { prestoSqlBuiltinDriverPaths } from "@/lib/database/prestoSqlBuiltinDriver"; import { JDBCX_DEFAULT_URL, JDBCX_DRIVER_PROFILE, JDBCX_JDBC_DRIVER_CLASS, ensureJdbcxRuntimeDrivers, isJdbcxRuntimeBundle, isJdbcxRuntimePath, jdbcxHighPrivilegeExtensionsEnabled, setJdbcxHighPrivilegeExtensionsEnabled } from "@/lib/database/jdbcxBuiltinDriver"; +import { notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents"; import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection"; import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout"; import { consulAgentAddressesMatch } from "@/lib/consul/agentTarget"; @@ -1964,6 +1965,7 @@ async function ensureRequiredAgentDriverInstalled(config: ConnectionConfig): Pro const operationId = beginAgentDriverInstall(driverKey, label); try { await api.installAgent(driverKey, operationId); + notifyComponentUpdatesChanged(); await refreshLocalAgentDrivers(); // A stale promise (cancelled then retried) must not close the retry's dialog. if (agentInstallOperationId.value === operationId) finishAgentDriverInstall(operationId); @@ -1999,6 +2001,7 @@ async function ensureRequiredJdbcxDriverInstalled(config: ConnectionConfig): Pro testResult.value = { ok: true, message: "Installing JDBC plugin..." }; }); if (!result) return; + notifyComponentUpdatesChanged(); jdbcMavenBundles.value = result.bundles; addJdbcDriverPaths(result.paths); @@ -2012,6 +2015,7 @@ async function ensureRequiredJdbcxDriverInstalled(config: ConnectionConfig): Pro async function ensureRequiredJdbcProductRuntimeInstalled(config: ConnectionConfig): Promise { const result = await ensureRegisteredJdbcProductRuntimeDrivers(config, api); if (!result) return; + notifyComponentUpdatesChanged(); jdbcMavenBundles.value = result.bundles; jdbcDriverPathsInput.value = result.paths.join("\n"); @@ -2031,6 +2035,7 @@ async function ensureRequiredGaussdbMJdbcRuntime(config: ConnectionConfig): Prom if (status.installed && status.compatible) return; testResult.value = { ok: true, message: t("connection.gaussdbMJdbcPluginInstalling") }; await api.installJdbcPlugin(); + notifyComponentUpdatesChanged(); } async function installSqlServerLegacyCompatibilityComponentIfNeeded(): Promise { @@ -2040,6 +2045,7 @@ async function installSqlServerLegacyCompatibilityComponentIfNeeded(): Promise installedPlugins.value); + notifyComponentUpdatesChanged(); } finally { marketplaceInstallingKey.value = ""; } @@ -325,6 +327,7 @@ async function runBatchInstallUpdate() { clearBatchSelection(); reportBatchSummary(outcome); await refreshAfterBatch(); + notifyComponentUpdatesChanged(); } finally { batchRunning.value = false; } @@ -348,6 +351,7 @@ async function runBatchUninstall() { clearBatchSelection(); reportBatchSummary(outcome); await refreshAfterBatch(); + notifyComponentUpdatesChanged(); } finally { batchRunning.value = false; } @@ -543,6 +547,7 @@ async function finishInstall(result: PluginInstallResult) { toast(t("pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version })); clearPluginIconCache(); installedPlugins.value = await api.listPlugins(); + notifyComponentUpdatesChanged(); selectPlugin(result.plugin.manifest.id); activeSection.value = "installed"; } @@ -689,6 +694,7 @@ async function rollbackSelectedPlugin() { toast(t("pluginPlatform.rollbackSuccess", { version: result.plugin.manifest.version })); clearPluginIconCache(); installedPlugins.value = await api.listPlugins(); + notifyComponentUpdatesChanged(); selectPlugin(result.plugin.manifest.id); } catch (cause) { toast(translateBackendError(t, cause), 8000); @@ -703,6 +709,7 @@ async function uninstallSelectedPlugin() { operating.value = true; try { installedPlugins.value = await api.uninstallPlugin(definition.plugin.manifest.id); + notifyComponentUpdatesChanged(); clearPluginIconCache(); toast(t("pluginPlatform.uninstallSuccess", { name: definition.plugin.manifest.name })); selectFirstProvider(); diff --git a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts index 35dc385681..95c7483c81 100644 --- a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts +++ b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts @@ -5,7 +5,7 @@ import { readFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database"; import type { MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace"; -import { COMPONENT_PLUGINS_UPDATED_EVENT } from "@/lib/updates/componentUpdateEvents"; +import { COMPONENT_PLUGINS_UPDATED_EVENT, COMPONENT_UPDATES_CHANGED_EVENT } from "@/lib/updates/componentUpdateEvents"; const mocks = vi.hoisted(() => ({ listPlugins: vi.fn(), @@ -246,6 +246,24 @@ describe("PluginContributionsPanel batch source validation", () => { }); }); +describe("PluginContributionsPanel update center synchronization", () => { + it("notifies after a single marketplace update without duplicating per batch item", async () => { + const listener = vi.fn(); + window.addEventListener(COMPONENT_UPDATES_CHANGED_EVENT, listener); + try { + await state.installMarketplaceListing(state.marketplaceListings[0]); + expect(listener).toHaveBeenCalledOnce(); + + listener.mockClear(); + state.selectAllUpdatable(); + await state.runBatchInstallUpdate(); + expect(listener).toHaveBeenCalledOnce(); + } finally { + window.removeEventListener(COMPONENT_UPDATES_CHANGED_EVENT, listener); + } + }); +}); + describe("PluginContributionsPanel installed plugin pin controls", () => { it("renders selection and pin actions as sibling native buttons", async () => { state.batchMode = false; diff --git a/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts b/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts index a9566a2ca6..0866fb15e3 100644 --- a/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts +++ b/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts @@ -87,6 +87,26 @@ describe("useComponentUpdates", () => { expect(updates.totalUpdateCount.value).toBe(4); }); + it("forces a fresh snapshot when a management surface changes during an in-flight refresh", async () => { + const firstAgents = deferred>(); + const secondAgents = deferred>(); + mocks.listInstalledAgents.mockReturnValueOnce(firstAgents.promise).mockReturnValueOnce(secondAgents.promise); + const updates = useComponentUpdates({ isDesktop: true }); + + const initialRefresh = updates.refresh(); + await Promise.resolve(); + const forcedRefresh = updates.refresh({ force: true }); + await Promise.resolve(); + + secondAgents.resolve([{ db_type: "mysql", update_available: false }]); + expect(await forcedRefresh).toBe(true); + firstAgents.resolve([{ db_type: "mysql", update_available: true }]); + expect(await initialRefresh).toBe(false); + + expect(mocks.listInstalledAgents).toHaveBeenCalledTimes(2); + expect(updates.driverUpdateCount.value).toBe(0); + }); + it("clears driver and MCP update state after a successful update and refresh", async () => { mocks.listInstalledAgents.mockResolvedValueOnce([{ db_type: "mysql", update_available: true }]).mockResolvedValueOnce([{ db_type: "mysql", update_available: false }]); mocks.checkMcpServerStatus.mockResolvedValueOnce(mcpStatus).mockResolvedValueOnce({ ...mcpStatus, latest_version: "1.0.0", update_available: false }); diff --git a/apps/desktop/src/composables/useComponentUpdates.ts b/apps/desktop/src/composables/useComponentUpdates.ts index c4cc646ad1..2a3d6d35b7 100644 --- a/apps/desktop/src/composables/useComponentUpdates.ts +++ b/apps/desktop/src/composables/useComponentUpdates.ts @@ -20,6 +20,10 @@ export interface ComponentUpdateResult { failed: string[]; } +interface ComponentUpdateRefreshOptions { + force?: boolean; +} + function emptyResult(): ComponentUpdateResult { return { drivers: 0, jdbc: false, mcp: false, plugins: 0, skippedDrivers: 0, failed: [] }; } @@ -44,16 +48,18 @@ export function useComponentUpdates(options: { isDesktop: boolean }) { const totalUpdateCount = computed(() => driverUpdateCount.value + (jdbcUpdateAvailable.value ? 1 : 0) + (mcpUpdateAvailable.value ? 1 : 0) + pluginUpdateCount.value); let refreshPromise: Promise | null = null; + let refreshVersion = 0; let activeUpdatePromise: Promise | null = null; - function refresh() { + function startRefresh(): Promise { if (!options.isDesktop) return Promise.resolve(false); - if (refreshPromise) return refreshPromise; - refreshPromise = (async () => { + const version = ++refreshVersion; + refreshPromise = Promise.resolve().then(async () => { loading.value = true; lastError.value = ""; try { if (isUpdatePreviewMockEnabled()) { + if (version !== refreshVersion) return false; drivers.value = previewDriverUpdates(); jdbcPluginStatus.value = previewJdbcUpdate(); mcpStatus.value = previewMcpUpdate(); @@ -61,6 +67,7 @@ export function useComponentUpdates(options: { isDesktop: boolean }) { return true; } const [agentResult, jdbcResult, mcpResult, installedPluginsResult, catalogsResult] = await Promise.allSettled([api.listInstalledAgents(), api.jdbcPluginStatus(), api.checkMcpServerStatus(), api.listPlugins(), api.fetchPluginMarketplaceCatalogs()]); + if (version !== refreshVersion) return false; const errors: string[] = []; if (agentResult.status === "fulfilled") drivers.value = agentResult.value; else { @@ -87,16 +94,24 @@ export function useComponentUpdates(options: { isDesktop: boolean }) { lastError.value = errors.join("; "); return errors.length === 0; } catch (error) { + if (version !== refreshVersion) return false; lastError.value = error instanceof Error ? error.message : String(error); return false; } finally { - loading.value = false; - refreshPromise = null; + if (version === refreshVersion) { + loading.value = false; + refreshPromise = null; + } } - })(); + }); return refreshPromise; } + function refresh(refreshOptions: ComponentUpdateRefreshOptions = {}) { + if (!refreshOptions.force && refreshPromise) return refreshPromise; + return startRefresh(); + } + async function updateDrivers(result: ComponentUpdateResult) { const updatable = driverUpdates.value.map((driver) => driver.db_type); if (!updatable.length) return; diff --git a/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts b/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts index 7e9d247530..7b53551e11 100644 --- a/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts +++ b/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts @@ -8,6 +8,8 @@ const driverStoreSource = readFileSync(new URL("../../../components/config/Drive 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"); const pluginCenterSource = readFileSync(new URL("../../../components/plugins/PluginContributionsPanel.vue", import.meta.url), "utf8"); +const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8"); +const connectionStoreSource = readFileSync(new URL("../../../stores/connectionStore.ts", import.meta.url), "utf8"); describe("plugin center integration", () => { it("opens from the top toolbar as an independent app surface", () => { @@ -43,6 +45,15 @@ describe("plugin center integration", () => { expect(pluginCenterSource).toContain("installedPlugins.value = await api.listPlugins()"); }); + it("refreshes global component update state after manual management-surface changes", () => { + expect(appSource).toContain("COMPONENT_UPDATES_CHANGED_EVENT"); + expect(appSource).toContain("window.addEventListener(COMPONENT_UPDATES_CHANGED_EVENT, handleComponentUpdatesChanged)"); + expect(appSource).toContain("componentUpdates.refresh({ force: true })"); + for (const source of [pluginCenterSource, driverStoreSource, settingsDialogSource, connectionDialogSource, connectionStoreSource]) { + expect(source).toContain("notifyComponentUpdatesChanged()"); + } + }); + it("formats structured backend errors for local and URL package installs", () => { const localInstall = pluginCenterSource.slice(pluginCenterSource.indexOf("async function installPlugin("), pluginCenterSource.indexOf("function isHttpPackageUrl(")); const urlInstall = pluginCenterSource.slice(pluginCenterSource.indexOf("async function installPluginFromUrl("), pluginCenterSource.indexOf("const urlProgressPercent")); diff --git a/apps/desktop/src/lib/updates/__tests__/componentUpdateEvents.spec.ts b/apps/desktop/src/lib/updates/__tests__/componentUpdateEvents.spec.ts index 4e2924f1fc..faf8ab9b53 100644 --- a/apps/desktop/src/lib/updates/__tests__/componentUpdateEvents.spec.ts +++ b/apps/desktop/src/lib/updates/__tests__/componentUpdateEvents.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { describe, expect, it, vi } from "vitest"; -import { COMPONENT_PLUGINS_UPDATED_EVENT, notifyComponentPluginsUpdated } from "@/lib/updates/componentUpdateEvents"; +import { COMPONENT_PLUGINS_UPDATED_EVENT, COMPONENT_UPDATES_CHANGED_EVENT, notifyComponentPluginsUpdated, notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents"; describe("component update events", () => { it("notifies plugin surfaces after component-level plugin updates", () => { @@ -13,4 +13,24 @@ describe("component update events", () => { expect(listener).toHaveBeenCalledOnce(); window.removeEventListener(COMPONENT_PLUGINS_UPDATED_EVENT, listener); }); + + it("notifies the app when a management surface changes component state", () => { + const listener = vi.fn(); + window.addEventListener(COMPONENT_UPDATES_CHANGED_EVENT, listener); + + notifyComponentUpdatesChanged(); + + expect(listener).toHaveBeenCalledOnce(); + window.removeEventListener(COMPONENT_UPDATES_CHANGED_EVENT, listener); + }); + + it("is inert when the store runs outside a browser environment", () => { + vi.stubGlobal("window", undefined); + try { + expect(() => notifyComponentPluginsUpdated()).not.toThrow(); + expect(() => notifyComponentUpdatesChanged()).not.toThrow(); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/apps/desktop/src/lib/updates/componentUpdateEvents.ts b/apps/desktop/src/lib/updates/componentUpdateEvents.ts index 26ffa2dd9e..c626b972f0 100644 --- a/apps/desktop/src/lib/updates/componentUpdateEvents.ts +++ b/apps/desktop/src/lib/updates/componentUpdateEvents.ts @@ -1,5 +1,12 @@ export const COMPONENT_PLUGINS_UPDATED_EVENT = "dbx:component-plugins-updated"; +export const COMPONENT_UPDATES_CHANGED_EVENT = "dbx:component-updates-changed"; export function notifyComponentPluginsUpdated(): void { + if (typeof window === "undefined") return; window.dispatchEvent(new Event(COMPONENT_PLUGINS_UPDATED_EVENT)); } + +export function notifyComponentUpdatesChanged(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(COMPONENT_UPDATES_CHANGED_EVENT)); +} diff --git a/apps/desktop/src/stores/__tests__/connectionStore.sqlserverLegacy.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.sqlserverLegacy.spec.ts index 51b127e338..d8bafcd630 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.sqlserverLegacy.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.sqlserverLegacy.spec.ts @@ -50,6 +50,7 @@ describe("connectionStore SQL Server legacy compatibility", () => { connectDb, installAgent, isAgentInstalled: vi.fn().mockResolvedValue(false), + listInstalledAgents: vi.fn().mockResolvedValue([]), })); const { useConnectionStore } = await import("@/stores/connectionStore"); @@ -81,6 +82,7 @@ describe("connectionStore SQL Server legacy compatibility", () => { connectDb, installAgent, isAgentInstalled: vi.fn().mockResolvedValue(true), + listInstalledAgents: vi.fn().mockResolvedValue([]), })); const { useConnectionStore } = await import("@/stores/connectionStore"); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index ce2febc74d..871b0a30f2 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -3,6 +3,7 @@ import { uuid } from "@/lib/common/utils"; import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin"; import { ref, computed, watch, markRaw } from "vue"; import { DEFAULT_QUERY_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits"; +import { notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents"; import type { ColumnInfo, CompletionAssistantCandidate, @@ -3830,6 +3831,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!requiresSqlServerLegacyCompatibilityComponent(config)) return; if (await api.isAgentInstalled(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY)) return; await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY); + notifyComponentUpdatesChanged(); } async function setDefaultDatabase(connectionId: string, database: string) {