diff --git a/apps/desktop/src/components/nacos/NacosAdminConsole.vue b/apps/desktop/src/components/nacos/NacosAdminConsole.vue index 6a579f7d7d..8e2d5056d4 100644 --- a/apps/desktop/src/components/nacos/NacosAdminConsole.vue +++ b/apps/desktop/src/components/nacos/NacosAdminConsole.vue @@ -15,10 +15,11 @@ import NacosConfigHistoryDialog from "@/components/nacos/NacosConfigHistoryDialo import { useToast } from "@/composables/useToast"; import { useI18n } from "vue-i18n"; import * as api from "@/lib/api"; -import { buildNacosConfigCopy, buildNacosConfigDeleteConfirm, buildNacosConfigExport, buildNacosConfigHistoryRollbackConfirm, buildNacosInstanceConfirm, createNacosSaveAsCopy } from "@/lib/nacosAdmin"; -import { copyToClipboard } from "@/lib/clipboard"; +import { buildNacosConfigDeleteConfirm, buildNacosConfigExportFileName, buildNacosConfigHistoryRollbackConfirm, buildNacosInstanceConfirm, createNacosSaveAsCopy, resolveNacosConfigCopyText } from "@/lib/nacosAdmin"; +import { copyToClipboard, readTextFromClipboard } from "@/lib/clipboard"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage"; import { editorFontTheme, loadEditorTheme } from "@/lib/editorThemes"; +import { isTauriRuntime } from "@/lib/tauriRuntime"; import { useSettingsStore } from "@/stores/settingsStore"; import { useTheme } from "@/composables/useTheme"; import type { NacosConfigHistoryItem, NacosConfigItem, NacosConfigKey, NacosConnectionInfo, NacosInstanceInfo, NacosServiceInfo } from "@/types/nacos"; @@ -83,6 +84,7 @@ const configFormatOptions = ["text", "json", "xml", "yaml", "html", "properties" const configEditorHost = ref(null); const configEditorView = shallowRef(null); const configSearchPanelRef = ref>(); +const knownConfigFormats = ref>({}); const configEditorTheme = new Compartment(); const configEditorFontTheme = new Compartment(); const configEditorLanguage = new Compartment(); @@ -276,24 +278,37 @@ function normalizeConfigFormat(value?: string): string { return normalized; } -function mergeSelectedConfigFormat(items: NacosConfigItem[]): NacosConfigItem[] { - const selected = selectedConfig.value; - const selectedKey = selectedConfigOriginalKey.value; - if (!selected || !selectedKey) return items; - const selectedFormat = configFormatValue(selected); - if (!selectedFormat) return items; - return items.map((item) => { - if (!isSameConfigKey(item, selectedKey) || configFormatValue(item)) return item; - return { ...item, configType: selectedFormat }; - }); -} - function normalizeConfigItemFormat(item: T): T { const value = normalizeConfigFormat(item.configType); if (!value || value === item.configType) return item; return { ...item, configType: value }; } +function configFormatCacheKey(key: { namespace?: string; dataId: string; group: string }): string { + return [props.connectionId, key.namespace || namespace.value || "", key.dataId, key.group || "DEFAULT_GROUP"].join("\u0000"); +} + +function rememberConfigFormat(item: { namespace?: string; dataId: string; group: string; configType?: string }) { + const value = configFormatValue(item); + if (!value) return; + knownConfigFormats.value = { + ...knownConfigFormats.value, + [configFormatCacheKey(item)]: value, + }; +} + +function applyKnownConfigFormats(items: NacosConfigItem[]): NacosConfigItem[] { + return items.map((item) => { + const existingFormat = configFormatValue(item); + if (existingFormat) { + rememberConfigFormat({ ...item, configType: existingFormat }); + return item.configType === existingFormat ? item : { ...item, configType: existingFormat }; + } + const knownFormat = knownConfigFormats.value[configFormatCacheKey(item)]; + return knownFormat ? { ...item, configType: knownFormat } : item; + }); +} + function configFormatDisplayLabel(value: string): string { const normalized = value.trim().toLowerCase(); if (!normalized) return "-"; @@ -358,7 +373,7 @@ async function loadConfigs(page = configPageNo.value) { pageNo: configPageNo.value, pageSize: configPageSize.value, }); - configs.value = mergeSelectedConfigFormat(result.items.map(normalizeConfigItemFormat)); + configs.value = applyKnownConfigFormats(result.items.map(normalizeConfigItemFormat)); configTotal.value = result.totalCount; } catch (error) { configError.value = error instanceof Error ? error.message : String(error); @@ -400,6 +415,12 @@ async function selectConfig(item: NacosConfigItem) { dataId: normalizedDetail.dataId || item.dataId, group: normalizedDetail.group || item.group, }; + rememberConfigFormat({ + ...normalizedDetail, + namespace: selectedConfigOriginalKey.value.namespace, + dataId: selectedConfigOriginalKey.value.dataId, + group: selectedConfigOriginalKey.value.group, + }); configContent.value = normalizedDetail.content || ""; originalConfigContent.value = configContent.value; configType.value = configFormatValue(normalizedDetail) || configFormatValue(item) || "text"; @@ -446,14 +467,57 @@ function saveConfigAsCopy() { async function copyConfigIdentity() { if (!selectedConfig.value) return; - await copyToClipboard(buildNacosConfigCopy(selectedConfig.value, configContent.value)); - toast(t("nacos.copied"), 2000); + const view = configEditorView.value; + const selection = view?.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to) || ""; + const text = resolveNacosConfigCopyText(selection, view?.state.doc.toString(), configContent.value); + try { + await copyToClipboard(text); + try { + const copiedText = await readTextFromClipboard(); + if (copiedText !== text) { + throw new Error(t("nacos.copyVerifyFailed")); + } + } catch (verifyError) { + if (isTauriRuntime()) throw verifyError; + } + toast(t("nacos.copied"), 2000); + } catch (error) { + toast(t("grid.copyFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); + } +} + +async function downloadConfigText(content: string, fileName: string) { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); } async function exportConfig() { if (!selectedConfig.value) return; - await copyToClipboard(buildNacosConfigExport({ ...selectedConfig.value, configType: configType.value }, configContent.value)); - toast(t("nacos.exported"), 2000); + const item = { ...selectedConfig.value, configType: configType.value }; + const fileName = buildNacosConfigExportFileName(item); + try { + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const path = await save({ + defaultPath: fileName, + filters: [{ name: configFormatDisplayLabel(configType.value || item.configType || "text"), extensions: [fileName.split(".").pop() || "txt"] }], + }); + if (!path) return; + await writeTextFile(path, configContent.value); + toast(t("nacos.exportedTo", { path }), 2000); + return; + } + await downloadConfigText(configContent.value, fileName); + toast(t("nacos.exported"), 2000); + } catch (error) { + toast(t("nacos.exportFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); + } } function historyKeyFor(item: NacosConfigHistoryItem) { @@ -575,6 +639,7 @@ async function rollbackConfigHistory() { async function setConfigFormat(format: string) { configType.value = format; if (selectedConfig.value) selectedConfig.value.configType = format; + if (selectedConfigOriginalKey.value) rememberConfigFormat({ ...selectedConfigOriginalKey.value, configType: format }); configSaveNotice.value = ""; await refreshConfigEditor(); } @@ -615,6 +680,7 @@ async function saveConfig() { }); const savedConfig = { ...selectedConfig.value, dataId, group, content: configContent.value, configType: configType.value }; selectedConfig.value = savedConfig; + rememberConfigFormat({ ...savedConfig, namespace: savedConfig.namespace || namespace.value || undefined }); originalConfigContent.value = configContent.value; selectedConfigOriginalKey.value = { namespace: savedConfig.namespace || namespace.value || undefined, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 86c57ef674..901528c1e9 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2865,8 +2865,11 @@ export default { delete: "Delete", copy: "Copy", copied: "Copied", + copyVerifyFailed: "Clipboard verification failed after writing", export: "Export", - exported: "Export copied", + exported: "Exported", + exportedTo: "Exported to {path}", + exportFailed: "Export failed: {message}", history: "History", configHistory: "Config history", historyDetail: "History detail", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 9cc3a97a7f..940f6001c3 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2441,8 +2441,11 @@ export default { delete: "Eliminar", copy: "Copiar", copied: "Copiado", + copyVerifyFailed: "La verificación del portapapeles falló después de escribir", export: "Exportar", - exported: "Exportación copiada", + exported: "Exportado", + exportedTo: "Exportado a {path}", + exportFailed: "Error al exportar: {message}", history: "History", configHistory: "Config history", historyDetail: "History detail", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 4285743208..18d78a73d4 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2510,8 +2510,11 @@ export default { delete: "Elimina", copy: "Copia", copied: "Copiato", + copyVerifyFailed: "Verifica degli appunti non riuscita dopo la scrittura", export: "Esporta", - exported: "Esportazione copiata", + exported: "Esportato", + exportedTo: "Esportato in {path}", + exportFailed: "Esportazione non riuscita: {message}", history: "History", configHistory: "Config history", historyDetail: "History detail", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 292a77c387..f4f4d378f1 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2755,8 +2755,11 @@ export default { delete: "削除", copy: "コピー", copied: "コピーしました", + copyVerifyFailed: "クリップボード書き込み後の検証に失敗しました", export: "エクスポート", - exported: "エクスポート内容をコピーしました", + exported: "エクスポートしました", + exportedTo: "{path} にエクスポートしました", + exportFailed: "エクスポートに失敗しました: {message}", history: "History", configHistory: "Config history", historyDetail: "History detail", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 6d91b42c07..fa1a4ddbd5 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2521,8 +2521,11 @@ export default { delete: "Excluir", copy: "Copiar", copied: "Copiado", + copyVerifyFailed: "A verificação da área de transferência falhou após a gravação", export: "Exportar", - exported: "Exportação copiada", + exported: "Exportado", + exportedTo: "Exportado para {path}", + exportFailed: "Falha ao exportar: {message}", history: "History", configHistory: "Config history", historyDetail: "History detail", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 294192156e..0b9a0a33e5 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2889,8 +2889,11 @@ export default { delete: "删除", copy: "复制", copied: "已复制", + copyVerifyFailed: "剪贴板写入后校验失败", export: "导出", - exported: "导出内容已复制", + exported: "导出完成", + exportedTo: "已导出到 {path}", + exportFailed: "导出失败:{message}", history: "历史", configHistory: "配置历史", historyDetail: "历史版本详情", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 6925c4aba4..4e1c1013e4 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2535,8 +2535,11 @@ export default { delete: "刪除", copy: "複製", copied: "已複製", + copyVerifyFailed: "剪貼簿寫入後驗證失敗", export: "匯出", - exported: "匯出內容已複製", + exported: "匯出完成", + exportedTo: "已匯出到 {path}", + exportFailed: "匯出失敗:{message}", history: "歷史", configHistory: "配置歷史", historyDetail: "歷史版本詳情", diff --git a/apps/desktop/src/lib/__tests__/nacosAdmin.spec.ts b/apps/desktop/src/lib/__tests__/nacosAdmin.spec.ts index ade4b2f93a..36edee39df 100644 --- a/apps/desktop/src/lib/__tests__/nacosAdmin.spec.ts +++ b/apps/desktop/src/lib/__tests__/nacosAdmin.spec.ts @@ -1,5 +1,19 @@ import { describe, expect, it } from "vitest"; -import { buildNacosConfigDeleteConfirm, buildNacosInlineDiff, buildNacosInstanceConfirm, buildNacosRawRequest, buildNacosSideBySideDiff, isNacosRawMutation, parseNacosRawBody, parseNacosRawQuery, summarizeNacosConfigDiff } from "../nacosAdmin"; +import { + buildNacosConfigExportFileName, + buildNacosConfigDeleteConfirm, + buildNacosInlineDiff, + buildNacosInstanceConfirm, + buildNacosRawRequest, + buildNacosSideBySideDiff, + isNacosRawMutation, + nacosConfigFileExtension, + parseNacosRawBody, + parseNacosRawQuery, + resolveNacosConfigCopyText, + sanitizeNacosConfigFileNameSegment, + summarizeNacosConfigDiff, +} from "../nacosAdmin"; describe("nacosAdmin helpers", () => { it("parses raw query and body text", () => { @@ -48,4 +62,19 @@ describe("nacosAdmin helpers", () => { expect(details).toContain("serviceName=DEFAULT_GROUP@@svc"); expect(details).toContain("targetEnabled=false"); }); + + it("builds safe export file names from config data id and format", () => { + expect(sanitizeNacosConfigFileNameSegment("../prod:app?config")).toBe("prod_app_config"); + expect(nacosConfigFileExtension("yaml")).toBe("yaml"); + expect(nacosConfigFileExtension("props")).toBe("properties"); + expect(buildNacosConfigExportFileName({ dataId: "application.yaml", configType: "text" })).toBe("application.yaml"); + expect(buildNacosConfigExportFileName({ dataId: "service/config", configType: "json" })).toBe("service_config.json"); + expect(buildNacosConfigExportFileName({ dataId: "", configType: "toml" })).toBe("nacos-config.toml"); + }); + + it("copies selected config text before editor text and state text", () => { + expect(resolveNacosConfigCopyText("selected", "editor", "state")).toBe("selected"); + expect(resolveNacosConfigCopyText("", "editor", "state")).toBe("editor"); + expect(resolveNacosConfigCopyText("", "", "state")).toBe("state"); + }); }); diff --git a/apps/desktop/src/lib/nacosAdmin.ts b/apps/desktop/src/lib/nacosAdmin.ts index 4985a04a67..6e2a5b05f9 100644 --- a/apps/desktop/src/lib/nacosAdmin.ts +++ b/apps/desktop/src/lib/nacosAdmin.ts @@ -78,6 +78,37 @@ export function buildNacosConfigCopy(item: NacosConfigItem, content: string): st return `${formatNacosConfigIdentity(item)}\n\n${content}`; } +export function resolveNacosConfigCopyText(selectionText: string, editorText: string | undefined, stateText: string): string { + return selectionText || editorText || stateText; +} + +export function sanitizeNacosConfigFileNameSegment(value: string): string { + const sanitized = value + .trim() + .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") + .replace(/\s+/g, " ") + .replace(/^[._\s-]+/, "") + .replace(/\.+$/, ""); + return sanitized || "nacos-config"; +} + +export function nacosConfigFileExtension(configType?: string): string { + const normalized = configType?.trim().toLowerCase(); + if (normalized === "yaml" || normalized === "yml") return "yaml"; + if (normalized === "json") return "json"; + if (normalized === "xml") return "xml"; + if (normalized === "html") return "html"; + if (normalized === "properties" || normalized === "props") return "properties"; + if (normalized === "toml") return "toml"; + return "txt"; +} + +export function buildNacosConfigExportFileName(item: Pick): string { + const baseName = sanitizeNacosConfigFileNameSegment(item.dataId || "nacos-config"); + if (/\.[A-Za-z0-9][A-Za-z0-9_-]{0,15}$/.test(baseName)) return baseName; + return `${baseName}.${nacosConfigFileExtension(item.configType)}`; +} + export function createNacosSaveAsCopy(item: NacosConfigItem): NacosConfigItem { return { ...item, diff --git a/crates/dbx-core/src/nacos/http.rs b/crates/dbx-core/src/nacos/http.rs index e620c872e3..de59553fe5 100644 --- a/crates/dbx-core/src/nacos/http.rs +++ b/crates/dbx-core/src/nacos/http.rs @@ -329,7 +329,26 @@ impl NacosOpenApiAdmin { let start = ((page_no.saturating_sub(1)) * page_size) as usize; let end = start.saturating_add(page_size as usize).min(matched.len()); let items = if start < matched.len() { matched[start..end].to_vec() } else { Vec::new() }; - Ok(NacosConfigList { page_no, page_size, total_count, items }) + Ok(self.enrich_missing_config_formats(NacosConfigList { page_no, page_size, total_count, items }).await) + } + + async fn enrich_missing_config_formats(&self, mut list: NacosConfigList) -> NacosConfigList { + for item in list.items.iter_mut() { + if item.config_type.is_some() { + continue; + } + let detail = self + .get_config(NacosConfigKey { + namespace: Some(item.namespace.clone()), + data_id: item.data_id.clone(), + group: item.group.clone(), + }) + .await; + if let Ok(detail) = detail { + item.config_type = detail.config_type; + } + } + list } } @@ -452,7 +471,8 @@ impl NacosAdmin for NacosOpenApiAdmin { let group_filter = query.group.clone(); let group = group_filter.clone().unwrap_or_default(); let value = self.get_config_list_value(&namespace, &search, &group, page_no, page_size).await?; - let parsed = parse_config_list(value, namespace.clone(), page_no, page_size); + let parsed = + self.enrich_missing_config_formats(parse_config_list(value, namespace.clone(), page_no, page_size)).await; if data_id_filter.is_some() && parsed.items.is_empty() { let fallback = self.list_configs_by_client_filter(namespace, group_filter, data_id_filter, page_no, page_size).await?; diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 46732dcc67..c3c181cfee 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "core:window:allow-close", "core:window:allow-start-dragging", "core:webview:allow-set-webview-zoom", + "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-text", "dialog:default", "dialog:allow-save",