feat(mcp): add update notification badge

This commit is contained in:
Abeautifulsnow 2026-08-05 15:52:20 +08:00 committed by GitHub
parent 7243f5eb06
commit 62624786dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 279 additions and 3 deletions

View File

@ -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<typeof setInterval> | undefined;
@ -175,6 +180,7 @@ const connectionDialogInitialTab = ref<ConfigTab | undefined>(undefined);
const settingsPageTabOpen = ref(false);
const settingsInitialTab = ref("appearance");
const settingsInitialSection = ref<string | undefined>(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);
});
</script>
@ -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())"

View File

@ -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();
});

View File

@ -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(() => {
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" :class="{ 'bg-accent': showSettingsPage }" @click="emit('open-settings')">
<Button variant="ghost" size="icon" class="relative h-8 w-8 shrink-0" :class="{ 'bg-accent': showSettingsPage }" @click="emit('open-settings')">
<Settings class="h-4 w-4" />
<span v-if="hasMcpUpdateAvailable" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" :aria-label="t('toolbar.mcpUpdateAvailable')" :title="t('toolbar.mcpUpdateAvailable')" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("settings.title") }}</TooltipContent>
<TooltipContent>{{ hasMcpUpdateAvailable ? t("toolbar.mcpUpdateAvailable") : t("settings.title") }}</TooltipContent>
</Tooltip>
<WindowControls v-if="showControls" :is-maximized="isMaximized" @minimize="minimize" @toggle-maximize="toggleMaximize" @close="close" />

View File

@ -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<McpServerStatus>>(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
const mockedCheck = apiMock.checkMcpServerStatus;
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: Deferred<T>["resolve"];
let reject!: Deferred<T>["reject"];
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function makeStatus(update_available: boolean, overrides: Partial<McpServerStatus> = {}): 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<McpServerStatus>();
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<McpServerStatus>();
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));
});
});

View File

@ -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,
};
}

View File

@ -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: {

View File

@ -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: {

View File

@ -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: {

View File

@ -64,6 +64,7 @@ export default withEnglishFallback({
sqlSaveFailed: "ファイルの保存に失敗しました: {message}",
driverManager: "ドライバーマネージャー",
updatableDriverCount: "更新可能なドライバー数",
mcpUpdateAvailable: "MCPサーバーの更新があります",
blockDangerousRedisCommands: "危険なコマンドをブロック",
},
updates: {

View File

@ -64,6 +64,7 @@ export default withEnglishFallback({
sqlSaveFailed: "파일을 저장하는 데 실패했습니다: {message}",
driverManager: "드라이버 관리자",
updatableDriverCount: "업데이트 가능한 드라이버 수",
mcpUpdateAvailable: "MCP 서버 업데이트 가능",
blockDangerousRedisCommands: "위험한 명령 차단",
},
updates: {

View File

@ -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: {

View File

@ -64,6 +64,7 @@ export default withEnglishFallback({
sqlSaveFailed: "保存文件失败:{message}",
driverManager: "驱动管理",
updatableDriverCount: "可更新驱动数量",
mcpUpdateAvailable: "MCP 服务有可用更新",
blockDangerousRedisCommands: "拦截危险命令",
},
updates: {

View File

@ -64,6 +64,7 @@ export default withEnglishFallback({
sqlSaveFailed: "儲存檔案失敗:{message}",
driverManager: "驅動程式管理器",
updatableDriverCount: "可更新驅動程式數量",
mcpUpdateAvailable: "MCP 服務有可用更新",
blockDangerousRedisCommands: "攔截危險命令",
},
updates: {

View File

@ -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;
}

View File

@ -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,/);
});
});