feat(nacos): enhance configuration management

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-26 11:20:05 +08:00
1 parent 406b7acdbb
commit 1ebc3fd12b
52 files changed
+7139 -263

No files matched your search

+250
View File
@@ -1,19 +1,31 @@
import { describe, expect, it } from "vitest";
import {
buildNacosContentSearchCsv,
buildNacosConfigExportFileName,
buildNacosConfigDeleteConfirm,
buildNacosInlineDiff,
buildNacosInstanceConfirm,
buildNacosRawRequest,
buildNacosSideBySideDiff,
canDeleteNacosConfig,
canStartNacosConfigDelete,
canStartNacosConfigSave,
createNacosConfigDeleteSnapshot,
createNacosConfigSaveSnapshot,
createNacosLatestRequestGuard,
isNacosRawMutation,
isNacosErrorCode,
isNacosConfigSaveSnapshotCurrent,
isNacosConfigDeleteSnapshotInScope,
nacosConfigFileExtension,
parseNacosRawBody,
parseNacosRawQuery,
normalizeNacosEndpoint,
resolveRNacosOpenApiFallback,
resolveNacosConfigCopyText,
resolveNacosConfigSaveCompletion,
sanitizeNacosConfigFileNameSegment,
splitNacosContentLiteralMatches,
summarizeNacosConfigDiff,
} from "@/lib/nacos/nacosAdmin";
@@ -49,6 +61,12 @@ describe("nacosAdmin helpers", () => {
expect(isNacosRawMutation("DELETE")).toBe(true);
});
it("recognizes structured Nacos errors without matching unrelated failures", () => {
expect(isNacosErrorCode(new Error("NACOS_ERROR[stalePreview]: preview again"), "stalePreview")).toBe(true);
expect(isNacosErrorCode("NACOS_ERROR[authFailed]: forbidden", "stalePreview")).toBe(false);
expect(isNacosErrorCode(new Error("stalePreview"), "stalePreview")).toBe(false);
});
it("redirects r-nacos console settings to the compatible OpenAPI endpoint", () => {
expect(resolveRNacosOpenApiFallback("http://rnacos.example:10848", "/rnacos")).toEqual({
serverAddr: "http://rnacos.example:8848",
@@ -113,4 +131,236 @@ describe("nacosAdmin helpers", () => {
expect(resolveNacosConfigCopyText("", "editor", "state")).toBe("editor");
expect(resolveNacosConfigCopyText("", "", "state")).toBe("state");
});
it("rejects stale config detail requests after a newer selection or invalidation", () => {
const guard = createNacosLatestRequestGuard();
const first = guard.begin();
const second = guard.begin();
expect(guard.isCurrent(first)).toBe(false);
expect(guard.isCurrent(second)).toBe(true);
guard.invalidate();
expect(guard.isCurrent(second)).toBe(false);
});
it("keeps a late A detail response from replacing a newer B selection", async () => {
const guard = createNacosLatestRequestGuard();
let resolveA!: (value: string) => void;
let resolveB!: (value: string) => void;
const responseA = new Promise<string>((resolve) => {
resolveA = resolve;
});
const responseB = new Promise<string>((resolve) => {
resolveB = resolve;
});
let selected = "";
const load = async (response: Promise<string>) => {
const requestId = guard.begin();
const detail = await response;
if (guard.isCurrent(requestId)) selected = detail;
};
const loadingA = load(responseA);
const loadingB = load(responseB);
resolveB("B");
await loadingB;
resolveA("A");
await loadingA;
expect(selected).toBe("B");
});
it("keeps save payloads immutable and applies them only to the unchanged editor session", () => {
const editedConfig = {
namespace: "dev",
dataId: "application.yaml",
group: "DEFAULT_GROUP",
configType: "yaml",
appName: "gateway",
desc: "published settings",
tags: "prod",
};
const snapshot = createNacosConfigSaveSnapshot({
requestId: 4,
editorSessionId: 9,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" },
config: editedConfig,
content: "server:\n port: 8080",
configType: "yaml",
});
editedConfig.desc = "changed after publish started";
expect(snapshot.config.desc).toBe("published settings");
expect(snapshot.content).toBe("server:\n port: 8080");
expect(
isNacosConfigSaveSnapshotCurrent(snapshot, {
latestRequestId: 4,
editorSessionId: 9,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" },
config: { ...snapshot.config },
content: snapshot.content,
configType: snapshot.configType,
}),
).toBe(true);
});
it("does not apply a completed save to another selection or to later edits", () => {
const snapshot = createNacosConfigSaveSnapshot({
requestId: 1,
editorSessionId: 3,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
config: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP", desc: "A" },
content: "value: A",
configType: "yaml",
});
const matchingState = {
latestRequestId: 1,
editorSessionId: 3,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
config: { ...snapshot.config },
content: snapshot.content,
configType: snapshot.configType,
};
expect(isNacosConfigSaveSnapshotCurrent(snapshot, { ...matchingState, editorSessionId: 4, config: { ...snapshot.config, dataId: "b.yaml" } })).toBe(false);
expect(isNacosConfigSaveSnapshotCurrent(snapshot, { ...matchingState, content: "value: edited while saving" })).toBe(false);
expect(isNacosConfigSaveSnapshotCurrent(snapshot, { ...matchingState, latestRequestId: 2 })).toBe(false);
});
it("advances the published baseline without overwriting edits made while saving", () => {
const originalContent = "value: O";
const publishedContent = "value: S";
const laterContent = "value: T";
const snapshot = createNacosConfigSaveSnapshot({
requestId: 7,
editorSessionId: 12,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
config: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP", desc: "snapshot metadata" },
content: publishedContent,
configType: "yaml",
});
const completion = resolveNacosConfigSaveCompletion(snapshot, {
latestRequestId: 7,
editorSessionId: 12,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
config: { ...snapshot.config, desc: "edited metadata" },
content: laterContent,
configType: "yaml",
});
expect(completion.kind).toBe("saved-with-later-edits");
if (completion.kind === "stale") throw new Error("expected a relevant save completion");
expect(completion.baseline.content).toBe(publishedContent);
expect(laterContent).not.toBe(completion.baseline.content);
expect(originalContent).not.toBe(completion.baseline.content);
});
it("keeps a renamed draft separate from the identity published by an in-flight save", () => {
const snapshot = createNacosConfigSaveSnapshot({
requestId: 8,
editorSessionId: 13,
connectionId: "nacos-a",
originalKey: null,
config: { namespace: "dev", dataId: "draft-a.yaml", group: "DEFAULT_GROUP" },
content: "value: S",
configType: "yaml",
});
const completion = resolveNacosConfigSaveCompletion(snapshot, {
latestRequestId: 8,
editorSessionId: 13,
connectionId: "nacos-a",
originalKey: null,
config: { ...snapshot.config, dataId: "draft-b.yaml" },
content: "value: T",
configType: "yaml",
});
expect(completion).toEqual({ kind: "stale" });
});
it("never allows a draft or read-only config to enter the delete flow", () => {
const publishedKey = { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" };
expect(canDeleteNacosConfig(false, publishedKey)).toBe(true);
expect(canDeleteNacosConfig(false, null)).toBe(false);
expect(canDeleteNacosConfig(true, publishedKey)).toBe(false);
});
it("keeps save and delete mutations mutually exclusive in both directions", () => {
const idle = {
readOnly: false,
saving: false,
deleting: false,
hasPendingDelete: false,
hasPendingSave: false,
};
const publishedKey = { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" };
expect(canStartNacosConfigSave(idle)).toBe(true);
expect(canStartNacosConfigSave({ ...idle, saving: true })).toBe(false);
expect(canStartNacosConfigSave({ ...idle, deleting: true })).toBe(false);
expect(canStartNacosConfigSave({ ...idle, hasPendingDelete: true })).toBe(false);
expect(canStartNacosConfigDelete(idle, publishedKey)).toBe(true);
expect(canStartNacosConfigDelete({ ...idle, saving: true }, publishedKey)).toBe(false);
expect(canStartNacosConfigDelete({ ...idle, deleting: true }, publishedKey)).toBe(false);
expect(canStartNacosConfigDelete({ ...idle, hasPendingSave: true }, publishedKey)).toBe(false);
expect(canStartNacosConfigDelete({ ...idle, hasPendingDelete: true }, publishedKey)).toBe(false);
});
it("freezes delete confirmation scope and never redirects it to a newly selected connection", async () => {
const key = { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" };
const config = { ...key, desc: "old connection config" };
const snapshot = createNacosConfigDeleteSnapshot("old-connection", key, config);
key.dataId = "mutated.yaml";
config.desc = "mutated after confirmation";
const deletedConnections: string[] = [];
const executeIfCurrent = async (connectionId: string, namespace: string) => {
if (!isNacosConfigDeleteSnapshotInScope(snapshot, connectionId, namespace)) return;
deletedConnections.push(snapshot.connectionId);
};
await executeIfCurrent("new-connection", "dev");
expect(deletedConnections).toEqual([]);
expect(snapshot).toMatchObject({
connectionId: "old-connection",
key: { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" },
config: { desc: "old connection config" },
});
});
it("splits every case-sensitive literal content match for safe highlighting", () => {
expect(splitNacosContentLiteralMatches("url=/deploy/deploy?mode=Deploy", "deploy")).toEqual([
{ text: "url=/", matched: false },
{ text: "deploy", matched: true },
{ text: "/", matched: false },
{ text: "deploy", matched: true },
{ text: "?mode=Deploy", matched: false },
]);
expect(splitNacosContentLiteralMatches("<script>alert(1)</script>", "alert")).toEqual([
{ text: "<script>", matched: false },
{ text: "alert", matched: true },
{ text: "(1)</script>", matched: false },
]);
});
it("exports content search matches as UTF-8 CSV and neutralizes spreadsheet formulas", () => {
const csv = buildNacosContentSearchCsv([
{
namespace: "",
group: "dev",
dataId: '=IMPORTXML("https://example.test")',
lineNumber: 12,
snippet: 'url: "jdbc:mysql://db/a,b"\nuser: root',
},
]);
expect(csv.startsWith("\uFEFF")).toBe(true);
expect(csv).toContain('"public","dev","\'=IMPORTXML(""https://example.test"")","12"');
expect(csv).toContain('"url: ""jdbc:mysql://db/a,b""\nuser: root"');
});
});
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createNacosNamespaceRequestGuard, NACOS_NAMESPACES_CHANGED_EVENT, notifyNacosNamespacesChanged, subscribeNacosNamespacesChanged } from "@/lib/nacos/nacosNamespaceCache";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Nacos namespace request guard", () => {
it("prevents an older response from replacing a newer namespace list", async () => {
const guard = createNacosNamespaceRequestGuard();
const committedLists: string[][] = [];
let resolveOlder!: (value: string[]) => void;
let resolveNewer!: (value: string[]) => void;
const olderResponse = new Promise<string[]>((resolve) => {
resolveOlder = resolve;
});
const newerResponse = new Promise<string[]>((resolve) => {
resolveNewer = resolve;
});
const olderRequest = guard.start("conn-1");
const commitOlder = olderResponse.then((namespaces) => {
if (guard.isCurrent(olderRequest, "conn-1")) committedLists.push(namespaces);
});
const newerRequest = guard.start("conn-1");
const commitNewer = newerResponse.then((namespaces) => {
if (guard.isCurrent(newerRequest, "conn-1")) committedLists.push(namespaces);
});
resolveNewer(["public", "new-space"]);
await commitNewer;
resolveOlder(["public"]);
await commitOlder;
expect(committedLists).toEqual([["public", "new-space"]]);
});
it("rejects pending responses after invalidation or a connection change", () => {
const guard = createNacosNamespaceRequestGuard();
const request = guard.start("conn-1");
expect(guard.isCurrent(request, "conn-2")).toBe(false);
guard.invalidate();
expect(guard.isCurrent(request, "conn-1")).toBe(false);
});
});
describe("Nacos namespace change events", () => {
it("notifies open views for the affected connection and supports unsubscribe", () => {
const listeners = new Map<string, Set<(event: Event) => void>>();
vi.stubGlobal("window", {
addEventListener: (type: string, listener: (event: Event) => void) => {
const handlers = listeners.get(type) ?? new Set();
handlers.add(listener);
listeners.set(type, handlers);
},
removeEventListener: (type: string, listener: (event: Event) => void) => {
listeners.get(type)?.delete(listener);
},
dispatchEvent: (event: Event) => {
listeners.get(event.type)?.forEach((listener) => listener(event));
return true;
},
});
const listener = vi.fn();
const unsubscribe = subscribeNacosNamespacesChanged(listener);
notifyNacosNamespacesChanged("conn-1");
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith({ connectionId: "conn-1" });
expect(listeners.get(NACOS_NAMESPACES_CHANGED_EVENT)?.size).toBe(1);
unsubscribe();
notifyNacosNamespacesChanged("conn-1");
expect(listener).toHaveBeenCalledOnce();
expect(listeners.get(NACOS_NAMESPACES_CHANGED_EVENT)?.size).toBe(0);
});
});
+7
View File
@@ -330,6 +330,13 @@ export const nacosListConfigs = forward("nacosListConfigs");
export const nacosGetConfig = forward("nacosGetConfig");
export const nacosPublishConfig = forward("nacosPublishConfig");
export const nacosDeleteConfig = forward("nacosDeleteConfig");
export const nacosSearchConfigContent = forward("nacosSearchConfigContent");
export const nacosCancelConfigContentSearch = forward("nacosCancelConfigContentSearch");
export const nacosExportConfigs = forward("nacosExportConfigs");
export const nacosPreviewConfigImport = forward("nacosPreviewConfigImport");
export const nacosApplyConfigImport = forward("nacosApplyConfigImport");
export const nacosPreviewConfigTransfer = forward("nacosPreviewConfigTransfer");
export const nacosApplyConfigTransfer = forward("nacosApplyConfigTransfer");
export const nacosListConfigHistory = forward("nacosListConfigHistory");
export const nacosGetConfigHistory = forward("nacosGetConfigHistory");
export const nacosRollbackConfig = forward("nacosRollbackConfig");
+95
View File
@@ -144,6 +144,13 @@ import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, Da
import { apiUrl, apiWebSocketUrl } from "@/lib/common/webPath";
import type { DataGridSavePreparation } from "@/lib/backend/tauri";
import type {
NacosBatchPreview,
NacosBatchReport,
NacosConfigSelector,
NacosConfigTransferRequest,
NacosConflictPolicy,
NacosContentSearchRequest,
NacosContentSearchResult,
NacosConfigHistoryKey,
NacosConfigHistoryList,
NacosConfigHistoryQuery,
@@ -165,6 +172,7 @@ import type {
NacosRawResponse,
NacosServiceList,
NacosServiceQuery,
NacosSearchProgress,
} from "@/types/nacos";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
@@ -2111,6 +2119,93 @@ export async function nacosDeleteConfig(connectionId: string, key: NacosConfigKe
return post("/api/nacos/configs/delete", { connectionId, key });
}
export async function nacosSearchConfigContent(connectionId: string, req: NacosContentSearchRequest, onProgress?: (progress: NacosSearchProgress) => void): Promise<NacosContentSearchResult> {
const response = await fetch(apiUrl("/api/nacos/configs/search"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connectionId, req }),
});
if (!response.ok) throw new Error(await response.text());
if (!response.body) throw new Error("Nacos content search did not return a response stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let result: NacosContentSearchResult | null = null;
const consumeLine = (line: string) => {
if (!line.startsWith("data:")) return;
const data = line.slice(5).trim();
if (!data) return;
const event = JSON.parse(data) as { type: "progress"; progress: NacosSearchProgress } | { type: "result"; result: NacosContentSearchResult } | { type: "error"; error: string };
if (event.type === "progress") onProgress?.(event.progress);
else if (event.type === "result") result = event.result;
else throw new Error(event.error);
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) consumeLine(line);
}
buffer += decoder.decode();
if (buffer) consumeLine(buffer);
if (!result) throw new Error("Nacos content search stream ended without a final result");
return result;
} finally {
await reader.cancel().catch(() => {});
}
}
export async function nacosCancelConfigContentSearch(operationId: string): Promise<boolean> {
const result = await post<{ cancelled: boolean }>("/api/nacos/configs/search/cancel", { operationId });
return result.cancelled;
}
export async function nacosExportConfigs(connectionId: string, selector: NacosConfigSelector, _destination: string, fileName = "nacos-configs.zip"): Promise<void> {
const response = await fetch(apiUrl("/api/nacos/configs/export"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ connectionId, selector, fileName }),
});
if (!response.ok) throw new Error(await response.text());
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
export async function nacosPreviewConfigImport(connectionId: string, targetNamespace: string, archivePath: string | File): Promise<NacosBatchPreview> {
if (!(archivePath instanceof File)) throw new Error("Nacos ZIP import in web mode requires a File object");
const formData = new FormData();
formData.append("connectionId", connectionId);
formData.append("targetNamespace", targetNamespace);
formData.append("file", archivePath, archivePath.name);
const response = await fetch(apiUrl("/api/nacos/configs/import/preview"), { method: "POST", body: formData });
if (!response.ok) throw new Error(await response.text());
return response.json();
}
export async function nacosApplyConfigImport(connectionId: string, operationId: string, targetNamespace: string, _archivePath: string | File, planHash: string, conflictPolicy: NacosConflictPolicy, archiveToken?: string): Promise<NacosBatchReport> {
if (!archiveToken) throw new Error("The Nacos import preview token is missing or expired");
return post("/api/nacos/configs/import/apply", { connectionId, operationId, targetNamespace, archiveToken, planHash, conflictPolicy });
}
export async function nacosPreviewConfigTransfer(req: NacosConfigTransferRequest): Promise<NacosBatchPreview> {
return post("/api/nacos/configs/copy/preview", { req });
}
export async function nacosApplyConfigTransfer(req: NacosConfigTransferRequest, planHash: string): Promise<NacosBatchReport> {
return post("/api/nacos/configs/copy/apply", { req, planHash });
}
export async function nacosListConfigHistory(connectionId: string, query: NacosConfigHistoryQuery): Promise<NacosConfigHistoryList> {
return post("/api/nacos/configs/history/list", { connectionId, query });
}
+41 -1
View File
@@ -1,5 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import { Channel, invoke } from "@tauri-apps/api/core";
import type {
NacosBatchPreview,
NacosBatchReport,
NacosConfigSelector,
NacosConfigTransferRequest,
NacosConflictPolicy,
NacosContentSearchRequest,
NacosContentSearchResult,
NacosConfigHistoryKey,
NacosConfigHistoryList,
NacosConfigHistoryQuery,
@@ -21,6 +28,7 @@ import type {
NacosRawResponse,
NacosServiceList,
NacosServiceQuery,
NacosSearchProgress,
} from "@/types/nacos";
export async function nacosTestConnection(connectionId: string): Promise<NacosConnectionInfo> {
@@ -55,6 +63,38 @@ export async function nacosDeleteConfig(connectionId: string, key: NacosConfigKe
return invoke("nacos_delete_config", { connectionId, key });
}
export async function nacosSearchConfigContent(connectionId: string, req: NacosContentSearchRequest, onProgress?: (progress: NacosSearchProgress) => void): Promise<NacosContentSearchResult> {
const channel = new Channel<NacosSearchProgress>();
channel.onmessage = (progress) => onProgress?.(progress);
return invoke("nacos_search_config_content", { connectionId, req, onProgress: channel });
}
export async function nacosCancelConfigContentSearch(operationId: string): Promise<boolean> {
return invoke("nacos_cancel_operation", { operationId });
}
export async function nacosExportConfigs(connectionId: string, selector: NacosConfigSelector, destination: string, _fileName?: string): Promise<void> {
return invoke("nacos_export_configs", { connectionId, selector, destination });
}
export async function nacosPreviewConfigImport(connectionId: string, targetNamespace: string, archivePath: string | File): Promise<NacosBatchPreview> {
if (typeof archivePath !== "string") throw new Error("Desktop Nacos ZIP import requires a local file path");
return invoke("nacos_preview_config_import", { connectionId, targetNamespace, archivePath });
}
export async function nacosApplyConfigImport(connectionId: string, operationId: string, targetNamespace: string, archivePath: string | File, planHash: string, conflictPolicy: NacosConflictPolicy, _archiveToken?: string): Promise<NacosBatchReport> {
if (typeof archivePath !== "string") throw new Error("Desktop Nacos ZIP import requires a local file path");
return invoke("nacos_apply_config_import", { connectionId, operationId, targetNamespace, archivePath, planHash, conflictPolicy });
}
export async function nacosPreviewConfigTransfer(req: NacosConfigTransferRequest): Promise<NacosBatchPreview> {
return invoke("nacos_preview_config_transfer", { req });
}
export async function nacosApplyConfigTransfer(req: NacosConfigTransferRequest, planHash: string): Promise<NacosBatchReport> {
return invoke("nacos_apply_config_transfer", { req, planHash });
}
export async function nacosListConfigHistory(connectionId: string, query: NacosConfigHistoryQuery): Promise<NacosConfigHistoryList> {
return invoke("nacos_list_config_history", { connectionId, query });
}
@@ -73,6 +73,10 @@ export function supportsClearableQuerySchema(dbType?: DatabaseType): boolean {
return !!dbType && CLEARABLE_QUERY_SCHEMA_TYPES.has(dbType);
}
export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
return dbType !== "nacos";
}
export function usesFetchFirst(dbType?: DatabaseType): boolean {
return !!dbType && FETCH_FIRST_TYPES.has(dbType);
}
+232 -1
View File
@@ -1,4 +1,4 @@
import type { NacosConfigHistoryItem, NacosConfigItem, NacosImplementation, NacosInstanceInfo, NacosRawRequest, NacosServiceInfo, NacosVersionMode } from "@/types/nacos";
import type { NacosConfigHistoryItem, NacosConfigItem, NacosConfigKey, NacosContentMatch, NacosImplementation, NacosInstanceInfo, NacosRawRequest, NacosServiceInfo, NacosVersionMode } from "@/types/nacos";
import { diffChars, diffLines } from "diff";
export type NacosRawTemplateKey = "serverState" | "namespaceList" | "configDetail" | "serviceList" | "instanceList";
@@ -163,6 +163,11 @@ export function isNacosRawMutation(method: string): boolean {
return method.trim().toUpperCase() !== "GET";
}
export function isNacosErrorCode(error: unknown, code: string): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes(`NACOS_ERROR[${code}]`);
}
export function formatNacosConfigIdentity(item: Pick<NacosConfigItem, "namespace" | "dataId" | "group">, fallbackNamespace = ""): string {
return [`namespace=${item.namespace || fallbackNamespace || "public"}`, `dataId=${item.dataId}`, `group=${item.group || "DEFAULT_GROUP"}`].join("\n");
}
@@ -179,6 +184,232 @@ export function resolveNacosConfigCopyText(selectionText: string, editorText: st
return selectionText || editorText || stateText;
}
export interface NacosLatestRequestGuard {
begin(): number;
invalidate(): void;
isCurrent(requestId: number): boolean;
}
export function createNacosLatestRequestGuard(): NacosLatestRequestGuard {
let sequence = 0;
return {
begin() {
sequence += 1;
return sequence;
},
invalidate() {
sequence += 1;
},
isCurrent(requestId) {
return requestId === sequence;
},
};
}
export interface NacosConfigSaveSnapshot {
requestId: number;
editorSessionId: number;
connectionId: string;
wasCreating: boolean;
originalKey: NacosConfigKey | null;
targetKey: NacosConfigKey;
config: NacosConfigItem;
content: string;
configType: string;
}
export interface NacosConfigSaveSnapshotInput {
requestId: number;
editorSessionId: number;
connectionId: string;
fallbackNamespace?: string;
originalKey: NacosConfigKey | null;
config: NacosConfigItem;
content: string;
configType: string;
}
export function createNacosConfigSaveSnapshot(input: NacosConfigSaveSnapshotInput): NacosConfigSaveSnapshot {
const dataId = input.config.dataId.trim();
const group = input.config.group.trim() || "DEFAULT_GROUP";
const namespace = input.originalKey?.namespace || input.config.namespace || input.fallbackNamespace || undefined;
const configType = input.configType || "text";
const config: NacosConfigItem = {
...input.config,
namespace: namespace || "",
dataId,
group,
content: input.content,
configType,
};
return {
requestId: input.requestId,
editorSessionId: input.editorSessionId,
connectionId: input.connectionId,
wasCreating: !input.originalKey,
originalKey: input.originalKey ? { ...input.originalKey } : null,
targetKey: { namespace, dataId, group },
config,
content: input.content,
configType,
};
}
export interface NacosConfigEditorComparableState {
latestRequestId: number;
editorSessionId: number;
connectionId: string;
originalKey: NacosConfigKey | null;
config: NacosConfigItem | null;
content: string;
configType: string;
}
function sameNacosConfigKey(left: NacosConfigKey | null, right: NacosConfigKey | null): boolean {
if (!left || !right) return left === right;
return (left.namespace || "") === (right.namespace || "") && left.dataId === right.dataId && (left.group || "DEFAULT_GROUP") === (right.group || "DEFAULT_GROUP");
}
export function isNacosConfigSaveSnapshotSameEditor(snapshot: NacosConfigSaveSnapshot, current: NacosConfigEditorComparableState): boolean {
const config = current.config;
return (
snapshot.requestId === current.latestRequestId &&
snapshot.editorSessionId === current.editorSessionId &&
snapshot.connectionId === current.connectionId &&
sameNacosConfigKey(snapshot.originalKey, current.originalKey) &&
!!config &&
snapshot.config.dataId === config.dataId.trim() &&
snapshot.config.group === (config.group.trim() || "DEFAULT_GROUP") &&
(snapshot.config.namespace || "") === (config.namespace || snapshot.targetKey.namespace || "")
);
}
export function isNacosConfigSaveSnapshotCurrent(snapshot: NacosConfigSaveSnapshot, current: NacosConfigEditorComparableState): boolean {
const config = current.config;
return (
isNacosConfigSaveSnapshotSameEditor(snapshot, current) &&
!!config &&
snapshot.content === current.content &&
snapshot.configType === current.configType &&
(snapshot.config.appName || "") === (config.appName || "") &&
(snapshot.config.desc || "") === (config.desc || "") &&
(snapshot.config.tags || "") === (config.tags || "")
);
}
export type NacosConfigSaveCompletion =
| { kind: "stale" }
| {
kind: "saved" | "saved-with-later-edits";
originalKey: NacosConfigKey;
savedConfig: NacosConfigItem;
baseline: {
content: string;
configType: string;
appName: string;
desc: string;
tags: string;
};
};
export function resolveNacosConfigSaveCompletion(snapshot: NacosConfigSaveSnapshot, current: NacosConfigEditorComparableState): NacosConfigSaveCompletion {
if (!isNacosConfigSaveSnapshotSameEditor(snapshot, current)) return { kind: "stale" };
return {
kind: isNacosConfigSaveSnapshotCurrent(snapshot, current) ? "saved" : "saved-with-later-edits",
originalKey: { ...snapshot.targetKey },
savedConfig: { ...snapshot.config },
baseline: {
content: snapshot.content,
configType: snapshot.configType,
appName: snapshot.config.appName || "",
desc: snapshot.config.desc || "",
tags: snapshot.config.tags || "",
},
};
}
export function canDeleteNacosConfig(readOnly: boolean, originalKey: NacosConfigKey | null): boolean {
return !readOnly && !!originalKey;
}
export interface NacosConfigMutationGuardState {
readOnly: boolean;
saving: boolean;
deleting: boolean;
hasPendingDelete: boolean;
hasPendingSave: boolean;
}
export function canStartNacosConfigSave(state: NacosConfigMutationGuardState): boolean {
return !state.readOnly && !state.saving && !state.deleting && !state.hasPendingDelete;
}
export function canStartNacosConfigDelete(state: NacosConfigMutationGuardState, originalKey: NacosConfigKey | null): boolean {
return canDeleteNacosConfig(state.readOnly, originalKey) && !state.saving && !state.deleting && !state.hasPendingSave && !state.hasPendingDelete;
}
export interface NacosConfigDeleteSnapshot {
connectionId: string;
key: NacosConfigKey;
config: NacosConfigItem;
}
export function createNacosConfigDeleteSnapshot(connectionId: string, key: NacosConfigKey, config: NacosConfigItem): NacosConfigDeleteSnapshot {
return {
connectionId,
key: {
namespace: key.namespace || undefined,
dataId: key.dataId,
group: key.group || "DEFAULT_GROUP",
},
config: {
...config,
namespace: key.namespace || "",
dataId: key.dataId,
group: key.group || "DEFAULT_GROUP",
},
};
}
export function isNacosConfigDeleteSnapshotInScope(snapshot: NacosConfigDeleteSnapshot, connectionId: string, namespace?: string): boolean {
return snapshot.connectionId === connectionId && (snapshot.key.namespace || "") === (namespace || "");
}
export interface NacosLiteralMatchSegment {
text: string;
matched: boolean;
}
export function splitNacosContentLiteralMatches(text: string, query: string): NacosLiteralMatchSegment[] {
if (!text || !query) return text ? [{ text, matched: false }] : [];
const segments: NacosLiteralMatchSegment[] = [];
let cursor = 0;
while (cursor < text.length) {
const matchIndex = text.indexOf(query, cursor);
if (matchIndex < 0) {
segments.push({ text: text.slice(cursor), matched: false });
break;
}
if (matchIndex > cursor) {
segments.push({ text: text.slice(cursor, matchIndex), matched: false });
}
segments.push({ text: text.slice(matchIndex, matchIndex + query.length), matched: true });
cursor = matchIndex + query.length;
}
return segments;
}
function nacosSearchCsvCell(value: string | number): string {
let text = String(value);
if (/^[\t\r ]*[=+\-@]/.test(text)) text = `'${text}`;
return `"${text.replaceAll('"', '""')}"`;
}
export function buildNacosContentSearchCsv(matches: readonly NacosContentMatch[]): string {
const rows: Array<Array<string | number>> = [["namespace", "group", "dataId", "lineNumber", "snippet"], ...matches.map((match) => [match.namespace || "public", match.group || "DEFAULT_GROUP", match.dataId, match.lineNumber, match.snippet])];
return `\uFEFF${rows.map((row) => row.map(nacosSearchCsvCell).join(",")).join("\r\n")}\r\n`;
}
export function sanitizeNacosConfigFileNameSegment(value: string): string {
const sanitized = value
.trim()
@@ -0,0 +1,40 @@
export const NACOS_NAMESPACES_CHANGED_EVENT = "dbx:nacos-namespaces-changed";
export interface NacosNamespacesChangedDetail {
connectionId: string;
}
export interface NacosNamespaceRequestGuard {
invalidate: () => void;
start: (connectionId: string) => number;
isCurrent: (requestId: number, connectionId: string) => boolean;
}
export function createNacosNamespaceRequestGuard(): NacosNamespaceRequestGuard {
let latestRequestId = 0;
let latestConnectionId = "";
return {
invalidate: () => {
latestRequestId++;
latestConnectionId = "";
},
start: (connectionId) => {
latestConnectionId = connectionId;
return ++latestRequestId;
},
isCurrent: (requestId, connectionId) => requestId === latestRequestId && connectionId === latestConnectionId,
};
}
export function notifyNacosNamespacesChanged(connectionId: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<NacosNamespacesChangedDetail>(NACOS_NAMESPACES_CHANGED_EVENT, { detail: { connectionId } }));
}
export function subscribeNacosNamespacesChanged(listener: (detail: NacosNamespacesChangedDetail) => void): () => void {
if (typeof window === "undefined") return () => undefined;
const handleEvent = (event: Event) => listener((event as CustomEvent<NacosNamespacesChangedDetail>).detail);
window.addEventListener(NACOS_NAMESPACES_CHANGED_EVENT, handleEvent);
return () => window.removeEventListener(NACOS_NAMESPACES_CHANGED_EVENT, handleEvent);
}