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;