diff --git a/apps/desktop/src/components/nacos/NacosAdminConsole.vue b/apps/desktop/src/components/nacos/NacosAdminConsole.vue index 04d82461bf..dd6a92c91e 100644 --- a/apps/desktop/src/components/nacos/NacosAdminConsole.vue +++ b/apps/desktop/src/components/nacos/NacosAdminConsole.vue @@ -1,5 +1,5 @@ diff --git a/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts b/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts index 13154d3170..1b7887e71b 100644 --- a/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts +++ b/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts @@ -1,15 +1,18 @@ // @vitest-environment happy-dom -import { createApp, nextTick, type App, type ComponentPublicInstance } from "vue"; +import { createApp, defineComponent, h, KeepAlive, nextTick, ref, type App, type ComponentPublicInstance } from "vue"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { NacosConfigItem, NacosConfigKey, NacosConfigList } from "@/types/nacos"; const mocks = vi.hoisted(() => ({ ensureConnected: vi.fn(), nacosDeleteConfig: vi.fn(), + nacosGetConfig: vi.fn(), nacosListConfigs: vi.fn(), nacosListServices: vi.fn(), nacosTestConnection: vi.fn(), + queryTabs: [] as Array>, + updateNacosConfigEditorViewport: vi.fn(), toast: vi.fn(), })); @@ -17,6 +20,7 @@ vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); vi.mock("@/lib/backend/api", () => ({ nacosDeleteConfig: mocks.nacosDeleteConfig, + nacosGetConfig: mocks.nacosGetConfig, nacosListConfigs: mocks.nacosListConfigs, nacosListServices: mocks.nacosListServices, nacosTestConnection: mocks.nacosTestConnection, @@ -34,6 +38,8 @@ vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ clearNacosNavigationTarget: vi.fn(), openNacosAdmin: vi.fn(), + tabs: mocks.queryTabs, + updateNacosConfigEditorViewport: mocks.updateNacosConfigEditorViewport, }), })); @@ -111,6 +117,7 @@ type NacosAdminSetupState = { configGroup: string; configPageNo: number; configPageSize: number; + configEditorView: { scrollDOM: HTMLElement } | null; configs: NacosConfigItem[]; deleteConfig: () => Promise; deleteSelectedConfigs: () => Promise; @@ -119,6 +126,7 @@ type NacosAdminSetupState = { selectedConfig: NacosConfigItem | null; selectedConfigKeys: string[]; selectedConfigOriginalKey: NacosConfigKey | null; + selectConfig: (item: NacosConfigItem) => Promise; toggleConfigSelection: (item: NacosConfigItem, checked: boolean) => void; }; @@ -139,9 +147,16 @@ async function flushUi() { } } +async function flushAnimationFrames(count: number) { + for (let index = 0; index < count; index += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } +} + beforeEach(async () => { mocks.ensureConnected.mockReset().mockResolvedValue(undefined); mocks.nacosDeleteConfig.mockReset().mockResolvedValue(undefined); + mocks.nacosGetConfig.mockReset().mockResolvedValue(configA); mocks.nacosListConfigs.mockReset().mockResolvedValue(configList(1, 20, 2, [configA, configB])); mocks.nacosListServices.mockReset().mockResolvedValue({ pageNo: 1, pageSize: 20, totalCount: 0, items: [] }); mocks.nacosTestConnection.mockReset().mockResolvedValue({ @@ -152,6 +167,8 @@ beforeEach(async () => { capabilities: { supportsConfigManagement: true, supportsConfigHistory: true, supportsServiceManagement: true, supportsInstanceUpdate: true, supportsRawApi: true }, }); mocks.toast.mockReset(); + mocks.updateNacosConfigEditorViewport.mockReset(); + mocks.queryTabs.splice(0); host = document.createElement("div"); document.body.append(host); @@ -211,3 +228,76 @@ describe("NacosAdminConsole config deletion", () => { expect(mocks.nacosListConfigs.mock.calls.map(([, query]) => query)).toEqual([expect.objectContaining({ pageNo: 2, pageSize: 50 }), expect.objectContaining({ pageNo: 1, pageSize: 50 })]); }); }); + +describe("NacosAdminConsole cached tab restoration", () => { + it("does not let a hidden editor overwrite its viewport before reactivation", async () => { + app?.unmount(); + host?.remove(); + const active = ref(true); + const nacosRef = ref(null); + const CachedNacos = defineComponent({ + setup() { + return () => + h(KeepAlive, null, { + default: () => [active.value ? h(NacosAdminConsole, { ref: nacosRef, connectionId: "connection-1", namespace: "public" }) : h("div", "other-tab")], + }); + }, + }); + host = document.createElement("div"); + document.body.append(host); + app = createApp(CachedNacos); + app.mount(host); + await flushUi(); + + state = nacosRef.value?.$.setupState as unknown as NacosAdminSetupState; + await state.selectConfig(configA); + await flushUi(); + const editor = state.configEditorView; + expect(editor).not.toBeNull(); + if (!editor) throw new Error("Expected the Nacos config editor to mount"); + + editor.scrollDOM.scrollTop = 96; + editor.scrollDOM.dispatchEvent(new Event("scroll")); + await flushAnimationFrames(1); + + active.value = false; + await nextTick(); + editor.scrollDOM.scrollTop = 0; + editor.scrollDOM.dispatchEvent(new Event("scroll")); + await flushAnimationFrames(1); + + active.value = true; + await nextTick(); + await flushAnimationFrames(9); + + expect(mocks.updateNacosConfigEditorViewport).toHaveBeenLastCalledWith("connection-1", "public", expect.objectContaining({ scrollTop: 96 })); + expect(editor.scrollDOM.scrollTop).toBe(96); + }); + + it("reselects the configuration saved with an evicted tab viewport", async () => { + app?.unmount(); + host?.remove(); + mocks.queryTabs.push({ + mode: "nacos", + connectionId: "connection-1", + nacosNamespace: "public", + nacosConfigEditorViewport: { + namespace: "public", + dataId: "config-a", + group: "DEFAULT_GROUP", + scrollTop: 96, + scrollLeft: 0, + }, + }); + host = document.createElement("div"); + document.body.append(host); + app = createApp(NacosAdminConsole, { connectionId: "connection-1", namespace: "public" }); + const instance = app.mount(host) as ComponentPublicInstance; + state = instance.$.setupState as unknown as NacosAdminSetupState; + + await flushUi(); + + expect(mocks.nacosGetConfig).toHaveBeenCalledWith("connection-1", { namespace: "public", dataId: "config-a", group: "DEFAULT_GROUP" }); + expect(state.selectedConfig?.dataId).toBe("config-a"); + }); +}); diff --git a/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts index 1d8ce3d4b2..19df87b1ad 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts @@ -241,4 +241,33 @@ describe("queryStore switchTab", () => { expect(queryStore.tabs.filter((tab) => tab.mode === "nacos-dashboard")).toHaveLength(1); expect(queryStore.activeTabId).toBe(tabId); }); + + it("keeps the active Nacos configuration editor viewport on its matching tab", () => { + const queryStore = useQueryStore(); + const tabId = queryStore.openNacosAdmin("nacos-1", { namespace: "team-a" }); + + queryStore.updateNacosConfigEditorViewport("nacos-1", "team-a", { + namespace: "team-a", + dataId: "application.yaml", + group: "DEFAULT_GROUP", + scrollTop: 82.6, + scrollLeft: -8, + }); + queryStore.updateNacosConfigEditorViewport("nacos-1", "team-b", { + namespace: "team-b", + dataId: "ignored.yaml", + group: "DEFAULT_GROUP", + scrollTop: 12, + scrollLeft: 4, + }); + + const tab = queryStore.tabs.find((candidate) => candidate.id === tabId); + expect(tab?.nacosConfigEditorViewport).toEqual({ + namespace: "team-a", + dataId: "application.yaml", + group: "DEFAULT_GROUP", + scrollTop: 83, + scrollLeft: 0, + }); + }); }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 74e0f214b6..a0436ad121 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2,7 +2,7 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/common/utils"; import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; -import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database"; +import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database"; import { orderPinnedFirst } from "@/lib/app/pinnedItems"; import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState"; import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan"; @@ -3397,6 +3397,22 @@ export const useQueryStore = defineStore("query", () => { tab.objectBrowser = { ...tab.objectBrowser, viewport }; } + function updateNacosConfigEditorViewport(connectionId: string, namespace: string, viewport: NacosConfigEditorViewport) { + if (!Number.isFinite(viewport.scrollTop) || !Number.isFinite(viewport.scrollLeft)) return; + const tab = tabs.value.find((candidate) => candidate.mode === "nacos" && candidate.connectionId === connectionId && (candidate.nacosNamespace || "") === namespace); + if (!tab) return; + const next = { + ...viewport, + scrollTop: Math.max(0, Math.round(viewport.scrollTop)), + scrollLeft: Math.max(0, Math.round(viewport.scrollLeft)), + }; + const previous = tab.nacosConfigEditorViewport; + if (previous?.namespace === next.namespace && previous.dataId === next.dataId && previous.group === next.group && previous.scrollTop === next.scrollTop && previous.scrollLeft === next.scrollLeft) { + return; + } + tab.nacosConfigEditorViewport = next; + } + function renameTab(id: string, title: string) { const trimmed = title.trim(); if (!trimmed) return false; @@ -6977,6 +6993,7 @@ export const useQueryStore = defineStore("query", () => { updateEditorViewport, updateEditorSelection, updateObjectBrowserViewport, + updateNacosConfigEditorViewport, setAutoCommit, commitTransaction, rollbackTransaction, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index f325d202b1..a7052fccfc 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1109,6 +1109,15 @@ export interface ObjectBrowserViewport { viewMode: ObjectBrowserViewMode; } +/** Runtime-only viewport state for the selected configuration in a Nacos tab. */ +export interface NacosConfigEditorViewport { + namespace: string; + dataId: string; + group: string; + scrollTop: number; + scrollLeft: number; +} + export interface ExternalSqlFileVersion { sizeBytes: number; modifiedNs: string; @@ -1257,6 +1266,7 @@ export interface QueryTab { nacosTargetGroup?: string; nacosTargetKeyword?: string; nacosTargetRequestId?: number; + nacosConfigEditorViewport?: NacosConfigEditorViewport; structureTableName?: string; structureInitialTab?: TableInfoTab; structureInitialTabRequestId?: number;