From 62624786dca1d0e2886d1091f4097dd5636b00fa Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Wed, 5 Aug 2026 15:52:20 +0800 Subject: [PATCH] feat(mcp): add update notification badge --- apps/desktop/src/App.vue | 16 +- .../editor/EditorSettingsDialog.vue | 18 +++ .../src/components/layout/AppToolbar.vue | 6 +- .../__tests__/useMcpUpdateBadge.spec.ts | 138 ++++++++++++++++++ .../src/composables/useMcpUpdateBadge.ts | 63 ++++++++ apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + apps/desktop/src/lib/mcp/mcpUpdateStatus.ts | 19 +++ .../__tests__/settingsPageNavigation.spec.ts | 14 ++ 15 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/composables/__tests__/useMcpUpdateBadge.spec.ts create mode 100644 apps/desktop/src/composables/useMcpUpdateBadge.ts create mode 100644 apps/desktop/src/lib/mcp/mcpUpdateStatus.ts create mode 100644 apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 0c0156c59..a7f848de8 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -19,6 +19,7 @@ import { usePromptTemplateStore } from "@/stores/promptTemplateStore"; import { useToast } from "@/composables/useToast"; import { useTheme } from "@/composables/useTheme"; import { useAppUpdater } from "@/composables/useAppUpdater"; +import { useMcpUpdateBadge } from "@/composables/useMcpUpdateBadge"; import { useExportTracker } from "@/composables/useExportTracker"; import { useFileDrop } from "@/composables/useFileDrop"; import { usePanelResize } from "@/composables/usePanelResize"; @@ -162,6 +163,10 @@ const { const { setupFileDrop } = useFileDrop(); const isDesktop = isTauriRuntime(); +const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged } = useMcpUpdateBadge({ + isDesktop, + updateNotificationsEnabled: () => settingsStore.editorSettings.updateNotificationsEnabled, +}); const drawDesktopWindowFrame = shouldDrawDesktopWindowFrame(isMacOS(), isDesktop, isWindows()); const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; let updateCheckTimer: ReturnType | undefined; @@ -175,6 +180,7 @@ const connectionDialogInitialTab = ref(undefined); const settingsPageTabOpen = ref(false); const settingsInitialTab = ref("appearance"); const settingsInitialSection = ref(undefined); +const settingsNavigationRequestId = ref(0); const showQueryEditorDdlDialog = ref(false); const showQueryEditorObjectSourceDialog = ref(false); const driverStoreTabOpen = ref(false); @@ -364,6 +370,7 @@ const updateNotificationsEnabled = computed(() => settingsStore.editorSettings.u function openSettings(initialTab = "appearance", initialSection?: string) { settingsInitialTab.value = initialTab; settingsInitialSection.value = initialSection; + settingsNavigationRequestId.value += 1; if (!settingsStore.settingsPageActive) { settingsReturnSurface.value = showDriverStore.value ? "driverStore" : activeTab.value ? "query" : "welcome"; } @@ -420,6 +427,7 @@ function closeDriverStorePage() { } const toolbarAgentDriverUpdateCount = computed(() => (updateNotificationsEnabled.value ? agentDriverUpdateCount.value : 0)); const toolbarHasUpdateAvailable = computed(() => updateNotificationsEnabled.value && hasUpdateAvailable.value); +const toolbarMcpUpdateAvailable = computed(() => updateNotificationsEnabled.value && mcpUpdateAvailable.value); const hasSqlFileConnections = computed(() => connectionStore.connections.some((c) => supportsSqlFileExecution(c.db_type))); const queryEditorDdlDatabaseType = computed(() => { if (!queryEditorDdlTarget.value?.connectionId) return undefined; @@ -2134,11 +2142,13 @@ function runUpdateNotificationChecks() { if (!updateNotificationsEnabled.value) return; checkUpdates({ silent: true }); void refreshAgentDriverUpdateCount(); + void refreshMcpUpdateStatus(); } watch(updateNotificationsEnabled, (enabled) => { if (!enabled) { agentDriverUpdateCount.value = 0; + mcpUpdateAvailable.value = false; if (updateCheckTimer) { clearInterval(updateCheckTimer); updateCheckTimer = undefined; @@ -2162,6 +2172,7 @@ onMounted(async () => { window.addEventListener("keydown", handleNativeSelectAll, true); window.addEventListener("keydown", handleKeydown); window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent); + window.addEventListener("dbx-mcp-status-changed", handleMcpStatusChanged); if (isDesktop) { document.addEventListener("contextmenu", handleContextMenu); } @@ -2229,6 +2240,7 @@ onUnmounted(() => { window.removeEventListener("keydown", handleNativeSelectAll, true); window.removeEventListener("keydown", handleKeydown); window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent); + window.removeEventListener("dbx-mcp-status-changed", handleMcpStatusChanged); document.removeEventListener("contextmenu", handleContextMenu); }); @@ -2250,6 +2262,7 @@ onUnmounted(() => { :checking-updates="checkingUpdates" :has-update-available="toolbarHasUpdateAvailable" :agent-driver-update-count="toolbarAgentDriverUpdateCount" + :has-mcp-update-available="toolbarMcpUpdateAvailable" :has-connections="connectionStore.connections.length > 0" :has-sql-file-connections="hasSqlFileConnections" @new-connection="showConnectionDialog = true" @@ -2260,7 +2273,7 @@ onUnmounted(() => { @toggle-sql-library="toggleRightSidebarPanel('sqlLibrary')" @toggle-sql-file-panel="toggleRightSidebarPanel('sqlFile')" @open-github="openGitHub" - @open-settings="openSettings()" + @open-settings="openSettings(toolbarMcpUpdateAvailable ? 'mcp' : 'appearance')" @open-driver-store="openDriverStorePage" @check-updates="checkUpdates()" @open-transfer="dialogs.showTransferDialog.value = true" @@ -2318,6 +2331,7 @@ onUnmounted(() => { :open="settingsPageTabOpen" :initial-tab="settingsInitialTab" :initial-section="settingsInitialSection" + :navigation-request-id="settingsNavigationRequestId" :app-version="appVersion" class="flex-1 min-h-0" @update:open="(open: boolean) => (open ? activateSettingsPage() : closeSettingsPage())" diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 08043cbf1..2736b3690 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -102,6 +102,7 @@ import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt import { EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE, parseTableColumnTemplateFields, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES } from "@/lib/table/tableColumnTemplates"; import { DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES, normalizeSqlVariableSyntaxOverrides, SQL_VARIABLE_SYNTAX_DATABASE_TYPES, SQL_VARIABLE_SYNTAX_KEYS, SQL_VARIABLE_SYNTAX_TOKENS, type SqlVariableSyntaxOverrides, type SqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax"; import { buildMcpCherryStudioConfig, buildMcpCodexConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpTraeConfig, buildMcpVsCodeConfig, mcpWebBackendUrl, type McpLaunchConfig } from "@/lib/mcp/mcpConfigTemplates"; +import { beginMcpStatusRequest, mcpUpdateAvailability } from "@/lib/mcp/mcpUpdateStatus"; import { isMcpPolicyMutationBlocked, MCP_CAPABILITY_ROWS, MCP_EXECUTION_MODE_COLUMNS, mcpExecutionModeFromPolicy, mcpPolicyFieldsForExecutionMode, type McpExecutionMode } from "@/lib/mcp/mcpPolicySelection"; import { isMacOS, isWindows } from "@/lib/backend/platform"; import { combineDataTypeForDatabase, dataTypeLengthInputValue, getDataTypeOptions, getDefaultLengthForType, isDataTypeLengthDisabled, splitDataType } from "@/lib/table/tableStructureEditorState"; @@ -185,6 +186,7 @@ const props = defineProps<{ variant?: "dialog" | "page"; initialTab?: string; initialSection?: string; + navigationRequestId?: number; appVersion?: string; }>(); @@ -1780,8 +1782,15 @@ async function refreshMcpStatus() { if (mcpStatusLoading.value) return; mcpStatusLoading.value = true; mcpStatusError.value = ""; + const requestId = beginMcpStatusRequest(); try { mcpStatus.value = await checkMcpServerStatus(); + // 通知工具栏徽章同步:携带已获取的 update_available,避免根组件重复查询 npm registry。 + window.dispatchEvent( + new CustomEvent("dbx-mcp-status-changed", { + detail: { updateAvailable: mcpUpdateAvailability(mcpStatus.value), requestId }, + }), + ); } catch (e: any) { mcpStatusError.value = e?.message || String(e); } finally { @@ -2291,6 +2300,15 @@ watch( }, ); +watch( + () => props.navigationRequestId, + () => { + if (!settingsVisible.value || !props.initialTab) return; + activeSettingsTab.value = props.initialTab; + void scrollToInitialSettingsSection(); + }, +); + watch([webdavEndpoint, webdavUsername], () => { void refreshWebDavPasswordStatus(); }); diff --git a/apps/desktop/src/components/layout/AppToolbar.vue b/apps/desktop/src/components/layout/AppToolbar.vue index 75694056c..2601cbe4f 100644 --- a/apps/desktop/src/components/layout/AppToolbar.vue +++ b/apps/desktop/src/components/layout/AppToolbar.vue @@ -35,6 +35,7 @@ const props = defineProps<{ checkingUpdates: boolean; hasUpdateAvailable: boolean; agentDriverUpdateCount: number; + hasMcpUpdateAvailable: boolean; hasConnections: boolean; hasSqlFileConnections: boolean; }>(); @@ -618,11 +619,12 @@ const toolbarStyle = computed(() => { - - {{ t("settings.title") }} + {{ hasMcpUpdateAvailable ? t("toolbar.mcpUpdateAvailable") : t("settings.title") }} diff --git a/apps/desktop/src/composables/__tests__/useMcpUpdateBadge.spec.ts b/apps/desktop/src/composables/__tests__/useMcpUpdateBadge.spec.ts new file mode 100644 index 000000000..b029a9634 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useMcpUpdateBadge.spec.ts @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useMcpUpdateBadge } from "@/composables/useMcpUpdateBadge"; +import { beginMcpStatusRequest } from "@/lib/mcp/mcpUpdateStatus"; +import type { McpServerStatus } from "@/lib/backend/tauri"; + +const apiMock = vi.hoisted(() => ({ + checkMcpServerStatus: vi.fn<() => Promise>(), +})); + +vi.mock("@/lib/backend/api", () => apiMock); + +const mockedCheck = apiMock.checkMcpServerStatus; + +interface Deferred { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: Deferred["resolve"]; + let reject!: Deferred["reject"]; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function makeStatus(update_available: boolean, overrides: Partial = {}): McpServerStatus { + return { + installed: true, + npm_available: true, + node_path: null, + node_version: null, + current_version: "1.0.0", + latest_version: "1.0.0", + update_available, + bin_path: null, + native_bin_path: null, + script_path: null, + data_dir: null, + install_command: "", + update_command: "", + error: null, + ...overrides, + }; +} + +function makeBadge(enabled = true, isDesktop = true) { + return useMcpUpdateBadge({ + isDesktop, + updateNotificationsEnabled: () => enabled, + }); +} + +beforeEach(() => { + mockedCheck.mockReset(); +}); + +describe("useMcpUpdateBadge", () => { + it("更新通知关闭时不发起检查", async () => { + const badge = makeBadge(false); + await badge.refreshMcpUpdateStatus(); + expect(mockedCheck).not.toHaveBeenCalled(); + expect(badge.mcpUpdateAvailable.value).toBe(false); + }); + + it("update_available 为真时置亮红点", async () => { + mockedCheck.mockResolvedValue(makeStatus(true)); + const badge = makeBadge(true); + await badge.refreshMcpUpdateStatus(); + expect(badge.mcpUpdateAvailable.value).toBe(true); + }); + + it("更新后通过事件 payload 清除红点", () => { + const badge = makeBadge(true); + badge.applyMcpStatus(true); + expect(badge.mcpUpdateAvailable.value).toBe(true); + badge.handleMcpStatusChanged(new CustomEvent("dbx:mcp-status-changed", { detail: { updateAvailable: false } })); + expect(badge.mcpUpdateAvailable.value).toBe(false); + }); + + it("乱序响应保护:旧请求晚返回不覆盖新结果", async () => { + const pending = deferred(); + mockedCheck.mockReturnValueOnce(pending.promise); + const badge = makeBadge(true); + const refreshA = badge.refreshMcpUpdateStatus(); + // 用户升级,事件回传 false,使在途的请求 A 失效 + badge.handleMcpStatusChanged(new CustomEvent("dbx:mcp-status-changed", { detail: { updateAvailable: false } })); + expect(badge.mcpUpdateAvailable.value).toBe(false); + // 旧请求 A 晚返回 true(升级前的状态),必须被忽略 + pending.resolve(makeStatus(true)); + await refreshA; + expect(badge.mcpUpdateAvailable.value).toBe(false); + }); + + it("忽略早于当前根组件请求的设置页事件", async () => { + const settingsRequestId = beginMcpStatusRequest(); + const pending = deferred(); + mockedCheck.mockReturnValueOnce(pending.promise); + const badge = makeBadge(true); + const refresh = badge.refreshMcpUpdateStatus(); + + badge.handleMcpStatusChanged(new CustomEvent("dbx-mcp-status-changed", { detail: { updateAvailable: true, requestId: settingsRequestId } })); + expect(badge.mcpUpdateAvailable.value).toBe(false); + + pending.resolve(makeStatus(true)); + await refresh; + expect(badge.mcpUpdateAvailable.value).toBe(true); + }); + + it("registry 结果未知时保留已知红点", async () => { + mockedCheck.mockResolvedValue(makeStatus(false, { latest_version: null })); + const badge = makeBadge(true); + badge.applyMcpStatus(true); + + await badge.refreshMcpUpdateStatus(); + + expect(badge.mcpUpdateAvailable.value).toBe(true); + }); + + it("Web 端(非桌面)不发起检查", async () => { + const badge = makeBadge(true, false); + await badge.refreshMcpUpdateStatus(); + expect(mockedCheck).not.toHaveBeenCalled(); + expect(badge.mcpUpdateAvailable.value).toBe(false); + }); + + it("无 payload 的事件回退到重新检查", async () => { + mockedCheck.mockResolvedValue(makeStatus(true)); + const badge = makeBadge(true); + badge.handleMcpStatusChanged(new CustomEvent("dbx:mcp-status-changed")); + await vi.waitFor(() => expect(badge.mcpUpdateAvailable.value).toBe(true)); + }); +}); diff --git a/apps/desktop/src/composables/useMcpUpdateBadge.ts b/apps/desktop/src/composables/useMcpUpdateBadge.ts new file mode 100644 index 000000000..7fb995f9e --- /dev/null +++ b/apps/desktop/src/composables/useMcpUpdateBadge.ts @@ -0,0 +1,63 @@ +import { ref } from "vue"; +import * as api from "@/lib/backend/api"; +import { beginMcpStatusRequest, isLatestMcpStatusRequest, mcpUpdateAvailability } from "@/lib/mcp/mcpUpdateStatus"; + +interface UseMcpUpdateBadgeOptions { + isDesktop: boolean; + updateNotificationsEnabled: () => boolean; +} + +/** + * MCP server 更新徽章状态。 + * + * 照搬 app/驱动两套 badge 模式:后台 silent 轮询 + computed 驱动红点 + 事件回传。 + * 通过递增请求序号忽略过期响应,避免“定时检查旧请求晚返回、覆盖升级后新结果”的竞态。 + */ +export function useMcpUpdateBadge(options: UseMcpUpdateBadgeOptions) { + const mcpUpdateAvailable = ref(false); + + async function refreshMcpUpdateStatus() { + if (!options.isDesktop || !options.updateNotificationsEnabled()) return; + const requestId = beginMcpStatusRequest(); + try { + const status = await api.checkMcpServerStatus(); + if (!isLatestMcpStatusRequest(requestId)) return; + if (!options.updateNotificationsEnabled()) return; + const updateAvailable = mcpUpdateAvailability(status); + if (updateAvailable !== null) mcpUpdateAvailable.value = updateAvailable; + } catch { + // MCP 状态仅作徽章提示;取不到就保持原值,不打扰用户。 + } + } + + /** + * EditorSettingsDialog 刷新/升级后通过事件回传已获取的 update_available, + * 避免根组件重复查询 npm registry,同时使在途的定时检查失效。 + */ + function applyMcpStatus(updateAvailable: boolean, requestId?: number) { + if (requestId !== undefined) { + if (!isLatestMcpStatusRequest(requestId)) return; + } else { + beginMcpStatusRequest(); + } + mcpUpdateAvailable.value = updateAvailable; + } + + function handleMcpStatusChanged(event: Event) { + const detail = (event as CustomEvent<{ updateAvailable?: boolean | null; requestId?: number } | null | undefined>).detail; + if (detail && typeof detail.updateAvailable === "boolean") { + applyMcpStatus(detail.updateAvailable, detail.requestId); + } else if (detail && typeof detail.requestId === "number") { + return; + } else { + void refreshMcpUpdateStatus(); + } + } + + return { + mcpUpdateAvailable, + refreshMcpUpdateStatus, + handleMcpStatusChanged, + applyMcpStatus, + }; +} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index db0eb41c3..189229685 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -62,6 +62,7 @@ export default { sqlSaveFailed: "Failed to save file: {message}", driverManager: "Driver Manager", updatableDriverCount: "Updatable driver count", + mcpUpdateAvailable: "MCP server update available", blockDangerousRedisCommands: "Block dangerous commands", }, updates: { diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 160168b46..7f47a7da7 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -64,6 +64,7 @@ export default withEnglishFallback({ sqlSaveFailed: "Error al guardar el archivo: {message}", driverManager: "Administrador de drivers", updatableDriverCount: "Cantidad de drivers actualizables", + mcpUpdateAvailable: "Actualización del servidor MCP disponible", blockDangerousRedisCommands: "Bloquear comandos peligrosos", }, updates: { diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 0f092cb04..4dd122226 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -63,6 +63,7 @@ export default withEnglishFallback({ sqlSaveFailed: "Impossibile salvare il file: {message}", driverManager: "Gestione Driver", updatableDriverCount: "Driver aggiornabili", + mcpUpdateAvailable: "Aggiornamento server MCP disponibile", blockDangerousRedisCommands: "Blocca comandi pericolosi", }, updates: { diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 4438d81e6..443a840ab 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -64,6 +64,7 @@ export default withEnglishFallback({ sqlSaveFailed: "ファイルの保存に失敗しました: {message}", driverManager: "ドライバーマネージャー", updatableDriverCount: "更新可能なドライバー数", + mcpUpdateAvailable: "MCPサーバーの更新があります", blockDangerousRedisCommands: "危険なコマンドをブロック", }, updates: { diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index ed4e041dc..366a0a506 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -64,6 +64,7 @@ export default withEnglishFallback({ sqlSaveFailed: "파일을 저장하는 데 실패했습니다: {message}", driverManager: "드라이버 관리자", updatableDriverCount: "업데이트 가능한 드라이버 수", + mcpUpdateAvailable: "MCP 서버 업데이트 가능", blockDangerousRedisCommands: "위험한 명령 차단", }, updates: { diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f9450321a..cb50032ea 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -64,6 +64,7 @@ export default withEnglishFallback({ sqlSaveFailed: "Falha ao salvar o arquivo: {message}", driverManager: "Gerenciador de Drivers", updatableDriverCount: "Quantidade de drivers atualizáveis", + mcpUpdateAvailable: "Atualização do servidor MCP disponível", blockDangerousRedisCommands: "Bloquear comandos perigosos", }, updates: { diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 919f5eceb..3a6c58c49 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -64,6 +64,7 @@ export default withEnglishFallback({ sqlSaveFailed: "保存文件失败:{message}", driverManager: "驱动管理", updatableDriverCount: "可更新驱动数量", + mcpUpdateAvailable: "MCP 服务有可用更新", blockDangerousRedisCommands: "拦截危险命令", }, updates: { diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index db4a42158..7fc8445eb 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -64,6 +64,7 @@ export default withEnglishFallback({ sqlSaveFailed: "儲存檔案失敗:{message}", driverManager: "驅動程式管理器", updatableDriverCount: "可更新驅動程式數量", + mcpUpdateAvailable: "MCP 服務有可用更新", blockDangerousRedisCommands: "攔截危險命令", }, updates: { diff --git a/apps/desktop/src/lib/mcp/mcpUpdateStatus.ts b/apps/desktop/src/lib/mcp/mcpUpdateStatus.ts new file mode 100644 index 000000000..a27f01440 --- /dev/null +++ b/apps/desktop/src/lib/mcp/mcpUpdateStatus.ts @@ -0,0 +1,19 @@ +import type { McpServerStatus } from "@/lib/backend/tauri"; + +let mcpStatusRequestSequence = 0; +let latestMcpStatusRequestId = 0; + +export function beginMcpStatusRequest(): number { + latestMcpStatusRequestId = ++mcpStatusRequestSequence; + return latestMcpStatusRequestId; +} + +export function isLatestMcpStatusRequest(requestId: number): boolean { + return requestId === latestMcpStatusRequestId; +} + +export function mcpUpdateAvailability(status: McpServerStatus): boolean | null { + if (!status.installed) return false; + if (!status.current_version || !status.latest_version) return null; + return status.update_available; +} diff --git a/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts new file mode 100644 index 000000000..9ffdd244e --- /dev/null +++ b/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8"); +const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8"); + +describe("settings page navigation", () => { + it("replays repeated navigation requests for the same tab", () => { + expect(appSource).toContain("settingsNavigationRequestId.value += 1"); + expect(appSource).toContain(':navigation-request-id="settingsNavigationRequestId"'); + expect(settingsDialogSource).toContain("navigationRequestId?: number;"); + expect(settingsDialogSource).toMatch(/watch\(\s*\(\) => props\.navigationRequestId,/); + }); +});