feat(nacos): improve config history and diff viewing

This commit is contained in:
二丫讲梵
2026-09-08 01:47:36 +08:00
committed by GitHub
parent 2e72bee9a1
commit eb87c9e837
18 changed files with 508 additions and 167 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, shallowRef, useId, watch } from "vue";
import { Compartment, StateEffect, StateField, type Extension } from "@codemirror/state";
import { StreamLanguage, ensureSyntaxTree } from "@codemirror/language";
import { Compartment, StateEffect, StateField } from "@codemirror/state";
import { ensureSyntaxTree } from "@codemirror/language";
import { Decoration, EditorView } from "@codemirror/view";
import { Archive, ArrowLeftRight, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Columns3, Download, ExternalLink, FileClock, FileInput, FileText, Loader2, Maximize2, Minimize2, Network, Plus, RefreshCw, Save, Search, Send, Server, Trash2, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
@@ -59,6 +59,7 @@ import { executeWithProductionContextGuard } from "@/lib/database/productionExec
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { validateNacosConfigContent, type NacosConfigDiagnostic } from "@/lib/nacos/nacosConfigValidation";
import { loadNacosConfigLanguage, resolveNacosConfigFormat } from "@/lib/nacos/nacosConfigLanguage";
import type { NacosConfigEditorViewport } from "@/types/database";
import type {
NacosBatchPreview,
@@ -158,10 +159,12 @@ const historyPageSize = ref(20);
const historyTotal = ref(0);
const historyViewingItem = ref<NacosConfigHistoryItem | null>(null);
const historyViewingContent = ref("");
const historyViewingFormat = ref("text");
const historyViewingLoading = ref(false);
const historyCompareOpen = ref(false);
const historyCompareCurrent = ref("");
const historyCompareContent = ref("");
const historyCompareFormat = ref("text");
const historyCompareLoading = ref(false);
const historyCompareItem = ref<NacosConfigHistoryItem | null>(null);
const pendingHistoryRollback = ref<NacosConfigHistoryItem | null>(null);
@@ -501,37 +504,6 @@ function currentCustomThemeColors() {
return activeTheme?.colors ?? settings.customThemeColors;
}
async function configLanguageExtension(format: string): Promise<Extension[]> {
switch (format) {
case "json": {
const { json } = await import("@codemirror/lang-json");
return [json()];
}
case "yaml": {
const { yaml } = await import("@codemirror/lang-yaml");
return [yaml()];
}
case "xml": {
const { xml } = await import("@codemirror/lang-xml");
return [xml()];
}
case "html": {
const { html } = await import("@codemirror/lang-html");
return [html({ matchClosingTags: false })];
}
case "properties": {
const { properties } = await import("@codemirror/legacy-modes/mode/properties");
return [StreamLanguage.define(properties)];
}
case "toml": {
const { toml } = await import("@codemirror/legacy-modes/mode/toml");
return [StreamLanguage.define(toml)];
}
default:
return [];
}
}
function configValidationHighlightExtension() {
const decorationsFor = (state: import("@codemirror/state").EditorState, diagnostics: NacosConfigDiagnostic[]) => {
const ranges = diagnostics
@@ -577,7 +549,7 @@ async function mountConfigEditor() {
import("codemirror"),
import("@codemirror/commands"),
import("@codemirror/search"),
configLanguageExtension(format),
loadNacosConfigLanguage(format),
]);
const editorSettings = settingsStore.editorSettings;
configEditorFontSize.value = clampEditorFontSize(editorSettings.fontSize);
@@ -1680,9 +1652,11 @@ async function viewConfigHistory(item: NacosConfigHistoryItem) {
await nextTick();
historyViewingItem.value = item;
historyViewingContent.value = "";
historyViewingFormat.value = resolveNacosConfigFormat(item.configType, item.dataId);
historyViewingLoading.value = true;
const detail = await loadHistoryDetail(item);
historyViewingContent.value = detail?.content || "";
historyViewingFormat.value = resolveNacosConfigFormat(detail?.configType || item.configType, detail?.dataId || item.dataId);
historyViewingLoading.value = false;
}
@@ -1690,6 +1664,7 @@ function closeHistoryDetail() {
historyViewingItem.value = null;
historyViewingContent.value = "";
historyViewingLoading.value = false;
historyViewingFormat.value = "text";
}
async function compareConfigHistory(item: NacosConfigHistoryItem) {
@@ -1699,10 +1674,12 @@ async function compareConfigHistory(item: NacosConfigHistoryItem) {
historyCompareItem.value = item;
historyCompareCurrent.value = "";
historyCompareContent.value = "";
historyCompareFormat.value = resolveNacosConfigFormat(item.configType, item.dataId);
try {
const [current, history] = await Promise.all([api.nacosGetConfig(props.connectionId, selectedConfigOriginalKey.value), api.nacosGetConfigHistory(props.connectionId, historyKeyFor(item))]);
historyCompareCurrent.value = current.content || "";
historyCompareContent.value = history.content || "";
historyCompareFormat.value = resolveNacosConfigFormat(history.configType || item.configType || current.configType, history.dataId || item.dataId);
} catch (error) {
await handleRNacosConsoleError(error, () => compareConfigHistory(item), "history");
historyCompareOpen.value = false;
@@ -3341,7 +3318,7 @@ function openNacosConsole() {
</DialogContent>
</Dialog>
<NacosConfigDiffDialog v-model:open="pendingConfigSave" :before="originalConfigContent" :after="configContent" :loading="savingConfig" @confirm="saveConfig" />
<NacosConfigDiffDialog v-model:open="pendingConfigSave" :before="originalConfigContent" :after="configContent" :format="resolveNacosConfigFormat(configType, configDataId)" :loading="savingConfig" @confirm="saveConfig" />
<NacosContentSearchDialog
v-model:open="searchOpen"
@@ -3394,6 +3371,7 @@ function openNacosConsole() {
:read-only="readOnly"
:viewing-item="historyViewingItem"
:viewing-content="historyViewingContent"
:viewing-format="historyViewingFormat"
:viewing-loading="historyViewingLoading"
@load="loadConfigHistory"
@view="viewConfigHistory"
@@ -3433,6 +3411,7 @@ function openNacosConsole() {
:after-label="t('nacos.historyVersionContent')"
:before="historyCompareCurrent"
:after="historyCompareContent"
:format="historyCompareFormat"
:loading="historyCompareLoading"
:show-confirm="!readOnly"
:confirm-label="t('nacos.rollback')"
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { EditorState } from "@codemirror/state";
import { EditorView, lineNumbers } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
import { editorFontTheme, loadEditorTheme } from "@/lib/editor/editorThemes";
import { loadNacosConfigLanguage, resolveNacosConfigFormat } from "@/lib/nacos/nacosConfigLanguage";
const props = withDefaults(
defineProps<{
content: string;
format?: string;
dataId?: string;
lineNumbers?: boolean;
}>(),
{ format: "", dataId: "", lineNumbers: true },
);
const host = ref<HTMLElement | null>(null);
const settingsStore = useSettingsStore();
const { isDark, themePalette } = useTheme();
let view: EditorView | null = null;
let generation = 0;
function currentCustomThemeColors() {
const settings = settingsStore.editorSettings;
if (settings.theme !== "custom") return settings.customThemeColors;
const activeTheme = settings.customThemes?.find((theme) => theme.id === settings.activeCustomThemeId) || settings.customThemes?.[0];
return activeTheme?.colors ?? settings.customThemeColors;
}
async function mount() {
const target = host.value;
if (!target) return;
const currentGeneration = ++generation;
view?.destroy();
view = null;
const format = resolveNacosConfigFormat(props.format, props.dataId);
const [language, theme] = await Promise.all([loadNacosConfigLanguage(format), loadEditorTheme(settingsStore.editorSettings.theme, isDark.value ? "dark" : "light", currentCustomThemeColors(), themePalette.value)]);
if (currentGeneration !== generation || target !== host.value) return;
const state = EditorState.create({
doc: props.content,
extensions: [
basicSetup,
language,
theme,
editorFontTheme(EditorView, settingsStore.editorSettings.fontSize, settingsStore.editorSettings.fontFamily, { fixedHeight: true, scrollable: true }),
...(props.lineNumbers ? [lineNumbers()] : []),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.theme({
"&": { height: "100%", backgroundColor: "transparent" },
".cm-scroller": { overflow: "auto" },
".cm-content": { minHeight: "100%", userSelect: "text", WebkitUserSelect: "text" },
".cm-lineNumbers .cm-gutterElement": { padding: "0 10px 0 8px" },
}),
],
});
view = new EditorView({ parent: target, state });
}
onMounted(() => void mount());
watch(
() => [props.content, props.format, props.dataId, props.lineNumbers],
() => void mount(),
);
onBeforeUnmount(() => {
generation += 1;
view?.destroy();
view = null;
});
</script>
<template>
<div ref="host" class="nacos-config-code-viewer h-full min-h-0 overflow-hidden rounded-md border bg-background" />
</template>
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { Loader2 } from "@lucide/vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { ChevronDown, ChevronUp, Loader2, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { buildNacosInlineDiff, buildNacosSideBySideDiff, summarizeNacosConfigDiff, type NacosDiffLineType, type NacosInlineSegment } from "@/lib/nacos/nacosAdmin";
import NacosConfigDiffPane from "@/components/nacos/NacosConfigDiffPane.vue";
import { buildNacosInlineDiff, buildNacosSideBySideDiff, type NacosInlineDiffRow } from "@/lib/nacos/nacosAdmin";
const open = defineModel<boolean>("open", { default: false });
@@ -19,6 +20,7 @@ const props = withDefaults(
confirmVariant?: "default" | "destructive";
showConfirm?: boolean;
loading?: boolean;
format?: string;
}>(),
{
title: "",
@@ -28,6 +30,7 @@ const props = withDefaults(
confirmVariant: "default",
showConfirm: true,
loading: false,
format: "text",
},
);
@@ -39,7 +42,23 @@ const { t } = useI18n();
const inlineCompare = ref(false);
const rows = computed(() => buildNacosSideBySideDiff(props.before, props.after));
const inlineRows = computed(() => buildNacosInlineDiff(props.before, props.after));
const summary = computed(() => summarizeNacosConfigDiff(props.before, props.after));
const changedRows = computed(() => rows.value.map((row, index) => ({ row, index })).filter(({ row }) => row.leftType !== "equal" || row.rightType !== "equal"));
const diffStats = computed(() => {
let added = 0;
let removed = 0;
let modified = 0;
for (const { row } of changedRows.value) {
if (row.leftType === "modify" && row.rightType === "modify") modified += 1;
else if (row.leftType === "padding" && row.rightType === "insert") added += 1;
else if (row.leftType === "delete" && row.rightType === "padding") removed += 1;
}
return { added, removed, modified };
});
const activeChange = ref(0);
const activeRowIndex = computed(() => changedRows.value[activeChange.value]?.index ?? null);
const leftPane = ref<InstanceType<typeof NacosConfigDiffPane> | null>(null);
const rightPane = ref<InstanceType<typeof NacosConfigDiffPane> | null>(null);
let detachScrollSync: (() => void) | null = null;
const dialogOpen = computed({
get: () => open.value,
@@ -49,69 +68,29 @@ const dialogOpen = computed({
},
});
function lineClass(type: NacosDiffLineType, side: "left" | "right") {
function inlineRowClass(type: NacosInlineDiffRow["type"]) {
return {
"bg-red-500/20 text-red-50": type === "delete" || (type === "modify" && side === "left"),
"bg-emerald-500/18 text-emerald-50": type === "insert" || (type === "modify" && side === "right"),
"text-zinc-200": type === "equal",
"bg-zinc-900/80 text-zinc-600": type === "padding",
"bg-red-500/20 text-red-700 dark:text-red-50": type === "delete",
"bg-emerald-500/18 text-emerald-700 dark:text-emerald-50": type === "insert",
"text-foreground": type === "equal",
};
}
function gutterClass(type: NacosDiffLineType, side: "left" | "right") {
function inlineGutterClass(type: NacosInlineDiffRow["type"]) {
return {
"text-red-300": type === "delete" || (type === "modify" && side === "left"),
"text-emerald-300": type === "insert" || (type === "modify" && side === "right"),
"text-zinc-500": type === "equal" || type === "padding",
"text-red-600 dark:text-red-300": type === "delete",
"text-emerald-600 dark:text-emerald-300": type === "insert",
"text-muted-foreground": type === "equal",
};
}
function prefix(type: NacosDiffLineType, side: "left" | "right") {
if (type === "delete" || (type === "modify" && side === "left")) return "-";
if (type === "insert" || (type === "modify" && side === "right")) return "+";
return "";
}
function inlineSegments(content: string, segments: NacosInlineSegment[]) {
return segments.length ? segments : [{ value: content, changed: false }];
}
function inlineClass(type: NacosDiffLineType, changed: boolean) {
if (!changed) return "";
if (type === "delete" || type === "modify") return "nacos-inline-change rounded-[2px] bg-red-500/80 text-red-50";
if (type === "insert") return "nacos-inline-change rounded-[2px] bg-emerald-500/75 text-emerald-50";
return "";
}
function rightInlineClass(type: NacosDiffLineType, changed: boolean) {
if (!changed) return "";
if (type === "modify" || type === "insert") return "nacos-inline-change rounded-[2px] bg-emerald-500/75 text-emerald-50";
return inlineClass(type, changed);
}
function inlineRowClass(type: "equal" | "delete" | "insert") {
return {
"bg-red-500/20 text-red-50": type === "delete",
"bg-emerald-500/18 text-emerald-50": type === "insert",
"text-zinc-200": type === "equal",
};
}
function inlineGutterClass(type: "equal" | "delete" | "insert") {
return {
"text-red-300": type === "delete",
"text-emerald-300": type === "insert",
"text-zinc-500": type === "equal",
};
}
function inlinePrefix(type: "equal" | "delete" | "insert") {
function inlinePrefix(type: NacosInlineDiffRow["type"]) {
if (type === "delete") return "-";
if (type === "insert") return "+";
return "";
}
function inlineRowSegmentClass(type: "equal" | "delete" | "insert", changed: boolean) {
function inlineRowSegmentClass(type: NacosInlineDiffRow["type"], changed: boolean) {
if (!changed) return "";
if (type === "delete") return "nacos-inline-change rounded-[2px] bg-red-500/80 text-red-50";
if (type === "insert") return "nacos-inline-change rounded-[2px] bg-emerald-500/75 text-emerald-50";
@@ -122,6 +101,78 @@ function onConfirm() {
if (props.loading) return;
emit("confirm");
}
function bindScrollSync() {
detachScrollSync?.();
detachScrollSync = null;
const left = leftPane.value?.scrollerElement();
const right = rightPane.value?.scrollerElement();
if (!left || !right) return;
let syncing = false;
const sync = (source: HTMLElement, target: HTMLElement) => {
if (syncing) return;
syncing = true;
target.scrollTop = source.scrollTop;
target.scrollLeft = source.scrollLeft;
requestAnimationFrame(() => {
syncing = false;
});
};
const onLeft = () => sync(left, right);
const onRight = () => sync(right, left);
left.addEventListener("scroll", onLeft, { passive: true });
right.addEventListener("scroll", onRight, { passive: true });
detachScrollSync = () => {
left.removeEventListener("scroll", onLeft);
right.removeEventListener("scroll", onRight);
};
}
function onPaneReady() {
if (!open.value || inlineCompare.value) return;
bindScrollSync();
const rowIndex = activeRowIndex.value;
if (rowIndex != null) {
leftPane.value?.scrollToRow(rowIndex);
rightPane.value?.scrollToRow(rowIndex);
}
}
function navigateChange(direction: 1 | -1) {
if (!changedRows.value.length) return;
activeChange.value = (activeChange.value + direction + changedRows.value.length) % changedRows.value.length;
const rowIndex = changedRows.value[activeChange.value]?.index;
if (rowIndex == null) return;
leftPane.value?.scrollToRow(rowIndex);
rightPane.value?.scrollToRow(rowIndex);
}
watch(
[open, rows],
async () => {
activeChange.value = 0;
if (!open.value) {
detachScrollSync?.();
detachScrollSync = null;
return;
}
await nextTick();
bindScrollSync();
},
{ deep: true },
);
watch(inlineCompare, async (value) => {
if (value) {
detachScrollSync?.();
detachScrollSync = null;
return;
}
await nextTick();
bindScrollSync();
});
onBeforeUnmount(() => detachScrollSync?.());
</script>
<template>
@@ -130,47 +181,42 @@ function onConfirm() {
<DialogHeader class="shrink-0 border-b px-5 py-4">
<div class="flex items-center justify-between gap-4">
<DialogTitle class="text-lg font-semibold">{{ title || t("nacos.configDiffTitle") }}</DialogTitle>
<button type="button" class="text-2xl leading-none text-muted-foreground hover:text-foreground" :disabled="loading" @click="open = false">×</button>
<Button size="icon" variant="ghost" class="h-8 w-8 shrink-0" :disabled="loading" :aria-label="t('dangerDialog.cancel')" @click="open = false"><X class="h-4 w-4" /></Button>
</div>
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
<label class="inline-flex items-center gap-2 text-sm text-muted-foreground">
<input v-model="inlineCompare" type="checkbox" class="h-4 w-4 rounded border-border" />
<span>{{ t("nacos.inlineCompare") }}</span>
</label>
<div class="text-xs text-muted-foreground">{{ t("nacos.confirmSaveMessage", { added: summary.addedLines, removed: summary.removedLines }) }}</div>
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<span>{{ t("nacos.diffStats", diffStats) }}</span>
<div v-if="changedRows.length && !inlineCompare" class="inline-flex items-center gap-1">
<Button size="icon" variant="ghost" class="h-6 w-6" :title="t('nacos.previousDifference')" :aria-label="t('nacos.previousDifference')" @click="navigateChange(-1)"><ChevronUp class="h-3.5 w-3.5" /></Button>
<span>{{ activeChange + 1 }} / {{ changedRows.length }}</span>
<Button size="icon" variant="ghost" class="h-6 w-6" :title="t('nacos.nextDifference')" :aria-label="t('nacos.nextDifference')" @click="navigateChange(1)"><ChevronDown class="h-3.5 w-3.5" /></Button>
</div>
</div>
</div>
</DialogHeader>
<div v-if="!inlineCompare" class="grid min-h-0 flex-1 grid-cols-1 gap-4 bg-background px-5 py-4 lg:grid-cols-2">
<section class="flex min-w-0 min-h-0 flex-col">
<div class="mb-2 text-sm font-medium text-foreground">{{ beforeLabel || t("nacos.currentVersionContent") }}</div>
<div class="min-h-0 flex-1 overflow-auto rounded-sm border border-zinc-700 bg-[#1f1f1f] font-mono text-[13px] leading-6 text-zinc-200">
<div v-for="row in rows" :key="`${row.id}-left`" class="grid min-w-max grid-cols-[52px_22px_minmax(48rem,1fr)]" :class="lineClass(row.leftType, 'left')">
<span class="select-none border-r border-white/8 pr-2 text-right" :class="gutterClass(row.leftType, 'left')">{{ row.leftLineNumber ?? "" }}</span>
<span class="select-none pl-2" :class="gutterClass(row.leftType, 'left')">{{ prefix(row.leftType, "left") }}</span>
<pre class="whitespace-pre px-2"><template v-for="(segment, index) in inlineSegments(row.leftContent, row.leftInline)" :key="index"><span :class="inlineClass(row.leftType, segment.changed)">{{ segment.value }}</span></template></pre>
</div>
</div>
<NacosConfigDiffPane ref="leftPane" :rows="rows" side="left" :format="format" :active-row="activeRowIndex" class="min-h-0 flex-1" @ready="onPaneReady" />
</section>
<section class="flex min-w-0 min-h-0 flex-col">
<div class="mb-2 text-sm font-medium text-foreground">{{ afterLabel || t("nacos.publishVersionContent") }}</div>
<div class="min-h-0 flex-1 overflow-auto rounded-sm border border-zinc-700 bg-[#1f1f1f] font-mono text-[13px] leading-6 text-zinc-200">
<div v-for="row in rows" :key="`${row.id}-right`" class="grid min-w-max grid-cols-[52px_22px_minmax(48rem,1fr)]" :class="lineClass(row.rightType, 'right')">
<span class="select-none border-r border-white/8 pr-2 text-right" :class="gutterClass(row.rightType, 'right')">{{ row.rightLineNumber ?? "" }}</span>
<span class="select-none pl-2" :class="gutterClass(row.rightType, 'right')">{{ prefix(row.rightType, "right") }}</span>
<pre class="whitespace-pre px-2"><template v-for="(segment, index) in inlineSegments(row.rightContent, row.rightInline)" :key="index"><span :class="rightInlineClass(row.rightType, segment.changed)">{{ segment.value }}</span></template></pre>
</div>
</div>
<NacosConfigDiffPane ref="rightPane" :rows="rows" side="right" :format="format" :active-row="activeRowIndex" class="min-h-0 flex-1" @ready="onPaneReady" />
</section>
</div>
<div v-else class="min-h-0 flex-1 bg-background px-5 py-4">
<section class="flex h-full min-h-0 flex-col">
<div class="mb-2 text-sm font-medium text-foreground">{{ t("nacos.inlineCompare") }}</div>
<div class="min-h-0 flex-1 overflow-auto rounded-sm border border-zinc-700 bg-[#1f1f1f] font-mono text-[13px] leading-6 text-zinc-200">
<div class="min-h-0 flex-1 overflow-auto rounded-sm border bg-background font-mono text-[13px] leading-6 text-foreground">
<div v-for="row in inlineRows" :key="row.id" class="grid min-w-max grid-cols-[52px_22px_minmax(96rem,1fr)]" :class="inlineRowClass(row.type)">
<span class="select-none border-r border-white/8 pr-2 text-right" :class="inlineGutterClass(row.type)">{{ row.lineNumber ?? "" }}</span>
<span class="select-none border-r border-border pr-2 text-right" :class="inlineGutterClass(row.type)">{{ row.lineNumber ?? "" }}</span>
<span class="select-none pl-2" :class="inlineGutterClass(row.type)">{{ inlinePrefix(row.type) }}</span>
<pre class="whitespace-pre px-2"><template v-for="(segment, index) in row.segments" :key="index"><span :class="inlineRowSegmentClass(row.type, segment.changed)">{{ segment.value }}</span></template></pre>
</div>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Decoration, EditorView, lineNumbers, type DecorationSet } from "@codemirror/view";
import { EditorState, StateEffect, StateField, type Range } from "@codemirror/state";
import { basicSetup } from "codemirror";
import { editorFontTheme, loadEditorTheme } from "@/lib/editor/editorThemes";
import { loadNacosConfigLanguage } from "@/lib/nacos/nacosConfigLanguage";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
import type { NacosSideBySideDiffRow } from "@/lib/nacos/nacosAdmin";
const props = defineProps<{
rows: NacosSideBySideDiffRow[];
side: "left" | "right";
format: string;
activeRow?: number | null;
}>();
const emit = defineEmits<{
ready: [];
}>();
const host = ref<HTMLElement | null>(null);
let view: EditorView | null = null;
let generation = 0;
const settingsStore = useSettingsStore();
const { isDark, themePalette } = useTheme();
const setDecorations = StateEffect.define<DecorationSet>();
function currentCustomThemeColors() {
const settings = settingsStore.editorSettings;
if (settings.theme !== "custom") return settings.customThemeColors;
const activeTheme = settings.customThemes?.find((theme) => theme.id === settings.activeCustomThemeId) || settings.customThemes?.[0];
return activeTheme?.colors ?? settings.customThemeColors;
}
function lineContent(row: NacosSideBySideDiffRow) {
return props.side === "left" ? row.leftContent : row.rightContent;
}
function lineType(row: NacosSideBySideDiffRow) {
return props.side === "left" ? row.leftType : row.rightType;
}
function lineNumber(row?: NacosSideBySideDiffRow) {
if (!row) return null;
return props.side === "left" ? row.leftLineNumber : row.rightLineNumber;
}
function inlineSegments(row: NacosSideBySideDiffRow) {
return props.side === "left" ? row.leftInline : row.rightInline;
}
function buildDecorations(state: EditorState) {
const ranges: Range<Decoration>[] = [];
props.rows.forEach((row, index) => {
const line = state.doc.line(index + 1);
const type = lineType(row);
const changeClass = type === "delete" || (type === "modify" && props.side === "left") ? "nacos-diff-line-delete" : type === "insert" || (type === "modify" && props.side === "right") ? "nacos-diff-line-insert" : type === "padding" ? "nacos-diff-line-padding" : "";
const baseClass = changeClass;
const classes = [baseClass, props.activeRow === index ? "nacos-diff-line-active" : ""].filter(Boolean).join(" ");
if (classes) ranges.push(Decoration.line({ attributes: { class: classes } }).range(line.from));
let offset = line.from;
for (const segment of inlineSegments(row)) {
if (segment.changed && segment.value) {
const charClass = type === "delete" || (type === "modify" && props.side === "left") ? "nacos-diff-char-delete" : "nacos-diff-char-insert";
ranges.push(Decoration.mark({ class: charClass }).range(offset, offset + segment.value.length));
}
offset += segment.value.length;
}
});
return Decoration.set(ranges, true);
}
const decorationsField = StateField.define<DecorationSet>({
create: (state) => buildDecorations(state),
update: (decorations, transaction) => {
for (const effect of transaction.effects) {
if (effect.is(setDecorations)) return effect.value;
}
return decorations.map(transaction.changes);
},
provide: (field) => EditorView.decorations.from(field),
});
async function mount() {
const target = host.value;
if (!target) return;
const currentGeneration = ++generation;
view?.destroy();
view = null;
const [language, theme] = await Promise.all([loadNacosConfigLanguage(props.format), loadEditorTheme(settingsStore.editorSettings.theme, isDark.value ? "dark" : "light", currentCustomThemeColors(), themePalette.value)]);
if (currentGeneration !== generation || target !== host.value) return;
const content = props.rows.map(lineContent).join("\n");
const state = EditorState.create({
doc: content,
extensions: [
basicSetup,
language,
theme,
editorFontTheme(EditorView, settingsStore.editorSettings.fontSize, settingsStore.editorSettings.fontFamily, { fixedHeight: true, scrollable: true }),
lineNumbers({ formatNumber: (number) => String(lineNumber(props.rows[number - 1]) ?? "") }),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
decorationsField,
EditorView.theme({
"&": { height: "100%", backgroundColor: "transparent" },
".cm-scroller": { overflow: "auto" },
".cm-content": { minHeight: "100%", userSelect: "text", WebkitUserSelect: "text" },
".cm-line": { padding: "0 8px" },
".cm-lineNumbers .cm-gutterElement": { padding: "0 10px 0 8px" },
}),
],
});
view = new EditorView({ parent: target, state });
emit("ready");
}
function scrollToRow(index: number) {
if (!view || index < 0 || index >= props.rows.length) return;
view.dispatch({ effects: EditorView.scrollIntoView(view.state.doc.line(index + 1).from, { y: "center" }) });
}
function scrollerElement() {
return view?.scrollDOM;
}
defineExpose({ scrollToRow, scrollerElement });
onMounted(() => void mount());
watch(
() => [props.rows, props.format],
() => void mount(),
{ deep: true },
);
watch(
() => props.activeRow,
() => {
if (!view) return;
view.dispatch({ effects: setDecorations.of(buildDecorations(view.state)) });
},
);
onBeforeUnmount(() => {
generation += 1;
view?.destroy();
view = null;
});
</script>
<template>
<div ref="host" class="nacos-config-diff-pane h-full min-h-0 overflow-hidden rounded-sm border bg-background text-[13px] leading-6 text-foreground" />
</template>
<style>
.nacos-config-diff-pane .nacos-diff-line-delete {
background: rgb(220 38 38 / 0.2);
}
.nacos-config-diff-pane .nacos-diff-line-insert {
background: rgb(22 163 74 / 0.2);
}
.nacos-config-diff-pane .nacos-diff-line-padding {
background: rgb(113 113 122 / 0.15);
}
.dark .nacos-config-diff-pane .nacos-diff-line-padding {
background: rgb(24 24 27 / 0.6);
}
.nacos-config-diff-pane .nacos-diff-line-active {
box-shadow: inset 3px 0 0 rgb(250 204 21 / 0.95);
}
.nacos-config-diff-pane .nacos-diff-char-delete {
background: rgb(248 113 113 / 0.65);
}
.nacos-config-diff-pane .nacos-diff-char-insert {
background: rgb(74 222 128 / 0.6);
}
</style>
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { formatNacosHistoryTime } from "@/lib/nacos/nacosAdmin";
import NacosConfigCodeViewer from "@/components/nacos/NacosConfigCodeViewer.vue";
import type { NacosConfigHistoryItem, NacosConfigItem } from "@/types/nacos";
const open = defineModel<boolean>("open", { default: false });
@@ -22,6 +23,7 @@ const props = withDefaults(
readOnly?: boolean;
viewingItem?: NacosConfigHistoryItem | null;
viewingContent?: string;
viewingFormat?: string;
viewingLoading?: boolean;
}>(),
{
@@ -31,6 +33,7 @@ const props = withDefaults(
readOnly: false,
viewingItem: null,
viewingContent: "",
viewingFormat: "",
viewingLoading: false,
},
);
@@ -208,7 +211,7 @@ function loadPage(pageNo: number) {
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ t("nacos.loadingHistory") }}
</div>
<pre v-else class="min-h-full rounded-md border bg-background p-3 font-mono text-xs leading-5">{{ viewingContent || "" }}</pre>
<NacosConfigCodeViewer v-else :content="viewingContent || ''" :format="viewingFormat" :data-id="viewingItem?.dataId" class="min-h-full" />
</div>
<DialogFooter class="m-0 shrink-0 rounded-none border-t bg-background px-5 py-5 sm:py-4">
<Button variant="outline" @click="detailOpen = false">{{ t("dangerDialog.cancel") }}</Button>
@@ -227,9 +230,4 @@ function loadPage(pageNo: number) {
width: min(92vw, 1180px) !important;
max-width: min(92vw, 1180px) !important;
}
.nacos-config-history-detail-dialog pre {
min-width: max-content;
white-space: pre;
}
</style>
+3 -1
View File
@@ -9328,10 +9328,12 @@ export default {
template: "Template",
configDiffTitle: "Config Content Compare",
inlineCompare: "Inline compare",
diffStats: "+{added} / -{removed} / ~{modified} lines",
previousDifference: "Previous difference",
nextDifference: "Next difference",
currentVersionContent: "Current official version:",
publishVersionContent: "This release content:",
confirmSaveTitle: "Review Nacos config changes",
confirmSaveMessage: "Save this config change? +{added} / -{removed} changed lines.",
confirmDeleteTitle: "Delete Nacos config",
confirmDeleteMessage: "This removes the selected config from Nacos.",
confirmInstanceTitle: "Update Nacos instance",
+3 -1
View File
@@ -7618,10 +7618,12 @@ export default withEnglishFallback({
template: "Plantilla",
configDiffTitle: "Comparar contenido de configuración",
inlineCompare: "Comparación en línea",
diffStats: "+{added} / -{removed} / ~{modified} líneas",
previousDifference: "Diferencia anterior",
nextDifference: "Siguiente diferencia",
currentVersionContent: "Versión oficial actual:",
publishVersionContent: "Contenido de esta publicación:",
confirmSaveTitle: "Revisar cambios de configuración Nacos",
confirmSaveMessage: "¿Guardar este cambio? +{added} / -{removed} líneas.",
confirmDeleteTitle: "Eliminar configuración Nacos",
confirmDeleteMessage: "Esto elimina la configuración seleccionada de Nacos.",
confirmInstanceTitle: "Actualizar instancia Nacos",
+3 -1
View File
@@ -7619,10 +7619,12 @@ export default withEnglishFallback({
template: "Template",
configDiffTitle: "Confronto contenuto configurazione",
inlineCompare: "Confronto inline",
diffStats: "+{added} / -{removed} / ~{modified} righe",
previousDifference: "Differenza precedente",
nextDifference: "Differenza successiva",
currentVersionContent: "Versione ufficiale attuale:",
publishVersionContent: "Contenuto di questa pubblicazione:",
confirmSaveTitle: "Rivedi modifiche configurazione Nacos",
confirmSaveMessage: "Salvare questa modifica? +{added} / -{removed} righe.",
confirmDeleteTitle: "Elimina configurazione Nacos",
confirmDeleteMessage: "Questo rimuove la configurazione selezionata da Nacos.",
confirmInstanceTitle: "Aggiorna istanza Nacos",
+3 -1
View File
@@ -7658,10 +7658,12 @@ export default withEnglishFallback({
template: "テンプレート",
configDiffTitle: "設定内容の比較",
inlineCompare: "行内比較",
diffStats: "+{added} / -{removed} / ~{modified} 行",
previousDifference: "前の差分",
nextDifference: "次の差分",
currentVersionContent: "現在の正式バージョン:",
publishVersionContent: "今回の公開内容:",
confirmSaveTitle: "Nacos設定の変更を確認",
confirmSaveMessage: "この設定変更を保存しますか? +{added} / -{removed} 行。",
confirmDeleteTitle: "Nacos設定を削除",
confirmDeleteMessage: "選択した設定をNacosから削除します。",
confirmInstanceTitle: "Nacosインスタンスを更新",
+3 -1
View File
@@ -8428,10 +8428,12 @@ export default withEnglishFallback({
template: "템플릿",
configDiffTitle: "구성 내용 비교",
inlineCompare: "인라인 비교",
diffStats: "+{added} / -{removed} / ~{modified}줄",
previousDifference: "이전 차이",
nextDifference: "다음 차이",
currentVersionContent: "현재 공식 버전:",
publishVersionContent: "이번 릴리스 내용:",
confirmSaveTitle: "Nacos 구성 변경 검토",
confirmSaveMessage: "이 구성 변경을 저장하시겠습니까? +{added} / -{removed}줄 변경.",
confirmDeleteTitle: "Nacos 구성 삭제",
confirmDeleteMessage: "선택한 구성을 Nacos에서 제거합니다.",
confirmInstanceTitle: "Nacos 인스턴스 업데이트",
+3 -1
View File
@@ -7620,10 +7620,12 @@ export default withEnglishFallback({
template: "Modelo",
configDiffTitle: "Comparar conteúdo da configuração",
inlineCompare: "Comparação inline",
diffStats: "+{added} / -{removed} / ~{modified} linhas",
previousDifference: "Diferença anterior",
nextDifference: "Próxima diferença",
currentVersionContent: "Versão oficial atual:",
publishVersionContent: "Conteúdo desta publicação:",
confirmSaveTitle: "Revisar alterações da configuração Nacos",
confirmSaveMessage: "Salvar esta alteração? +{added} / -{removed} linhas.",
confirmDeleteTitle: "Excluir configuração Nacos",
confirmDeleteMessage: "Isso remove a configuração selecionada do Nacos.",
confirmInstanceTitle: "Atualizar instância Nacos",
+3 -1
View File
@@ -9195,10 +9195,12 @@ export default withEnglishFallback({
template: "Şablon",
configDiffTitle: "Yapılandırma İçeriği Karşılaştırması",
inlineCompare: "Satır içi karşılaştırma",
diffStats: "+{added} / -{removed} / ~{modified} satır",
previousDifference: "Önceki fark",
nextDifference: "Sonraki fark",
currentVersionContent: "Geçerli resmî sürüm:",
publishVersionContent: "Bu yayının içeriği:",
confirmSaveTitle: "Nacos yapılandırma değişikliklerini incele",
confirmSaveMessage: "Bu yapılandırma değişikliği kaydedilsin mi? +{added} / -{removed} satır değişti.",
confirmDeleteTitle: "Nacos yapılandırmasını sil",
confirmDeleteMessage: "Bu, seçili yapılandırmayı Nacos'tan kaldırır.",
confirmInstanceTitle: "Nacos örneğini güncelle",
+3 -1
View File
@@ -9307,10 +9307,12 @@ export default withEnglishFallback({
template: "模板",
configDiffTitle: "配置内容对比",
inlineCompare: "行内对比",
diffStats: "新增 {added} / 删除 {removed} / 修改 {modified} 行",
previousDifference: "上一个差异",
nextDifference: "下一个差异",
currentVersionContent: "当前正式版本内容:",
publishVersionContent: "本次发布内容:",
confirmSaveTitle: "确认保存 Nacos 配置",
confirmSaveMessage: "是否保存这次配置变更?新增 {added} 行,删除 {removed} 行。",
confirmDeleteTitle: "删除 Nacos 配置",
confirmDeleteMessage: "该操作会从 Nacos 删除所选配置。",
confirmInstanceTitle: "更新 Nacos 实例",
+3 -1
View File
@@ -6923,10 +6923,12 @@ export default withEnglishFallback({
template: "模板",
configDiffTitle: "配置內容對比",
inlineCompare: "行內對比",
diffStats: "新增 {added} / 刪除 {removed} / 修改 {modified} 行",
previousDifference: "上一個差異",
nextDifference: "下一個差異",
currentVersionContent: "目前正式版本內容:",
publishVersionContent: "本次發布內容:",
confirmSaveTitle: "確認儲存 Nacos 配置",
confirmSaveMessage: "是否儲存這次配置變更?新增 {added} 行,刪除 {removed} 行。",
confirmDeleteTitle: "刪除 Nacos 配置",
confirmDeleteMessage: "此操作會從 Nacos 刪除所選配置。",
confirmInstanceTitle: "更新 Nacos 實例",
-16
View File
@@ -34,7 +34,6 @@ import {
resolveNacosConsoleUrl,
sanitizeNacosConfigFileNameSegment,
splitNacosContentLiteralMatches,
summarizeNacosConfigDiff,
} from "@/lib/nacos/nacosAdmin";
describe("nacosAdmin helpers", () => {
@@ -187,21 +186,6 @@ describe("nacosAdmin helpers", () => {
expect(resolveRNacosOpenApiFallback("http://rnacos.example:8848", "/nacos")).toBeNull();
});
it("summarizes config diffs", () => {
const diff = summarizeNacosConfigDiff("a\nb", "a\nc\nd");
expect(diff.changed).toBe(true);
expect(diff.removedLines).toBe(1);
expect(diff.addedLines).toBe(2);
expect(diff.preview).toContain("- b");
expect(diff.preview).toContain("+ c");
});
it("uses the same terminal newline semantics for diff summaries", () => {
expect(summarizeNacosConfigDiff("aa", "aa\nbb")).toMatchObject({ changed: true, addedLines: 1, removedLines: 0 });
expect(summarizeNacosConfigDiff("aa", "aa\n")).toEqual({ changed: false, addedLines: 0, removedLines: 0, preview: "No content changes." });
expect(summarizeNacosConfigDiff("aa\r\n", "aa\n")).toEqual({ changed: false, addedLines: 0, removedLines: 0, preview: "No content changes." });
});
it("builds side-by-side config diff rows with inline segments", () => {
const rows = buildNacosSideBySideDiff('cloud:\n secret: "aaa"\n', 'cloud:\n secret: "aaa1"\n enabled: true\n');
expect(rows[0]).toMatchObject({ leftLineNumber: 1, rightLineNumber: 1, leftType: "equal", rightType: "equal" });
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { inferNacosConfigFormat, normalizeNacosConfigFormat, resolveNacosConfigFormat } from "../nacosConfigLanguage";
describe("Nacos config format helpers", () => {
it("normalizes config type aliases and trims or lowercases raw values", () => {
expect(normalizeNacosConfigFormat()).toBe("");
expect(normalizeNacosConfigFormat(" ")).toBe("");
expect(normalizeNacosConfigFormat("txt")).toBe("text");
expect(normalizeNacosConfigFormat("YML")).toBe("yaml");
expect(normalizeNacosConfigFormat("Props")).toBe("properties");
expect(normalizeNacosConfigFormat(" JSON ")).toBe("json");
expect(normalizeNacosConfigFormat("toml")).toBe("toml");
});
it("infers formats from dataId extensions with aliases and unknown fallback", () => {
expect(inferNacosConfigFormat("app.yml")).toBe("yaml");
expect(inferNacosConfigFormat("app.yaml")).toBe("yaml");
expect(inferNacosConfigFormat("app.TXT")).toBe("text");
expect(inferNacosConfigFormat("app.properties")).toBe("properties");
expect(inferNacosConfigFormat("app.json")).toBe("json");
expect(inferNacosConfigFormat("app.xml")).toBe("xml");
expect(inferNacosConfigFormat("app.html")).toBe("html");
expect(inferNacosConfigFormat("app.toml")).toBe("toml");
expect(inferNacosConfigFormat("app.text")).toBe("text");
expect(inferNacosConfigFormat("docker.jar")).toBe("");
expect(inferNacosConfigFormat("no-extension")).toBe("");
expect(inferNacosConfigFormat()).toBe("");
});
it("prefers normalized config type, then dataId inference, then text", () => {
expect(resolveNacosConfigFormat("yaml", "app.json")).toBe("yaml");
expect(resolveNacosConfigFormat("YML", "")).toBe("yaml");
expect(resolveNacosConfigFormat("", "app.yml")).toBe("yaml");
expect(resolveNacosConfigFormat(undefined, "app")).toBe("text");
expect(resolveNacosConfigFormat("", "")).toBe("text");
});
});
-29
View File
@@ -565,13 +565,6 @@ export function createNacosSaveAsCopy(item: NacosConfigItem): NacosConfigItem {
};
}
export interface NacosDiffSummary {
changed: boolean;
addedLines: number;
removedLines: number;
preview: string;
}
export type NacosDiffLineType = "equal" | "delete" | "insert" | "modify" | "padding";
export interface NacosInlineSegment {
@@ -599,28 +592,6 @@ export interface NacosInlineDiffRow {
segments: NacosInlineSegment[];
}
export function summarizeNacosConfigDiff(before: string, after: string, maxPreviewLines = 40): NacosDiffSummary {
const changes = diffArrays(splitDiffLines(before), splitDiffLines(after));
const lines: string[] = [];
let addedLines = 0;
let removedLines = 0;
for (const change of changes) {
if (!change.added && !change.removed) continue;
const prefix = change.added ? "+" : "-";
for (const line of change.value) {
if (change.added) addedLines += 1;
else removedLines += 1;
if (lines.length < maxPreviewLines) lines.push(`${prefix} ${line}`);
}
}
const changed = addedLines > 0 || removedLines > 0;
if (!changed) return { changed: false, addedLines: 0, removedLines: 0, preview: "No content changes." };
if (lines.length < addedLines + removedLines) lines.push("...");
return { changed: true, addedLines, removedLines, preview: lines.join("\n") };
}
function normalizeNacosDiffText(value: string): string {
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
@@ -0,0 +1,54 @@
import { StreamLanguage } from "@codemirror/language";
import type { Extension } from "@codemirror/state";
export function normalizeNacosConfigFormat(value?: string): string {
const normalized = value?.trim().toLowerCase();
if (!normalized) return "";
if (normalized === "txt") return "text";
if (normalized === "yml") return "yaml";
if (normalized === "props") return "properties";
return normalized;
}
export function inferNacosConfigFormat(dataId?: string): string {
const ext = dataId?.trim().toLowerCase().split(".").pop() || "";
if (ext === "yml") return "yaml";
if (["yaml", "json", "xml", "html", "properties", "toml", "text"].includes(ext)) return ext;
if (ext === "txt") return "text";
return "";
}
export function resolveNacosConfigFormat(configType?: string, dataId?: string): string {
return normalizeNacosConfigFormat(configType) || inferNacosConfigFormat(dataId) || "text";
}
export async function loadNacosConfigLanguage(format: string): Promise<Extension[]> {
switch (normalizeNacosConfigFormat(format)) {
case "json": {
const { json } = await import("@codemirror/lang-json");
return [json()];
}
case "yaml": {
const { yaml } = await import("@codemirror/lang-yaml");
return [yaml()];
}
case "xml": {
const { xml } = await import("@codemirror/lang-xml");
return [xml()];
}
case "html": {
const { html } = await import("@codemirror/lang-html");
return [html({ matchClosingTags: false })];
}
case "properties": {
const { properties } = await import("@codemirror/legacy-modes/mode/properties");
return [StreamLanguage.define(properties)];
}
case "toml": {
const { toml } = await import("@codemirror/legacy-modes/mode/toml");
return [StreamLanguage.define(toml)];
}
default:
return [];
}
}