feat(mcp): add update notification badge
This commit is contained in:
parent
7243f5eb06
commit
62624786dc
|
|
@ -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())"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default withEnglishFallback({
|
|||
sqlSaveFailed: "ファイルの保存に失敗しました: {message}",
|
||||
driverManager: "ドライバーマネージャー",
|
||||
updatableDriverCount: "更新可能なドライバー数",
|
||||
mcpUpdateAvailable: "MCPサーバーの更新があります",
|
||||
blockDangerousRedisCommands: "危険なコマンドをブロック",
|
||||
},
|
||||
updates: {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default withEnglishFallback({
|
|||
sqlSaveFailed: "파일을 저장하는 데 실패했습니다: {message}",
|
||||
driverManager: "드라이버 관리자",
|
||||
updatableDriverCount: "업데이트 가능한 드라이버 수",
|
||||
mcpUpdateAvailable: "MCP 서버 업데이트 가능",
|
||||
blockDangerousRedisCommands: "위험한 명령 차단",
|
||||
},
|
||||
updates: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default withEnglishFallback({
|
|||
sqlSaveFailed: "保存文件失败:{message}",
|
||||
driverManager: "驱动管理",
|
||||
updatableDriverCount: "可更新驱动数量",
|
||||
mcpUpdateAvailable: "MCP 服务有可用更新",
|
||||
blockDangerousRedisCommands: "拦截危险命令",
|
||||
},
|
||||
updates: {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default withEnglishFallback({
|
|||
sqlSaveFailed: "儲存檔案失敗:{message}",
|
||||
driverManager: "驅動程式管理器",
|
||||
updatableDriverCount: "可更新驅動程式數量",
|
||||
mcpUpdateAvailable: "MCP 服務有可用更新",
|
||||
blockDangerousRedisCommands: "攔截危險命令",
|
||||
},
|
||||
updates: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue