feat(nacos): restore config editor selection and scroll

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-02 20:16:01 +08:00
1 parent fe509e80c7
commit 0f60b6a655
5 files changed
+302 -3

No files matched your search

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, watch } from "vue";
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, shallowRef, useId, watch } from "vue";
import { Compartment, type Extension } from "@codemirror/state";
import { StreamLanguage, ensureSyntaxTree } from "@codemirror/language";
import type { EditorView } from "@codemirror/view";
@@ -56,6 +56,7 @@ import { useTheme } from "@/composables/useTheme";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import type { NacosConfigEditorViewport } from "@/types/database";
import type {
NacosBatchPreview,
NacosBatchReport,
@@ -167,6 +168,10 @@ const configEditorHost = ref<HTMLDivElement | null>(null);
const configEditorView = shallowRef<EditorView | null>(null);
const configSearchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
const configEditorFontSize = ref(clampEditorFontSize(settingsStore.editorSettings.fontSize));
let configEditorViewportFrame: number | null = null;
let configEditorViewportRestoreFrame: number | null = null;
let latestConfigEditorViewport: NacosConfigEditorViewport | undefined;
let configEditorIsActive = true;
const configEditorZoomCommitScheduler = createEditorZoomCommitScheduler((fontSize) => {
if (settingsStore.editorSettings.fontSize === fontSize) return;
settingsStore.updateEditorSettings({ fontSize });
@@ -589,14 +594,140 @@ async function mountConfigEditor() {
return;
}
configEditorView.value = view;
view.scrollDOM.addEventListener("scroll", scheduleConfigEditorViewportCapture, { passive: true });
restoreConfigEditorViewport();
}
function destroyConfigEditor() {
configEditorGeneration += 1;
flushConfigEditorViewport();
if (configEditorViewportRestoreFrame !== null) {
cancelAnimationFrame(configEditorViewportRestoreFrame);
configEditorViewportRestoreFrame = null;
}
configEditorView.value?.scrollDOM.removeEventListener("scroll", scheduleConfigEditorViewportCapture);
configEditorView.value?.destroy();
configEditorView.value = null;
}
function configEditorViewportIdentity() {
const config = selectedConfig.value;
if (!config) return undefined;
return {
namespace: config.namespace || namespace.value || "",
dataId: config.dataId,
group: config.group || "DEFAULT_GROUP",
};
}
function isCurrentConfigEditorViewport(viewport: NacosConfigEditorViewport) {
const identity = configEditorViewportIdentity();
return !!identity && viewport.namespace === identity.namespace && viewport.dataId === identity.dataId && viewport.group === identity.group;
}
function storedConfigEditorViewport() {
const viewport = savedConfigEditorViewport();
return viewport && isCurrentConfigEditorViewport(viewport) ? viewport : undefined;
}
function savedConfigEditorViewport() {
const tab = queryStore.tabs.find((candidate) => candidate.mode === "nacos" && candidate.connectionId === props.connectionId && (candidate.nacosNamespace || "") === namespace.value);
return tab?.nacosConfigEditorViewport;
}
function initializeConfigEditorViewport() {
const identity = configEditorViewportIdentity();
if (!identity) return;
const saved = savedConfigEditorViewport();
const viewport = latestConfigEditorViewport && isCurrentConfigEditorViewport(latestConfigEditorViewport) ? latestConfigEditorViewport : saved && isCurrentConfigEditorViewport(saved) ? saved : { ...identity, scrollTop: 0, scrollLeft: 0 };
latestConfigEditorViewport = viewport;
queryStore.updateNacosConfigEditorViewport(props.connectionId, namespace.value, viewport);
}
async function restoreSelectedConfig() {
const viewport = savedConfigEditorViewport();
if (!viewport?.dataId) return;
await selectConfig({
namespace: viewport.namespace,
dataId: viewport.dataId,
group: viewport.group,
});
}
function captureConfigEditorViewport() {
const view = configEditorView.value;
const identity = configEditorViewportIdentity();
if (!view || !identity) return false;
const viewport = {
...identity,
scrollTop: Math.max(0, view.scrollDOM.scrollTop),
scrollLeft: Math.max(0, view.scrollDOM.scrollLeft),
};
if (
latestConfigEditorViewport?.namespace === viewport.namespace &&
latestConfigEditorViewport.dataId === viewport.dataId &&
latestConfigEditorViewport.group === viewport.group &&
latestConfigEditorViewport.scrollTop === viewport.scrollTop &&
latestConfigEditorViewport.scrollLeft === viewport.scrollLeft
) {
return false;
}
latestConfigEditorViewport = viewport;
return true;
}
function persistConfigEditorViewport() {
const viewport = latestConfigEditorViewport;
if (!viewport || !isCurrentConfigEditorViewport(viewport)) return;
queryStore.updateNacosConfigEditorViewport(props.connectionId, namespace.value, viewport);
}
function scheduleConfigEditorViewportCapture() {
if (!configEditorIsActive) return;
captureConfigEditorViewport();
if (configEditorViewportFrame !== null) return;
configEditorViewportFrame = requestAnimationFrame(() => {
configEditorViewportFrame = null;
persistConfigEditorViewport();
});
}
function flushConfigEditorViewport() {
if (configEditorViewportFrame !== null) {
cancelAnimationFrame(configEditorViewportFrame);
configEditorViewportFrame = null;
}
persistConfigEditorViewport();
}
function restoreConfigEditorViewport() {
const view = configEditorView.value;
const viewport = latestConfigEditorViewport && isCurrentConfigEditorViewport(latestConfigEditorViewport) ? latestConfigEditorViewport : storedConfigEditorViewport();
if (!view || !viewport) return;
const restore = () => {
if (configEditorView.value !== view) return;
view.scrollDOM.scrollTo({ top: viewport.scrollTop, left: viewport.scrollLeft });
view.scrollDOM.scrollTop = viewport.scrollTop;
view.scrollDOM.scrollLeft = viewport.scrollLeft;
};
restore();
nextTick(() => {
restore();
let attempts = 0;
const restoreNextFrame = () => {
restore();
attempts += 1;
if (attempts >= 8) {
configEditorViewportRestoreFrame = null;
return;
}
configEditorViewportRestoreFrame = requestAnimationFrame(restoreNextFrame);
};
if (configEditorViewportRestoreFrame !== null) cancelAnimationFrame(configEditorViewportRestoreFrame);
configEditorViewportRestoreFrame = requestAnimationFrame(restoreNextFrame);
});
}
async function refreshConfigEditor() {
destroyConfigEditor();
await mountConfigEditor();
@@ -829,6 +960,7 @@ async function selectConfig(item: NacosConfigItem) {
group: item.group,
};
selectedConfig.value = { ...item };
initializeConfigEditorViewport();
configContent.value = item.content || "";
configType.value = configFormatValue(item) || "text";
rememberOriginalConfigState(item, configContent.value, configType.value);
@@ -891,6 +1023,7 @@ function newConfig() {
};
configContent.value = "";
configType.value = selectedConfig.value.configType || "text";
initializeConfigEditorViewport();
rememberOriginalConfigState(selectedConfig.value, "", configType.value);
configAdvancedOpen.value = false;
void mountConfigEditor();
@@ -908,6 +1041,7 @@ function saveConfigAsCopy() {
configContent.value = copy.content || "";
originalConfigContent.value = "";
configType.value = copy.configType || configType.value || "text";
initializeConfigEditorViewport();
originalConfigType.value = configType.value;
originalConfigMetadata.value = {
appName: copy.appName || "",
@@ -2377,8 +2511,26 @@ onMounted(async () => {
await loadInfo();
await Promise.all([loadConfigsWithRetry(1), loadServicesWithRetry(1)]);
if (props.targetDataId) await openTargetConfig(props.targetDataId, props.targetGroup || "DEFAULT_GROUP", props.targetKeyword);
else await restoreSelectedConfig();
});
function pauseConfigEditorViewportWork() {
flushConfigEditorViewport();
configEditorIsActive = false;
if (configEditorViewportRestoreFrame !== null) {
cancelAnimationFrame(configEditorViewportRestoreFrame);
configEditorViewportRestoreFrame = null;
}
}
function resumeConfigEditorViewportWork() {
configEditorIsActive = true;
restoreConfigEditorViewport();
}
onActivated(resumeConfigEditorViewportWork);
onDeactivated(pauseConfigEditorViewportWork);
onBeforeUnmount(() => {
configListRequestGuard.invalidate();
configDetailRequestGuard.invalidate();
@@ -2396,6 +2548,7 @@ onBeforeUnmount(() => {
if (activeSearchOperationId.value) void api.nacosCancelConfigContentSearch(activeSearchOperationId.value);
configListResizeObserver?.disconnect();
configEditorZoomCommitScheduler.dispose();
pauseConfigEditorViewportWork();
destroyConfigEditor();
});
</script>
@@ -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<Record<string, unknown>>,
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<void>;
deleteSelectedConfigs: () => Promise<void>;
@@ -119,6 +126,7 @@ type NacosAdminSetupState = {
selectedConfig: NacosConfigItem | null;
selectedConfigKeys: string[];
selectedConfigOriginalKey: NacosConfigKey | null;
selectConfig: (item: NacosConfigItem) => Promise<void>;
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<void>((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<ComponentPublicInstance | null>(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");
});
});