feat(nacos): improve config history and diff viewing

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-08 01:47:36 +08:00
1 parent 2e72bee9a1
commit eb87c9e837
18 files changed
+508 -167

No files matched your search

@@ -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>