feat(nacos): restore config editor selection and scroll
This commit is contained in:
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>
|
||||
|
||||
+91
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user