From e4577abeed0903aa6f2720d2749dca1a120953e0 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Thu, 20 Aug 2026 15:15:05 +0800 Subject: [PATCH] feat(config): support selective connection import and export --- .../config/ConfigConnectionSelectDialog.vue | 117 ++++++++++ .../ConfigConnectionSelectDialog.spec.ts | 141 +++++++++++++ .../src/components/layout/AppDialogs.vue | 13 +- .../__tests__/useDialogSources.spec.ts | 87 ++++++++ .../src/composables/useDialogSources.ts | 152 ++++++++++--- apps/desktop/src/i18n/locales/en.ts | 14 +- apps/desktop/src/i18n/locales/es.ts | 14 +- apps/desktop/src/i18n/locales/it.ts | 14 +- apps/desktop/src/i18n/locales/ja.ts | 14 +- apps/desktop/src/i18n/locales/ko.ts | 14 +- apps/desktop/src/i18n/locales/pt-BR.ts | 14 +- apps/desktop/src/i18n/locales/zh-CN.ts | 14 +- apps/desktop/src/i18n/locales/zh-TW.ts | 14 +- .../connectionConfigTransfer.spec.ts | 102 +++++++++ .../connection/connectionConfigTransfer.ts | 110 ++++++++++ apps/desktop/src/lib/sidebar/sidebarLayout.ts | 49 +++++ apps/desktop/src/stores/connectionStore.ts | 139 +++++------- docs/content/docs/config-export.cn.mdx | 23 +- docs/content/docs/config-export.mdx | 23 +- .../connectionImportRoundTrip.test.ts | 199 ++++++++++++++++-- packages/app-tests/sidebarLayout.test.ts | 79 ++++++- 21 files changed, 1171 insertions(+), 175 deletions(-) create mode 100644 apps/desktop/src/components/config/ConfigConnectionSelectDialog.vue create mode 100644 apps/desktop/src/components/config/__tests__/ConfigConnectionSelectDialog.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useDialogSources.spec.ts create mode 100644 apps/desktop/src/lib/connection/connectionConfigTransfer.spec.ts create mode 100644 apps/desktop/src/lib/connection/connectionConfigTransfer.ts diff --git a/apps/desktop/src/components/config/ConfigConnectionSelectDialog.vue b/apps/desktop/src/components/config/ConfigConnectionSelectDialog.vue new file mode 100644 index 0000000000..7130654015 --- /dev/null +++ b/apps/desktop/src/components/config/ConfigConnectionSelectDialog.vue @@ -0,0 +1,117 @@ + + + + + + + + {{ mode === "export" ? t("configExport.selectExportTitle") : t("configExport.selectImportTitle") }} + {{ t("configExport.selectedCount", { selected: selectedCount, total: connections.length }) }} + + + + + + {{ t("configExport.selectAll") }} + {{ t("configExport.deselectAll") }} + + + + + {{ t("configExport.noConnections") }} + + + + + + {{ connection.name }} + {{ connectionMeta(connection) }} + + + + + + + + + {{ mode === "export" ? t("configExport.next") : t("configExport.importSelected", { count: selectedCount }) }} + + + + + diff --git a/apps/desktop/src/components/config/__tests__/ConfigConnectionSelectDialog.spec.ts b/apps/desktop/src/components/config/__tests__/ConfigConnectionSelectDialog.spec.ts new file mode 100644 index 0000000000..275b738c8f --- /dev/null +++ b/apps/desktop/src/components/config/__tests__/ConfigConnectionSelectDialog.spec.ts @@ -0,0 +1,141 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { ConnectionConfig } from "@/types/database"; + +vi.mock("@/components/ui/dialog", async () => { + const { defineComponent, h } = await import("vue"); + const passthrough = defineComponent({ + setup(_props, { slots }) { + return () => h("div", slots.default?.()); + }, + }); + return { Dialog: passthrough, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough }; +}); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + props: { disabled: Boolean }, + setup(props, { slots, emit }) { + return () => + h( + "button", + { + disabled: props.disabled, + onClick: () => emit("click"), + }, + slots.default?.(), + ); + }, + }), + }; +}); + +vi.mock("@/components/ui/scroll-area", async () => { + const { defineComponent, h } = await import("vue"); + return { + ScrollArea: defineComponent({ + setup(_props, { slots }) { + return () => h("div", slots.default?.()); + }, + }), + }; +}); + +vi.mock("@/components/icons/DatabaseIcon.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + setup() { + return () => h("span"); + }, + }), + }; +}); + +import ConfigConnectionSelectDialog from "@/components/config/ConfigConnectionSelectDialog.vue"; + +const mountedApps: App[] = []; + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + while (document.body.firstChild) document.body.removeChild(document.body.firstChild); +}); + +function conn(id: string, name: string): ConnectionConfig { + return { id, name, db_type: "mysql", host: "127.0.0.1", port: 3306, username: "root", password: "secret" }; +} + +async function mountDialog(props: Record, onConfirm = vi.fn()) { + const container = document.createElement("div"); + document.body.append(container); + const app = createApp( + defineComponent({ + setup() { + return () => h(ConfigConnectionSelectDialog, { ...props, onConfirm }); + }, + }), + ); + mountedApps.push(app); + app.use(i18n); + app.mount(container); + await nextTick(); + return { onConfirm }; +} + +describe("ConfigConnectionSelectDialog", () => { + it("defaults to all selected and confirms the current selection", async () => { + i18n.global.locale.value = "en"; + const { onConfirm } = await mountDialog({ + open: true, + mode: "export", + connections: [conn("a", "Alpha"), conn("b", "Beta")], + }); + + expect(document.body.textContent).toContain("2 / 2"); + const confirm = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Next")); + expect(confirm).toBeTruthy(); + confirm?.click(); + expect(onConfirm).toHaveBeenCalledWith(["a", "b"]); + }); + + it("disables confirm when nothing is selected", async () => { + i18n.global.locale.value = "en"; + await mountDialog({ + open: true, + mode: "import", + connections: [conn("a", "Alpha")], + }); + + const deselect = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Deselect all")); + deselect?.click(); + await nextTick(); + const confirm = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Import")); + expect(confirm?.disabled).toBe(true); + }); + + it("disables selection controls while an import is being applied", async () => { + i18n.global.locale.value = "en"; + const { onConfirm } = await mountDialog({ + open: true, + mode: "import", + busy: true, + connections: [conn("a", "Alpha")], + }); + + const buttons = [...document.body.querySelectorAll("button")]; + const selectAll = buttons.find((button) => button.textContent?.includes("Select all")); + const deselectAll = buttons.find((button) => button.textContent?.includes("Deselect all")); + const confirm = buttons.find((button) => button.textContent?.includes("Import")); + expect(selectAll?.disabled).toBe(true); + expect(deselectAll?.disabled).toBe(true); + expect(confirm?.disabled).toBe(true); + expect(document.body.querySelector("input[type='checkbox']")?.hasAttribute("disabled")).toBe(true); + confirm?.click(); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 6fa9fe68a8..0ce01d03e6 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -15,6 +15,7 @@ const DatabaseDocsDialog = defineAsyncComponent(() => import("@/components/docs/ const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue")); const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue")); const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue")); +const ConfigConnectionSelectDialog = defineAsyncComponent(() => import("@/components/config/ConfigConnectionSelectDialog.vue")); const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue")); const SshHostKeyPromptDialog = defineAsyncComponent(() => import("@/components/ssh/SshHostKeyPromptDialog.vue")); const ConnectionPasswordPromptDialog = defineAsyncComponent(() => import("@/components/connection/ConnectionPasswordPromptDialog.vue")); @@ -275,11 +276,21 @@ watch( :prefill-tables="dialogs.databaseExportPrefillTables.value" :prefill-all-databases="dialogs.databaseExportAllDatabases.value" /> + diff --git a/apps/desktop/src/composables/__tests__/useDialogSources.spec.ts b/apps/desktop/src/composables/__tests__/useDialogSources.spec.ts new file mode 100644 index 0000000000..3e9cd59227 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDialogSources.spec.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { ConnectionConfig } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + store: { + connections: [] as ConnectionConfig[], + transferSource: null, + schemaDiffSource: null, + dataCompareSource: null, + sqlFileSource: null, + diagramSource: null, + docsSource: null, + tableImportSource: null, + tableDataGenerateSource: null, + fieldLineageSource: null, + databaseSearchSource: null, + databaseExportSource: null, + readImportFile: vi.fn(), + parseConnectionsImport: vi.fn(), + applyConnectionsImport: vi.fn(), + applyDataGripKeychainPasswords: vi.fn(), + exportConnectionsToFile: vi.fn(), + }, +})); + +vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => mocks.store })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); + +import { useDialogSources } from "@/composables/useDialogSources"; + +const mountedApps: App[] = []; + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + vi.clearAllMocks(); +}); + +function conn(id: string): ConnectionConfig { + return { id, name: "Imported", db_type: "mysql", host: "127.0.0.1", port: 3306, username: "root", password: "secret" }; +} + +async function mountDialogs() { + let dialogs!: ReturnType; + const container = document.createElement("div"); + document.body.append(container); + const app = createApp( + defineComponent({ + setup() { + dialogs = useDialogSources(); + return () => h("div"); + }, + }), + ); + mountedApps.push(app); + app.use(i18n); + app.mount(container); + await nextTick(); + return dialogs; +} + +describe("useDialogSources", () => { + it("runs the final import confirmation as a single flight", async () => { + let resolveApply!: (value: { count: number }) => void; + const applyPromise = new Promise<{ count: number }>((resolve) => { + resolveApply = resolve; + }); + mocks.store.readImportFile.mockResolvedValue({ content: "{}", encrypted: false }); + mocks.store.parseConnectionsImport.mockResolvedValue({ connections: [conn("imported")] }); + mocks.store.applyConnectionsImport.mockReturnValue(applyPromise); + + const dialogs = await mountDialogs(); + await dialogs.onImportClick("dbx"); + dialogs.onConfigConnectionSelectConfirm(["imported"]); + dialogs.onConfigConnectionSelectConfirm(["imported"]); + + expect(mocks.store.applyConnectionsImport).toHaveBeenCalledTimes(1); + + resolveApply({ count: 1 }); + await applyPromise; + await nextTick(); + }); +}); diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts index d7847059fd..5e464b94b7 100644 --- a/apps/desktop/src/composables/useDialogSources.ts +++ b/apps/desktop/src/composables/useDialogSources.ts @@ -3,7 +3,8 @@ import { useI18n } from "vue-i18n"; import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; import { hasSidebarLayoutEntries } from "@/lib/sidebar/sidebarLayout"; -import type { SidebarLayout } from "@/types/database"; +import type { ConnectionConfigBundle } from "@/lib/connection/connectionConfigTransfer"; +import type { ConnectionConfig, SidebarLayout } from "@/types/database"; const showTransferDialog = ref(false); const showSchemaDiffDialog = ref(false); @@ -22,6 +23,13 @@ const showConfigPassphraseDialog = ref(false); const configPassphraseMode = ref<"export" | "import">("export"); const configPassphraseError = ref(""); const pendingImportContent = ref(""); +const showConfigConnectionSelectDialog = ref(false); +const configConnectionSelectMode = ref<"export" | "import">("export"); +const configConnectionSelectList = ref([]); +const pendingExportConnectionIds = ref([]); +const pendingImportPreview = ref(null); +const pendingImportSource = ref<"dbx" | "navicat" | "dbeaver" | "datagrip">("dbx"); +const applyingImportSelection = ref(false); const transferPrefillConnectionId = ref(""); const transferPrefillDatabase = ref(""); @@ -266,8 +274,56 @@ export function useDialogSources() { ); } // end watchersRegistered + function clearPendingImportState() { + pendingImportContent.value = ""; + pendingImportPreview.value = null; + pendingImportSource.value = "dbx"; + configConnectionSelectList.value = []; + configPassphraseError.value = ""; + } + + function clearPendingExportState() { + pendingExportConnectionIds.value = []; + configConnectionSelectList.value = []; + configPassphraseError.value = ""; + } + + function openConnectionSelect(mode: "export" | "import", connections: ConnectionConfig[]) { + configConnectionSelectMode.value = mode; + configConnectionSelectList.value = connections; + showConfigConnectionSelectDialog.value = true; + } + + function importSuccessMessage(source: "dbx" | "navicat" | "dbeaver" | "datagrip", count: number, keychainFilled = 0) { + if (count <= 0) return t("configExport.importNone"); + if (source === "navicat") return t("configExport.importNavicatSuccess", { count }); + if (source === "dbeaver") return t("configExport.importDbeaverSuccess", { count }); + if (source === "datagrip") return t("configExport.importDatagripSuccess", { count, filled: keychainFilled }); + return t("configExport.importSuccess", { count }); + } + + async function finishImport(source: "dbx" | "navicat" | "dbeaver" | "datagrip", count: number, layout?: SidebarLayout) { + let keychainFilled = 0; + if (source === "datagrip" && count > 0) { + keychainFilled = await connectionStore.applyDataGripKeychainPasswords(); + } + toast(importSuccessMessage(source, count, keychainFilled), source === "dbx" ? 2000 : 4000); + if (hasSidebarLayoutEntries(layout)) { + pendingImportLayout.value = layout; + showImportLayoutConfirm.value = true; + } + clearPendingImportState(); + } + // Config export/import helpers function onExportClick() { + clearPendingExportState(); + openConnectionSelect("export", connectionStore.connections); + } + + function onExportConnectionsSelected(connectionIds: string[]) { + pendingExportConnectionIds.value = connectionIds; + showConfigConnectionSelectDialog.value = false; configPassphraseMode.value = "export"; configPassphraseError.value = ""; showConfigPassphraseDialog.value = true; @@ -275,8 +331,9 @@ export function useDialogSources() { async function onExportConfirm(passphrase: string) { try { - await connectionStore.exportConnectionsToFile(passphrase); + await connectionStore.exportConnectionsToFile(passphrase, pendingExportConnectionIds.value); showConfigPassphraseDialog.value = false; + clearPendingExportState(); toast(t("configExport.exportSuccess"), 2000); } catch (e: any) { configPassphraseError.value = e?.message === "crypto_unavailable" ? t("configExport.cryptoUnavailable") : e?.message || String(e); @@ -287,54 +344,80 @@ export function useDialogSources() { try { const result = await connectionStore.readImportFile(source); if (!result) return; + clearPendingImportState(); pendingImportContent.value = result.content; + pendingImportSource.value = source; if (result.encrypted) { configPassphraseMode.value = "import"; configPassphraseError.value = ""; showConfigPassphraseDialog.value = true; - } else { - const { count, layout } = await connectionStore.importConnectionsFromFile(result.content, null); - // For DataGrip imports, read Keychain passwords - let keychainFilled = 0; - if (source === "datagrip" && count > 0) { - keychainFilled = await connectionStore.applyDataGripKeychainPasswords(); - } - toast( - count > 0 - ? source === "navicat" - ? t("configExport.importNavicatSuccess", { count }) - : source === "dbeaver" - ? t("configExport.importDbeaverSuccess", { count }) - : source === "datagrip" - ? t("configExport.importDatagripSuccess", { count: count, filled: keychainFilled }) - : t("configExport.importSuccess", { count }) - : t("configExport.importNone"), - 4000, - ); - if (hasSidebarLayoutEntries(layout)) { - pendingImportLayout.value = layout; - showImportLayoutConfirm.value = true; - } + return; } + const preview = await connectionStore.parseConnectionsImport(result.content, null); + pendingImportPreview.value = preview; + if (source === "dbx") { + openConnectionSelect("import", preview.connections); + return; + } + const { count, layout } = await connectionStore.applyConnectionsImport(preview); + await finishImport(source, count, layout); } catch (e: any) { + clearPendingImportState(); toast(e?.message || String(e), 4000); } } async function onImportConfirm(passphrase: string) { try { - const { count, layout } = await connectionStore.importConnectionsFromFile(pendingImportContent.value, passphrase); + const preview = await connectionStore.parseConnectionsImport(pendingImportContent.value, passphrase); + pendingImportPreview.value = preview; showConfigPassphraseDialog.value = false; - toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000); - if (hasSidebarLayoutEntries(layout)) { - pendingImportLayout.value = layout; - showImportLayoutConfirm.value = true; - } + configPassphraseError.value = ""; + openConnectionSelect("import", preview.connections); } catch (e: any) { configPassphraseError.value = e?.message === "wrong_passphrase" ? t("configExport.wrongPassphrase") : e?.message === "crypto_unavailable" ? t("configExport.cryptoUnavailable") : e?.message || String(e); } } + async function onImportConnectionsSelected(connectionIds: string[]) { + const preview = pendingImportPreview.value; + if (!preview) return; + if (applyingImportSelection.value) return; + applyingImportSelection.value = true; + try { + const { count, layout } = await connectionStore.applyConnectionsImport(preview, connectionIds); + showConfigConnectionSelectDialog.value = false; + await finishImport(pendingImportSource.value, count, layout); + } catch (e: any) { + toast(e?.message || String(e), 4000); + } finally { + applyingImportSelection.value = false; + } + } + + function onConfigConnectionSelectConfirm(connectionIds: string[]) { + if (configConnectionSelectMode.value === "export") { + onExportConnectionsSelected(connectionIds); + return; + } + void onImportConnectionsSelected(connectionIds); + } + + function onConfigConnectionSelectOpenChange(open: boolean) { + if (!open && configConnectionSelectMode.value === "import" && applyingImportSelection.value) return; + showConfigConnectionSelectDialog.value = open; + // Export selection closing to open the passphrase dialog must keep the + // chosen ids. Only import preview is discarded when the user cancels. + if (!open && configConnectionSelectMode.value === "import") clearPendingImportState(); + } + + function onConfigPassphraseOpenChange(open: boolean) { + showConfigPassphraseDialog.value = open; + if (open) return; + if (configPassphraseMode.value === "export") clearPendingExportState(); + else if (!pendingImportPreview.value) clearPendingImportState(); + } + return { showTransferDialog, showSchemaDiffDialog, @@ -353,6 +436,10 @@ export function useDialogSources() { configPassphraseMode, configPassphraseError, pendingImportContent, + showConfigConnectionSelectDialog, + applyingImportSelection, + configConnectionSelectMode, + configConnectionSelectList, transferPrefillConnectionId, transferPrefillDatabase, transferPrefillCatalog, @@ -404,5 +491,8 @@ export function useDialogSources() { onExportConfirm, onImportClick, onImportConfirm, + onConfigConnectionSelectConfirm, + onConfigConnectionSelectOpenChange, + onConfigPassphraseOpenChange, }; } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 958b0510dd..47b630a15b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2279,9 +2279,9 @@ export default { }, configExport: { passphraseTitle: "Encrypt & Export", - passphraseImportTitle: "Decrypt & Import", + passphraseImportTitle: "Decrypt File", passphraseExportHint: "To include connection passwords in the export, set a passphrase for encryption. You will need the same passphrase to import.", - passphraseImportHint: "This file is encrypted. Enter the passphrase to decrypt and import.", + passphraseImportHint: "This file is encrypted. Enter the passphrase to decrypt it, then choose which connections to import.", passphrase: "Passphrase", passphraseConfirm: "Confirm Passphrase", passphrasePlaceholder: "Enter passphrase", @@ -2290,7 +2290,7 @@ export default { passphraseMismatch: "Passphrases do not match", passphraseTooShort: "Passphrase must be at least 4 characters", exportEncrypted: "Export", - decryptImport: "Decrypt & Import", + decryptImport: "Decrypt", wrongPassphrase: "Wrong passphrase or corrupted file", cryptoUnavailable: "Encrypted config import is unavailable in this browser context. Use HTTPS, localhost, or DBX Desktop.", exportSuccess: "Connections exported successfully", @@ -2304,6 +2304,14 @@ export default { importLayoutConfirm: "The imported file contains connection groups. Apply them?", importLayoutTitle: "Import Groups", importLayoutApply: "Apply", + selectExportTitle: "Select connections to export", + selectImportTitle: "Select connections to import", + selectAll: "Select all", + deselectAll: "Deselect all", + selectedCount: "{selected} / {total}", + next: "Next", + importSelected: "Import {count}", + noConnections: "No connections available", }, ai: { placeholder: "Describe your query in natural language...", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 264af65325..763d7ac504 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2214,9 +2214,9 @@ export default withEnglishFallback({ }, configExport: { passphraseTitle: "Cifrar y exportar", - passphraseImportTitle: "Descifrar e importar", + passphraseImportTitle: "Descifrar archivo", passphraseExportHint: "Para incluir las contraseñas de conexión en la exportación, define una frase de paso para el cifrado. Necesitarás la misma frase para importar.", - passphraseImportHint: "Este archivo está cifrado. Ingresa la frase de paso para descifrarlo e importarlo.", + passphraseImportHint: "Este archivo está cifrado. Ingresa la frase de paso para descifrarlo y luego elige qué conexiones importar.", passphrase: "Frase de paso", passphraseConfirm: "Confirmar frase de paso", passphrasePlaceholder: "Ingresa la frase de paso", @@ -2225,7 +2225,7 @@ export default withEnglishFallback({ passphraseMismatch: "Las frases de paso no coinciden", passphraseTooShort: "La frase de paso debe tener al menos 4 caracteres", exportEncrypted: "Exportar", - decryptImport: "Descifrar e importar", + decryptImport: "Descifrar", wrongPassphrase: "Frase de paso incorrecta o archivo corrupto", cryptoUnavailable: "La importación cifrada no está disponible en este navegador. Usa HTTPS, localhost o DBX Desktop.", exportSuccess: "Conexiones exportadas exitosamente", @@ -2239,6 +2239,14 @@ export default withEnglishFallback({ importLayoutApply: "Aplicar", importDatagripSelectFiles: "Seleccione dataSources.xml (obligatorio); también puede seleccionar dataSources.local.xml y db-forest-config.xml.", importDatagripDialogTitle: "Seleccionar archivos de configuración de DataGrip", + selectExportTitle: "Seleccionar conexiones para exportar", + selectImportTitle: "Seleccionar conexiones para importar", + selectAll: "Seleccionar todo", + deselectAll: "Deseleccionar todo", + selectedCount: "{selected} / {total}", + next: "Siguiente", + importSelected: "Importar {count}", + noConnections: "No hay conexiones disponibles", }, ai: { placeholder: "Describe tu consulta en lenguaje natural...", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 090f1f780f..f8613122e7 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2212,9 +2212,9 @@ export default withEnglishFallback({ }, configExport: { passphraseTitle: "Crittografa ed Esporta", - passphraseImportTitle: "Decrittografa e Importa", + passphraseImportTitle: "Decrittografa file", passphraseExportHint: "Per includere le password di connessione nell'esportazione, imposta una passphrase per la crittografia. Avrai bisogno della stessa passphrase per importare.", - passphraseImportHint: "Questo file è crittografato. Inserisci la passphrase per decrittografarlo e importarlo.", + passphraseImportHint: "Questo file è crittografato. Inserisci la passphrase per decrittografarlo, poi scegli quali connessioni importare.", passphrase: "Passphrase", passphraseConfirm: "Conferma Passphrase", passphrasePlaceholder: "Inserisci passphrase", @@ -2223,7 +2223,7 @@ export default withEnglishFallback({ passphraseMismatch: "Le passphrase non corrispondono", passphraseTooShort: "La passphrase deve essere di almeno 4 caratteri", exportEncrypted: "Esporta", - decryptImport: "Decrittografa e Importa", + decryptImport: "Decrittografa", wrongPassphrase: "Passphrase errata o file corrotto", cryptoUnavailable: "L'importazione cifrata non è disponibile in questo contesto del browser. Usa HTTPS, localhost o DBX Desktop.", exportSuccess: "Connessioni esportate con successo", @@ -2237,6 +2237,14 @@ export default withEnglishFallback({ importLayoutApply: "Applica", importDatagripSelectFiles: "Seleziona dataSources.xml (obbligatorio), puoi selezionare anche dataSources.local.xml e db-forest-config.xml", importDatagripDialogTitle: "Seleziona i file di configurazione DataGrip", + selectExportTitle: "Seleziona le connessioni da esportare", + selectImportTitle: "Seleziona le connessioni da importare", + selectAll: "Seleziona tutto", + deselectAll: "Deseleziona tutto", + selectedCount: "{selected} / {total}", + next: "Avanti", + importSelected: "Importa {count}", + noConnections: "Nessuna connessione disponibile", }, ai: { placeholder: "Descrivi la tua query in linguaggio naturale...", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index d30ae234bb..0b99734292 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2239,9 +2239,9 @@ export default withEnglishFallback({ }, configExport: { passphraseTitle: "暗号化してエクスポート", - passphraseImportTitle: "復号してインポート", + passphraseImportTitle: "ファイルを復号", passphraseExportHint: "エクスポートに接続パスワードを含めるには、暗号化用のパスフレーズを設定してください。インポート時に同じパスフレーズが必要です。", - passphraseImportHint: "このファイルは暗号化されています。復号してインポートするためにパスフレーズを入力してください。", + passphraseImportHint: "このファイルは暗号化されています。パスフレーズを入力して復号し、インポートする接続を選択してください。", passphrase: "パスフレーズ", passphraseConfirm: "パスフレーズを確認", passphrasePlaceholder: "パスフレーズを入力", @@ -2250,7 +2250,7 @@ export default withEnglishFallback({ passphraseMismatch: "パスフレーズが一致しません", passphraseTooShort: "パスフレーズは4文字以上必要です", exportEncrypted: "エクスポート", - decryptImport: "復号してインポート", + decryptImport: "復号", wrongPassphrase: "パスフレーズが間違っているか、ファイルが破損しています", cryptoUnavailable: "暗号化設定のインポートはこのブラウザ環境では利用できません。HTTPS、localhost、またはDBX Desktopを使用してください。", exportSuccess: "接続を正常にエクスポートしました", @@ -2264,6 +2264,14 @@ export default withEnglishFallback({ importLayoutApply: "適用", importDatagripSelectFiles: "dataSources.xml(必須)を選択してください。dataSources.local.xml と db-forest-config.xml も同時に選択できます。", importDatagripDialogTitle: "DataGrip 設定ファイルを選択", + selectExportTitle: "エクスポートする接続を選択", + selectImportTitle: "インポートする接続を選択", + selectAll: "すべて選択", + deselectAll: "すべて解除", + selectedCount: "{selected} / {total}", + next: "次へ", + importSelected: "{count}件をインポート", + noConnections: "選択できる接続がありません", }, ai: { placeholder: "自然言語でクエリを説明してください...", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 28b0e31e5e..b7278b1c3d 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2132,9 +2132,9 @@ export default withEnglishFallback({ }, configExport: { passphraseTitle: "암호화 및 내보내기", - passphraseImportTitle: "복호화 및 가져오기", + passphraseImportTitle: "파일 복호화", passphraseExportHint: "내보내기에 연결 비밀번호를 포함하려면 암호화용 암호구를 설정하세요. 가져오려면 동일한 암호구가 필요합니다.", - passphraseImportHint: "이 파일은 암호화되어 있습니다. 복호화하여 가져오려면 암호구를 입력하세요.", + passphraseImportHint: "이 파일은 암호화되어 있습니다. 암호구를 입력해 복호화한 다음 가져올 연결을 선택하세요.", passphrase: "암호구", passphraseConfirm: "암호구 확인", passphrasePlaceholder: "암호구 입력", @@ -2143,7 +2143,7 @@ export default withEnglishFallback({ passphraseMismatch: "암호구가 일치하지 않습니다", passphraseTooShort: "암호구는 최소 4자 이상이어야 합니다", exportEncrypted: "내보내기", - decryptImport: "복호화 및 가져오기", + decryptImport: "복호화", wrongPassphrase: "암호구가 잘못되었거나 파일이 손상되었습니다", cryptoUnavailable: "이 브라우저 환경에서는 암호화된 설정 가져오기를 사용할 수 없습니다. HTTPS, localhost 또는 DBX 데스크톱을 사용하세요.", exportSuccess: "연결을 성공적으로 내보냈습니다", @@ -2155,6 +2155,14 @@ export default withEnglishFallback({ importLayoutConfirm: "가져온 파일에 연결 그룹이 포함되어 있습니다. 적용하시겠습니까?", importLayoutTitle: "그룹 가져오기", importLayoutApply: "적용", + selectExportTitle: "내보낼 연결 선택", + selectImportTitle: "가져올 연결 선택", + selectAll: "모두 선택", + deselectAll: "모두 해제", + selectedCount: "{selected} / {total}", + next: "다음", + importSelected: "{count}개 가져오기", + noConnections: "선택할 연결이 없습니다", }, ai: { placeholder: "자연어로 쿼리를 설명하세요...", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 42e5d2f79e..84fe6b9177 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2214,9 +2214,9 @@ export default withEnglishFallback({ }, configExport: { passphraseTitle: "Criptografar e Exportar", - passphraseImportTitle: "Descriptografar e Importar", + passphraseImportTitle: "Descriptografar arquivo", passphraseExportHint: "Para incluir as senhas de conexão na exportação, defina uma frase secreta para a criptografia. Você precisará da mesma frase secreta para importar.", - passphraseImportHint: "Este arquivo está criptografado. Digite a frase secreta para descriptografar e importar.", + passphraseImportHint: "Este arquivo está criptografado. Digite a frase secreta para descriptografá-lo e depois escolha quais conexões importar.", passphrase: "Frase secreta", passphraseConfirm: "Confirmar Frase Secreta", passphrasePlaceholder: "Digite a frase secreta", @@ -2225,7 +2225,7 @@ export default withEnglishFallback({ passphraseMismatch: "As frases secretas não coincidem", passphraseTooShort: "A frase secreta deve ter pelo menos 4 caracteres", exportEncrypted: "Exportar", - decryptImport: "Descriptografar e Importar", + decryptImport: "Descriptografar", wrongPassphrase: "Frase secreta incorreta ou arquivo corrompido", cryptoUnavailable: "A importação criptografada não está disponível neste contexto do navegador. Use HTTPS, localhost ou DBX Desktop.", exportSuccess: "Conexões exportadas com sucesso", @@ -2239,6 +2239,14 @@ export default withEnglishFallback({ importLayoutApply: "Aplicar", importDatagripSelectFiles: "Selecione dataSources.xml (obrigatório), você também pode selecionar dataSources.local.xml e db-forest-config.xml", importDatagripDialogTitle: "Selecione o arquivo de configuração do DataGrip", + selectExportTitle: "Selecionar conexões para exportar", + selectImportTitle: "Selecionar conexões para importar", + selectAll: "Selecionar tudo", + deselectAll: "Desmarcar tudo", + selectedCount: "{selected} / {total}", + next: "Próximo", + importSelected: "Importar {count}", + noConnections: "Nenhuma conexão disponível", }, ai: { placeholder: "Descreva sua consulta em linguagem natural...", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 612bf18ab8..5988a992b3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2203,9 +2203,9 @@ export default withEnglishFallback({ }, configExport: { passphraseTitle: "加密导出", - passphraseImportTitle: "解密导入", + passphraseImportTitle: "解密文件", passphraseExportHint: "如需导出连接密码,请设置密码短语进行加密保护。导入时需要输入相同的密码短语才能解密。", - passphraseImportHint: "此文件已加密,请输入密码短语来解密并导入。", + passphraseImportHint: "此文件已加密,请输入密码短语解密,然后选择要导入的连接。", passphrase: "密码短语", passphraseConfirm: "确认密码短语", passphrasePlaceholder: "输入密码短语", @@ -2214,7 +2214,7 @@ export default withEnglishFallback({ passphraseMismatch: "两次输入的密码短语不一致", passphraseTooShort: "密码短语至少需要 4 个字符", exportEncrypted: "导出", - decryptImport: "解密并导入", + decryptImport: "解密", wrongPassphrase: "密码短语错误或文件已损坏", cryptoUnavailable: "当前浏览器环境不支持加密配置导入,请使用 HTTPS、localhost 或 DBX 桌面版。", exportSuccess: "连接配置导出成功", @@ -2228,6 +2228,14 @@ export default withEnglishFallback({ importLayoutConfirm: "导入文件包含连接分组信息,是否一并应用?", importLayoutTitle: "导入分组", importLayoutApply: "应用", + selectExportTitle: "选择要导出的连接", + selectImportTitle: "选择要导入的连接", + selectAll: "全选", + deselectAll: "取消全选", + selectedCount: "{selected} / {total}", + next: "下一步", + importSelected: "导入 {count} 个", + noConnections: "没有可选择的连接", }, ai: { placeholder: "描述你想查什么...", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 192539c9c8..17f85e2920 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2213,9 +2213,9 @@ export default withEnglishFallback({ }, configExport: { passphraseTitle: "加密匯出", - passphraseImportTitle: "解密匯入", + passphraseImportTitle: "解密檔案", passphraseExportHint: "若要在匯出檔案中包含連線密碼,請設定一組加密密碼。匯入時您將需要使用相同的加密密碼。", - passphraseImportHint: "此檔案已加密,請輸入加密密碼來解密並匯入。", + passphraseImportHint: "此檔案已加密,請輸入加密密碼解密,然後選擇要匯入的連線。", passphrase: "加密密碼", passphraseConfirm: "確認加密密碼", passphrasePlaceholder: "輸入加密密碼", @@ -2224,7 +2224,7 @@ export default withEnglishFallback({ passphraseMismatch: "兩次輸入的加密密碼不一致", passphraseTooShort: "加密密碼至少需要 4 個字元", exportEncrypted: "匯出", - decryptImport: "解密並匯入", + decryptImport: "解密", wrongPassphrase: "加密密碼錯誤或檔案已損壞", cryptoUnavailable: "目前瀏覽器環境不支援加密設定匯入,請使用 HTTPS、localhost 或 DBX 桌面版。", exportSuccess: "連線設定匯出成功", @@ -2238,6 +2238,14 @@ export default withEnglishFallback({ importLayoutConfirm: "匯入檔案包含連線群組資訊,是否一併套用?", importLayoutTitle: "匯入群組", importLayoutApply: "套用", + selectExportTitle: "選擇要匯出的連線", + selectImportTitle: "選擇要匯入的連線", + selectAll: "全選", + deselectAll: "取消全選", + selectedCount: "{selected} / {total}", + next: "下一步", + importSelected: "匯入 {count} 個", + noConnections: "沒有可選擇的連線", }, ai: { placeholder: "描述你想查什麼……", diff --git a/apps/desktop/src/lib/connection/connectionConfigTransfer.spec.ts b/apps/desktop/src/lib/connection/connectionConfigTransfer.spec.ts new file mode 100644 index 0000000000..669ad25bd6 --- /dev/null +++ b/apps/desktop/src/lib/connection/connectionConfigTransfer.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import type { ConnectionConfig, SidebarLayout, TunnelProfile } from "@/types/database"; +import { buildConnectionConfigBundle, parseConnectionConfigObject, selectConnectionConfigBundle, snapshotConnectionsForExport } from "./connectionConfigTransfer"; + +function conn(id: string, name: string, extras: Partial = {}): ConnectionConfig { + return { + id, + name, + db_type: "mysql", + host: "127.0.0.1", + port: 3306, + username: "root", + password: "secret", + ...extras, + }; +} + +const layout: SidebarLayout = { + groups: [ + { id: "prod", name: "Prod", collapsed: false }, + { id: "dev", name: "Dev", collapsed: false }, + ], + order: [ + { + type: "group", + id: "prod", + children: [ + { type: "connection", id: "a" }, + { type: "connection", id: "b" }, + ], + }, + { + type: "group", + id: "dev", + children: [{ type: "connection", id: "c" }], + }, + { type: "connection", id: "d" }, + ], +}; + +const tunnel1 = { type: "ssh", id: "tunnel-1", host: "bastion-1", port: 22, user: "root" } as TunnelProfile; +const tunnel2 = { type: "ssh", id: "tunnel-2", host: "bastion-2", port: 22, user: "root" } as TunnelProfile; + +describe("connectionConfigTransfer", () => { + it("filters connections, empty groups, and unused tunnel profiles", () => { + const connections = [ + conn("a", "A", { transport_layers: [{ type: "ssh", id: "layer-a", host: "", port: 22, user: "", profile_id: "tunnel-1" }] }), + conn("b", "B", { transport_layers: [{ type: "ssh", id: "layer-b", host: "", port: 22, user: "", profile_id: "tunnel-2" }] }), + conn("c", "C", { transport_layers: [{ type: "ssh", id: "layer-c", host: "", port: 22, user: "", profile_id: "tunnel-1" }] }), + conn("d", "D"), + ]; + + const bundle = buildConnectionConfigBundle(connections, layout, [tunnel1, tunnel2], ["a", "c"]); + expect(bundle.connections.map((connection) => connection.id)).toEqual(["a", "c"]); + expect(bundle.layout?.groups.map((group) => group.name)).toEqual(["Prod", "Dev"]); + expect(bundle.tunnelProfiles?.map((profile) => profile.id)).toEqual(["tunnel-1"]); + expect(bundle.connections.some((connection) => connection.id === "b")).toBe(false); + }); + + it("keeps a shared tunnel profile only once", () => { + const connections = [conn("a", "A", { transport_layers: [{ type: "ssh", id: "layer-a", host: "", port: 22, user: "", profile_id: "tunnel-1" }] }), conn("c", "C", { transport_layers: [{ type: "ssh", id: "layer-c", host: "", port: 22, user: "", profile_id: "tunnel-1" }] })]; + const bundle = buildConnectionConfigBundle(connections, null, [tunnel1, tunnel1, tunnel2], ["a", "c"]); + expect(bundle.tunnelProfiles).toEqual([tunnel1]); + }); + + it("exports the full set when no selection is provided", () => { + const connections = [conn("a", "A"), conn("b", "B"), conn("c", "C"), conn("d", "D")]; + const bundle = buildConnectionConfigBundle(connections, layout, [tunnel1], undefined); + expect(bundle.connections.map((connection) => connection.id)).toEqual(["a", "b", "c", "d"]); + expect(bundle.layout?.order.some((entry) => entry.type === "connection" && entry.id === "d")).toBe(true); + }); + + it("snapshots inherited timeouts before filtering", () => { + const exported = snapshotConnectionsForExport([conn("a", "A", { connect_timeout_inherit: true, connect_timeout_secs: 99, query_timeout_inherit: true, query_timeout_secs: 99 })], { + connectTimeoutSecs: () => 7, + queryTimeoutSecs: () => 12, + }); + expect(exported[0]).toMatchObject({ + connect_timeout_secs: 7, + connect_timeout_inherit: true, + query_timeout_secs: 12, + query_timeout_inherit: true, + }); + }); + + it("parses legacy arrays and dbx-config payloads without inventing a layout", () => { + expect(parseConnectionConfigObject([conn("a", "A")])).toEqual({ connections: [conn("a", "A")] }); + expect(parseConnectionConfigObject({ format: "dbx-config", connections: [conn("a", "A")] })).toEqual({ connections: [conn("a", "A")] }); + }); + + it("selects a preview subset without mutating the original preview", () => { + const preview = { + connections: [conn("a", "A"), conn("b", "B"), conn("c", "C")], + layout, + tunnelProfiles: [tunnel1, tunnel2], + }; + const selected = selectConnectionConfigBundle(preview, ["a", "c"]); + expect(selected.connections.map((connection) => connection.id)).toEqual(["a", "c"]); + expect(preview.connections).toHaveLength(3); + expect(selected.layout?.order.some((entry) => entry.type === "connection" && entry.id === "d")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/connection/connectionConfigTransfer.ts b/apps/desktop/src/lib/connection/connectionConfigTransfer.ts new file mode 100644 index 0000000000..7d0695d28f --- /dev/null +++ b/apps/desktop/src/lib/connection/connectionConfigTransfer.ts @@ -0,0 +1,110 @@ +import type { ConnectionConfig, SidebarLayout, TunnelProfile } from "@/types/database"; +import { filterSidebarLayoutByConnectionIds as filterLayoutByConnectionIds } from "@/lib/sidebar/sidebarLayout"; + +export interface ConnectionConfigBundle { + connections: ConnectionConfig[]; + layout?: SidebarLayout; + tunnelProfiles?: TunnelProfile[]; +} + +export interface ConnectionConfigSnapshotOptions { + connectTimeoutSecs: (connection: ConnectionConfig) => number; + queryTimeoutSecs: (connection: ConnectionConfig) => number; +} + +export function selectedConnectionIdSet(connectionIds: Iterable | undefined): Set | undefined { + if (connectionIds == null) return undefined; + return new Set(Array.from(connectionIds).filter((id) => typeof id === "string" && id.length > 0)); +} + +export function filterConnectionsByIds(connections: ConnectionConfig[], selectedIds?: Iterable): ConnectionConfig[] { + const selected = selectedConnectionIdSet(selectedIds); + if (!selected) return [...connections]; + return connections.filter((connection) => selected.has(connection.id)); +} + +export function filterSidebarLayoutByConnectionIds(layout: SidebarLayout | null | undefined, selectedIds: Iterable): SidebarLayout { + return filterLayoutByConnectionIds(layout, Array.from(selectedConnectionIdSet(selectedIds) ?? [])); +} + +export function referencedTunnelProfileIds(connections: Iterable>): Set { + const ids = new Set(); + for (const connection of connections) { + for (const layer of connection.transport_layers ?? []) { + if (typeof layer.profile_id === "string" && layer.profile_id) ids.add(layer.profile_id); + } + } + return ids; +} + +export function filterTunnelProfilesByIds(profiles: TunnelProfile[], selectedIds: Iterable): TunnelProfile[] { + const selected = selectedConnectionIdSet(selectedIds); + if (!selected) return [...profiles]; + const seen = new Set(); + const filtered: TunnelProfile[] = []; + for (const profile of profiles) { + if (!selected.has(profile.id) || seen.has(profile.id)) continue; + seen.add(profile.id); + filtered.push(profile); + } + return filtered; +} + +export function snapshotConnectionsForExport(connections: ConnectionConfig[], options: ConnectionConfigSnapshotOptions): ConnectionConfig[] { + return connections.map((connection) => ({ + ...connection, + connect_timeout_secs: connection.connect_timeout_inherit === true ? options.connectTimeoutSecs(connection) : connection.connect_timeout_secs, + query_timeout_secs: connection.query_timeout_inherit === true ? options.queryTimeoutSecs(connection) : connection.query_timeout_secs, + })); +} + +export function buildConnectionConfigBundle(connections: ConnectionConfig[], layout: SidebarLayout | null | undefined, tunnelProfiles: TunnelProfile[], selectedIds?: Iterable): ConnectionConfigBundle { + const selectedConnections = filterConnectionsByIds(connections, selectedIds); + const selectedConnectionIds = selectedConnections.map((connection) => connection.id); + return { + connections: selectedConnections, + layout: filterSidebarLayoutByConnectionIds(layout, selectedConnectionIds), + tunnelProfiles: filterTunnelProfilesByIds(tunnelProfiles, referencedTunnelProfileIds(selectedConnections)), + }; +} + +export function parseConnectionConfigObject(value: unknown): ConnectionConfigBundle { + if (Array.isArray(value)) { + return { connections: value as ConnectionConfig[] }; + } + + if (!value || typeof value !== "object") { + return { connections: [] }; + } + + const parsed = value as { + format?: unknown; + connections?: unknown; + layout?: SidebarLayout; + tunnelProfiles?: unknown; + }; + + if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) { + return { connections: parsed.connections as ConnectionConfig[] }; + } + + if (Array.isArray(parsed.connections)) { + return { + connections: parsed.connections as ConnectionConfig[], + layout: parsed.layout?.groups && parsed.layout?.order ? parsed.layout : undefined, + tunnelProfiles: Array.isArray(parsed.tunnelProfiles) ? (parsed.tunnelProfiles as TunnelProfile[]) : undefined, + }; + } + + return { connections: [] }; +} + +export function selectConnectionConfigBundle(bundle: ConnectionConfigBundle, selectedIds?: Iterable): ConnectionConfigBundle { + const selectedConnections = filterConnectionsByIds(bundle.connections, selectedIds); + const selectedConnectionIds = selectedConnections.map((connection) => connection.id); + return { + connections: selectedConnections, + layout: bundle.layout ? filterSidebarLayoutByConnectionIds(bundle.layout, selectedConnectionIds) : undefined, + tunnelProfiles: filterTunnelProfilesByIds(bundle.tunnelProfiles ?? [], referencedTunnelProfileIds(selectedConnections)), + }; +} diff --git a/apps/desktop/src/lib/sidebar/sidebarLayout.ts b/apps/desktop/src/lib/sidebar/sidebarLayout.ts index 1217ac8b47..5f0f8f5077 100644 --- a/apps/desktop/src/lib/sidebar/sidebarLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarLayout.ts @@ -107,6 +107,55 @@ export function reconcileLayout(connectionIds: string[], layout: SidebarLayout | return { groups, order }; } +/** + * Keep only the selected connections and drop groups that become empty. + * Unlike {@link reconcileLayout}, missing selected ids are not appended as + * ungrouped leftovers — the caller already owns the selected set. + */ +export function filterSidebarLayoutByConnectionIds(layout: SidebarLayout | null | undefined, connectionIds: Iterable): SidebarLayout { + const selectedIds = Array.from(new Set(Array.from(connectionIds).filter((id) => id.length > 0))); + if (!layout) { + return { + groups: [], + order: selectedIds.map((id) => ({ type: "connection" as const, id })), + }; + } + + const validIds = new Set(selectedIds); + const validGroups = new Set(layout.groups.map((group) => group.id)); + const seenConnections = new Set(); + const seenGroups = new Set(); + + const prune = (entry: SidebarOrderEntry): SidebarOrderEntry | null => { + if (entry.type === "connection") { + if (!validIds.has(entry.id) || seenConnections.has(entry.id)) return null; + seenConnections.add(entry.id); + return { type: "connection", id: entry.id }; + } + if (!validGroups.has(entry.id) || seenGroups.has(entry.id)) return null; + seenGroups.add(entry.id); + const children = entryChildren(entry).map(prune).filter(Boolean) as SidebarOrderEntry[]; + if (!children.length) return null; + return { type: "group", id: entry.id, children }; + }; + + const order = layout.order.map(prune).filter(Boolean) as SidebarOrderEntry[]; + const usedGroupIds = new Set(); + const collectGroups = (entries: SidebarOrderEntry[]) => { + for (const entry of entries) { + if (entry.type !== "group") continue; + usedGroupIds.add(entry.id); + collectGroups(entry.children ?? []); + } + }; + collectGroups(order); + + return { + groups: layout.groups.filter((group) => usedGroupIds.has(group.id)).map((group) => ({ ...group })), + order, + }; +} + export function remapSidebarLayoutConnectionIds(layout: SidebarLayout, connectionIdMap: Map): SidebarLayout { const remapEntries = (entries: SidebarOrderEntry[]): SidebarOrderEntry[] => entries.flatMap((entry): SidebarOrderEntry[] => { diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 078a581248..04187e8845 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -21,7 +21,6 @@ import type { TableNameFilter, TableInfo, TreeNode, - TunnelProfile, VectorCollectionMeta, } from "@/types/database"; import { @@ -63,6 +62,7 @@ import { type DropPosition, type ReorderEntriesOptions, } from "@/lib/sidebar/sidebarLayout"; +import { buildConnectionConfigBundle, filterSidebarLayoutByConnectionIds, filterTunnelProfilesByIds, parseConnectionConfigObject, referencedTunnelProfileIds, selectConnectionConfigBundle, snapshotConnectionsForExport, type ConnectionConfigBundle } from "@/lib/connection/connectionConfigTransfer"; import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation"; import * as api from "@/lib/backend/api"; @@ -7758,18 +7758,17 @@ export const useConnectionStore = defineStore("connection", () => { await refreshNodes(treeNodes.value); } - async function exportConnectionsToFile(passphrase: string) { + async function exportConnectionsToFile(passphrase: string, selectedConnectionIds?: Iterable) { const { encryptConfig } = await import("@/lib/backend/configCrypto"); const tunnelProfileStore = useTunnelProfileStore(); await tunnelProfileStore.init(); // Older DBX versions ignore inheritance flags, so always include the // effective numeric values as a backward-compatible snapshot. - const exportedConnections = connections.value.map((connection) => ({ - ...connection, - connect_timeout_secs: connection.connect_timeout_inherit === true ? settingsStore.editorSettings.globalConnectTimeoutSecs : connection.connect_timeout_secs, - query_timeout_secs: connection.query_timeout_inherit === true ? settingsStore.editorSettings.globalQueryTimeoutSecs : connection.query_timeout_secs, - })); - const exportData = { connections: exportedConnections, layout: sidebarLayout.value, tunnelProfiles: tunnelProfileStore.profiles }; + const exportedConnections = snapshotConnectionsForExport(connections.value, { + connectTimeoutSecs: () => settingsStore.editorSettings.globalConnectTimeoutSecs, + queryTimeoutSecs: () => settingsStore.editorSettings.globalQueryTimeoutSecs, + }); + const exportData = buildConnectionConfigBundle(exportedConnections, sidebarLayout.value, tunnelProfileStore.profiles, selectedConnectionIds); const json = JSON.stringify(exportData); const payload = await encryptConfig(json, passphrase); const content = JSON.stringify(payload, null, 2); @@ -7976,15 +7975,13 @@ export const useConnectionStore = defineStore("connection", () => { return { content, encrypted: isEncryptedConfig(parsed) }; } - async function importConnectionsFromFile(content: string, passphrase: string | null): Promise<{ count: number; layout?: SidebarLayout }> { - let imported: ConnectionConfig[] = []; - let importedLayout: SidebarLayout | undefined; - let importedTunnelProfiles: TunnelProfile[] = []; - + async function parseConnectionsImport(content: string, passphrase: string | null): Promise { if (!passphrase && content.trimStart().startsWith("<")) { const { parseNavicatConnections } = await import("@/lib/imports/navicatImport"); - imported = await parseNavicatConnections(content); - } else if (!passphrase) { + return { connections: await parseNavicatConnections(content) }; + } + + if (!passphrase) { const { isDbeaverImportPayload, parseDbeaverImport } = await import("@/lib/imports/dbeaverImport"); const { isDataGripImportPayload, parseDataGripImport } = await import("@/lib/imports/datagripImport"); if (isDataGripImportPayload(content)) { @@ -7996,89 +7993,61 @@ export const useConnectionStore = defineStore("connection", () => { }; pendingDataGripPayload = payload; const result = parseDataGripImport(payload); - imported = result.connections; - importedLayout = result.layout; - } else if (isDbeaverImportPayload(content)) { + return { connections: result.connections, layout: result.layout }; + } + if (isDbeaverImportPayload(content)) { const result = await parseDbeaverImport(content); - imported = result.connections; - importedLayout = result.layout; - } else { - const parsed = JSON.parse(content); - - if (Array.isArray(parsed)) { - imported = parsed; - } else if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) { - imported = parsed.connections; - } else if (parsed.connections && Array.isArray(parsed.connections)) { - imported = parsed.connections; - if (parsed.layout?.groups && parsed.layout?.order) { - importedLayout = parsed.layout; - } - if (Array.isArray(parsed.tunnelProfiles)) { - importedTunnelProfiles = parsed.tunnelProfiles; - } - } else { - imported = []; - } - } - } else { - const parsed = JSON.parse(content); - - if (passphrase) { - const { decryptConfig } = await import("@/lib/backend/configCrypto"); - const json = await decryptConfig(parsed, passphrase); - const decrypted = JSON.parse(json); - if (Array.isArray(decrypted)) { - imported = decrypted; - } else if (decrypted.connections) { - imported = decrypted.connections; - if (decrypted.layout?.groups && decrypted.layout?.order) { - importedLayout = decrypted.layout; - } - if (Array.isArray(decrypted.tunnelProfiles)) { - importedTunnelProfiles = decrypted.tunnelProfiles; - } - } else { - imported = []; - } + return { connections: result.connections, layout: result.layout }; } + return parseConnectionConfigObject(JSON.parse(content)); } - // Profiles keep their original ids: imported connections reference them - // via transport_layers[].profile_id, so regenerating ids would break the - // links. Same-id profiles are overwritten with the imported copy. + const { decryptConfig } = await import("@/lib/backend/configCrypto"); + const json = await decryptConfig(JSON.parse(content), passphrase); + return parseConnectionConfigObject(JSON.parse(json)); + } + + async function applyConnectionsImport(preview: ConnectionConfigBundle, selectedConnectionIds?: Iterable): Promise<{ count: number; layout?: SidebarLayout }> { + const selected = selectConnectionConfigBundle(preview, selectedConnectionIds); + const imported = selected.connections.map((connection) => ({ ...connection })); + let importedLayout = selected.layout; + const importedConnections: ConnectionConfig[] = []; + const importedConnectionIdMap = new Map(); + for (const config of imported) { + const duplicate = [...connections.value, ...importedConnections].find((connection) => connection.name === config.name && connection.host === config.host && connection.port === config.port); + if (duplicate) { + if (typeof config.id === "string") importedConnectionIdMap.set(config.id, duplicate.id); + continue; + } + const importedId = config.id; + config.id = uuid(); + if (typeof importedId === "string") importedConnectionIdMap.set(importedId, config.id); + importedConnections.push(normalizeConnection(config)); + } + const importedTunnelProfileIds = referencedTunnelProfileIds(importedConnections); + const importedTunnelProfiles = filterTunnelProfilesByIds(selected.tunnelProfiles ?? [], importedTunnelProfileIds); if (importedTunnelProfiles.length) { - const tunnelProfileStore = useTunnelProfileStore(); - await tunnelProfileStore.init(); - const merged = [...tunnelProfileStore.profiles]; + const importedTunnelProfileStore = useTunnelProfileStore(); + await importedTunnelProfileStore.init(); + const merged = [...importedTunnelProfileStore.profiles]; for (const profile of importedTunnelProfiles) { if (!profile || typeof profile.id !== "string" || !profile.id) continue; const index = merged.findIndex((existing) => existing.id === profile.id); if (index >= 0) merged[index] = profile; else merged.push(profile); } - await tunnelProfileStore.saveProfiles(merged); - } - - let count = 0; - const importedConnectionIdMap = new Map(); - for (const config of imported) { - const duplicate = connections.value.find((c) => c.name === config.name && c.host === config.host && c.port === config.port); - if (!duplicate) { - const importedId = config.id; - config.id = uuid(); - if (typeof importedId === "string") importedConnectionIdMap.set(importedId, config.id); - const normalized = normalizeConnection(config); - await addConnection(normalized); - count++; - } else if (typeof config.id === "string") { - importedConnectionIdMap.set(config.id, duplicate.id); - } + await importedTunnelProfileStore.saveProfiles(merged); } + for (const connection of importedConnections) await addConnection(connection); if (importedLayout) { - importedLayout = remapSidebarLayoutConnectionIds(importedLayout, importedConnectionIdMap); + importedLayout = filterSidebarLayoutByConnectionIds(remapSidebarLayoutConnectionIds(importedLayout, importedConnectionIdMap), importedConnectionIdMap.values()); } - return { count, layout: importedLayout }; + return { count: importedConnections.length, layout: importedLayout }; + } + + async function importConnectionsFromFile(content: string, passphrase: string | null, selectedConnectionIds?: Iterable): Promise<{ count: number; layout?: SidebarLayout }> { + const preview = await parseConnectionsImport(content, passphrase); + return applyConnectionsImport(preview, selectedConnectionIds); } /** Read macOS Keychain passwords for DataGrip connections and update them. */ @@ -8353,6 +8322,8 @@ export const useConnectionStore = defineStore("connection", () => { invalidateMetadataCache, exportConnectionsToFile, readImportFile, + parseConnectionsImport, + applyConnectionsImport, importConnectionsFromFile, applyDataGripKeychainPasswords, applySidebarLayout, diff --git a/docs/content/docs/config-export.cn.mdx b/docs/content/docs/config-export.cn.mdx index 105a6bad3a..562956c1fe 100644 --- a/docs/content/docs/config-export.cn.mdx +++ b/docs/content/docs/config-export.cn.mdx @@ -11,16 +11,18 @@ description: 导出数据库连接配置(AES-256-GCM 加密),安全地在 ### 发起导出 点击侧边栏顶部的 **导出** 按钮。 + ### 选择连接 勾选一个或多个连接。默认全选。最终只会导出已选连接、相关的侧边栏分组和排序,以及引用的隧道配置(Tunnel Profile)。 ### 设置加密密码 输入密码短语来加密连接密码。 - ### 保存文件 选择保存位置。导出文件包含所有连接及密码,已加密。 + ### 保存文件 选择保存位置。导出文件只包含已选连接及密码,已加密。 ## 导入 ### 发起导入 点击侧边栏顶部的 **导入** 按钮。 - ### 选择文件并解密 选择导出的文件,输入密码短语解密。 - ### 完成 连接导入完成,密码自动恢复。按 名称 + 主机 + 端口 自动去重。 + ### 选择文件并解密 选择导出的文件。加密文件会先要求输入密码短语,DBX 会先解密并预览,此时不会写入本地连接。 + ### 选择连接 勾选文件中要导入的连接。默认全选。 + ### 完成 只有已选连接会被导入,密码自动恢复。按 名称 + 主机 + 端口 自动去重。已选连接的侧边栏分组和排序仍可在导入后选择应用。 ## 安全性 @@ -37,12 +39,13 @@ description: 导出数据库连接配置(AES-256-GCM 加密),安全地在 ## 包含哪些内容 -| 项目 | 是否包含 | -| ---------------------------------------------- | ---------------------------------- | -| 连接名称、类型、主机、端口、数据库、颜色 | 包含 | -| URL 参数、SSL、SSH、代理、可见数据库设置 | 包含 | -| 数据库密码、SSH 密码、SSH 密钥密码、连接字符串 | 包含,并在导出文件中加密 | -| 厂商 JDBC 驱动 JAR 文件 | 不包含;目标机器需要重新安装或导入 | -| 已保存 SQL、界面/编辑器设置和侧边栏布局 | 不包含;需要迁移这些内容时使用 [云同步](/cn/docs/cloud-sync) | +| 项目 | 是否包含 | +| ---------------------------------------------- | ---------------------------------------------------- | +| 连接名称、类型、主机、端口、数据库、颜色 | 包含 | +| URL 参数、SSL、SSH、代理、可见数据库设置 | 包含 | +| 数据库密码、SSH 密码、SSH 密钥密码、连接字符串 | 包含,并在导出文件中加密 | +| 厂商 JDBC 驱动 JAR 文件 | 不包含;目标机器需要重新安装或导入 | +| 已选连接的侧边栏分组和排序 | 包含;导入连接后可选择是否应用 | +| 已保存 SQL 和界面/编辑器设置 | 不包含;需要迁移时使用 [云同步](/cn/docs/cloud-sync) | 如果要迁移 JDBC 连接,先在这里导出连接配置,再在目标机器通过 [JDBC 插件](/cn/docs/plugins) 安装插件并导入驱动 JAR。 diff --git a/docs/content/docs/config-export.mdx b/docs/content/docs/config-export.mdx index cdfcf9e782..f384fe1a85 100644 --- a/docs/content/docs/config-export.mdx +++ b/docs/content/docs/config-export.mdx @@ -11,16 +11,18 @@ description: Export database connections with AES-256-GCM encrypted passwords. M ### Start Export Click the **Export** button in the sidebar header. + ### Select Connections Choose one or more connections. All connections are selected by default. Only the selected connections, their sidebar groups and ordering, and referenced tunnel profiles are exported. ### Set Passphrase Enter a passphrase to encrypt your passwords. - ### Save File Choose a save location. The exported file contains all connections with passwords, encrypted. + ### Save File Choose a save location. The exported file contains the selected connections with passwords, encrypted. ## Import ### Start Import Click the **Import** button in the sidebar header. - ### Select File and Decrypt Select the exported file and enter the passphrase. - ### Done Connections are imported with passwords restored. Deduplicated by name + host + port. + ### Select File and Decrypt Select the exported file. Encrypted files ask for the passphrase first so DBX can decrypt and preview them without writing anything yet. + ### Select Connections Choose which connections from the file to import. All connections are selected by default. + ### Done Only the selected connections are imported, with passwords restored. Deduplicated by name + host + port. Sidebar groups and ordering for the selected connections can still be applied after import. ## Security @@ -37,12 +39,13 @@ Import also supports plain JSON files from older versions (without passwords) an ## What Is Included -| Item | Included | -| ---------------------------------------------------------------------- | -------------------------------------------------------- | -| Connection name, type, host, port, database, color | Yes | -| URL parameters, SSL, SSH, proxy, visible database settings | Yes | -| Database password, SSH password, SSH key passphrase, connection string | Yes, encrypted in the export file | -| Vendor JDBC driver JAR files | No; reinstall or re-import drivers on the target machine | -| Saved SQL, UI/editor settings, and sidebar layout | No; use [Cloud Sync](/en/docs/cloud-sync) to migrate them | +| Item | Included | +| ---------------------------------------------------------------------- | --------------------------------------------------------- | +| Connection name, type, host, port, database, color | Yes | +| URL parameters, SSL, SSH, proxy, visible database settings | Yes | +| Database password, SSH password, SSH key passphrase, connection string | Yes, encrypted in the export file | +| Vendor JDBC driver JAR files | No; reinstall or re-import drivers on the target machine | +| Sidebar groups and ordering for selected connections | Yes; choose whether to apply them after import | +| Saved SQL and UI/editor settings | No; use [Cloud Sync](/en/docs/cloud-sync) to migrate them | For JDBC migrations, export the connection settings here, then use [JDBC Plugin](/en/docs/plugins) to install the plugin and import driver JARs on the destination machine. diff --git a/packages/app-tests/connectionImportRoundTrip.test.ts b/packages/app-tests/connectionImportRoundTrip.test.ts index 9e9a52ef9d..9337651091 100644 --- a/packages/app-tests/connectionImportRoundTrip.test.ts +++ b/packages/app-tests/connectionImportRoundTrip.test.ts @@ -3,7 +3,8 @@ import assert from "node:assert/strict"; import { createPinia, setActivePinia } from "pinia"; import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts"; import { hasSidebarLayoutEntries } from "../../apps/desktop/src/lib/sidebar/sidebarLayout.ts"; -import type { ConnectionConfig, SidebarLayout, TreeNode } from "../../apps/desktop/src/types/database.ts"; +import { decryptConfig } from "../../apps/desktop/src/lib/backend/configCrypto.ts"; +import type { ConnectionConfig, SidebarLayout, TreeNode, TunnelProfile } from "../../apps/desktop/src/types/database.ts"; const PASSPHRASE = "round-trip-pass"; @@ -58,9 +59,9 @@ function installExportCapture() { }; } -function installBackend(initialConnections: ConnectionConfig[], initialLayout: SidebarLayout | null) { +function installBackend(initialConnections: ConnectionConfig[], initialLayout: SidebarLayout | null, initialProfiles: TunnelProfile[] = []) { const originalFetch = globalThis.fetch; - const state = { connections: initialConnections, layout: initialLayout }; + const state = { connections: initialConnections, layout: initialLayout, profiles: initialProfiles }; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -77,6 +78,12 @@ function installBackend(initialConnections: ConnectionConfig[], initialLayout: S state.connections = JSON.parse(String(init?.body ?? "[]")); return json(null); } + if (url === "/api/tunnel-profiles/list") return json(state.profiles); + if (url === "/api/tunnel-profiles/save") { + const body = JSON.parse(String(init?.body ?? "{}")) as { profiles?: TunnelProfile[] }; + state.profiles = body.profiles ?? []; + return json(null); + } return json(null); }) as typeof fetch; @@ -158,16 +165,7 @@ test("importing a dbx export merges into the existing sidebar instead of replaci // The machine's own folder and its connection must survive untouched, and // every imported connection must land in its exported folder. - assert.deepEqual(outline(store.treeNodes), [ - "[Local Folder]", - "Local Folder/Local DB", - "[Prod]", - "Prod/Prod DB 1", - "Prod/Prod DB 2", - "[Dev]", - "Dev/Dev DB 1", - "Scratch", - ]); + assert.deepEqual(outline(store.treeNodes), ["[Local Folder]", "Local Folder/Local DB", "[Prod]", "Prod/Prod DB 1", "Prod/Prod DB 2", "[Dev]", "Dev/Dev DB 1", "Scratch"]); assert.equal(store.connections.length, 5); } finally { storage.restore(); @@ -201,3 +199,178 @@ test("re-importing the same dbx export does not duplicate connections", async () backend.restore(); } }); + +function tunnelLayer(id: string, profileId: string) { + return { type: "ssh" as const, id, host: "", port: 22, user: "", profile_id: profileId }; +} + +async function decryptExport(content: string) { + return JSON.parse(await decryptConfig(JSON.parse(content), PASSPHRASE)); +} + +test("selective encrypted export keeps only chosen connections, layout, and tunnel profiles", async () => { + const backend = installBackend([], null, [ + { type: "ssh", id: "tunnel-1", host: "bastion-1", port: 22, user: "root" }, + { type: "ssh", id: "tunnel-2", host: "bastion-2", port: 22, user: "root" }, + ]); + const capture = installExportCapture(); + const storage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + const prod = store.createConnectionGroup("Prod"); + const dev = store.createConnectionGroup("Dev"); + await store.addConnection({ ...conn("a-1", "Prod DB 1", 3306), transport_layers: [tunnelLayer("layer-a", "tunnel-1")] }, prod); + await store.addConnection({ ...conn("a-2", "Prod DB 2", 3307), transport_layers: [tunnelLayer("layer-b", "tunnel-2")] }, prod); + await store.addConnection({ ...conn("a-3", "Dev DB 1", 3308), transport_layers: [tunnelLayer("layer-c", "tunnel-1")] }, dev); + await store.addConnection(conn("a-4", "Scratch", 3309), null); + + const selected = store.connections.filter((connection) => connection.name === "Prod DB 1" || connection.name === "Dev DB 1").map((connection) => connection.id); + await store.exportConnectionsToFile(PASSPHRASE, selected); + const decrypted = await decryptExport(await capture.content()); + + assert.deepEqual( + decrypted.connections.map((connection: ConnectionConfig) => connection.name), + ["Prod DB 1", "Dev DB 1"], + ); + assert.equal( + decrypted.connections.some((connection: ConnectionConfig) => connection.name === "Prod DB 2" || connection.name === "Scratch"), + false, + ); + assert.deepEqual( + decrypted.tunnelProfiles.map((profile: TunnelProfile) => profile.id), + ["tunnel-1"], + ); + assert.deepEqual( + decrypted.layout.groups.map((group: { name: string }) => group.name), + ["Prod", "Dev"], + ); + const layoutIds: string[] = []; + const walk = (entries: Array<{ type: string; id: string; children?: typeof entries }>) => { + for (const entry of entries) { + if (entry.type === "connection") layoutIds.push(entry.id); + else if (entry.children) walk(entry.children); + } + }; + walk(decrypted.layout.order); + assert.deepEqual(layoutIds.sort(), selected.slice().sort()); + } finally { + storage.restore(); + capture.restore(); + backend.restore(); + } +}); + +test("selective encrypted import applies only chosen connections and later remaining ones", async () => { + const exported = await exportFromMachineA(); + const backend = installBackend([], null); + const storage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + const preview = await store.parseConnectionsImport(exported, PASSPHRASE); + assert.equal(store.connections.length, 0); + const firstIds = preview.connections.filter((connection) => connection.name === "Prod DB 1" || connection.name === "Dev DB 1").map((connection) => connection.id); + const first = await store.applyConnectionsImport(preview, firstIds); + assert.equal(first.count, 2); + if (first.layout) store.applySidebarLayout(first.layout); + assert.deepEqual(store.connections.map((connection) => connection.name).sort(), ["Dev DB 1", "Prod DB 1"]); + assert.deepEqual(outline(store.treeNodes), ["[Prod]", "Prod/Prod DB 1", "[Dev]", "Dev/Dev DB 1"]); + + const again = await store.applyConnectionsImport(preview, firstIds); + assert.equal(again.count, 0); + + const remainingIds = preview.connections.filter((connection) => connection.name === "Prod DB 2").map((connection) => connection.id); + const remaining = await store.applyConnectionsImport(preview, remainingIds); + assert.equal(remaining.count, 1); + if (remaining.layout) store.applySidebarLayout(remaining.layout); + assert.deepEqual(store.connections.map((connection) => connection.name).sort(), ["Dev DB 1", "Prod DB 1", "Prod DB 2"]); + assert.deepEqual(outline(store.treeNodes), ["[Prod]", "Prod/Prod DB 1", "Prod/Prod DB 2", "[Dev]", "Dev/Dev DB 1"]); + } finally { + storage.restore(); + backend.restore(); + } +}); + +test("duplicate imports do not overwrite tunnel profiles before connection deduplication", async () => { + const existingProfile = { type: "ssh", id: "tunnel-duplicate", host: "local-bastion", port: 22, user: "root" } as TunnelProfile; + const backend = installBackend([conn("local-duplicate", "Duplicate", 3306)], null, [existingProfile]); + const storage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + const preview = { + connections: [ + { ...conn("import-duplicate", "Duplicate", 3306), transport_layers: [tunnelLayer("duplicate-layer", "tunnel-duplicate")] }, + { ...conn("import-new", "New", 3307), transport_layers: [tunnelLayer("new-layer", "tunnel-new")] }, + ], + tunnelProfiles: [{ ...existingProfile, host: "imported-bastion" }, { type: "ssh", id: "tunnel-new", host: "new-bastion", port: 22, user: "root" } as TunnelProfile], + }; + + const result = await store.applyConnectionsImport(preview); + + assert.equal(result.count, 1); + assert.deepEqual( + backend.state.profiles.map((profile) => ({ id: profile.id, host: profile.host })), + [ + { id: "tunnel-duplicate", host: "local-bastion" }, + { id: "tunnel-new", host: "new-bastion" }, + ], + ); + assert.deepEqual(store.connections.map((connection) => connection.name).sort(), ["Duplicate", "New"]); + } finally { + storage.restore(); + backend.restore(); + } +}); + +test("wrong passphrase and preview-only parse do not mutate local connections", async () => { + const exported = await exportFromMachineA(); + const backend = installBackend([], null); + const storage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + await assert.rejects(() => store.parseConnectionsImport(exported, "wrong-pass"), /wrong_passphrase/); + assert.equal(store.connections.length, 0); + + const preview = await store.parseConnectionsImport(exported, PASSPHRASE); + assert.equal(preview.connections.length, 4); + assert.equal(store.connections.length, 0); + } finally { + storage.restore(); + backend.restore(); + } +}); + +test("plain legacy dbx config still imports a selected subset", async () => { + const backend = installBackend([], null); + const storage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + const content = JSON.stringify({ + connections: [conn("old-a", "Legacy A", 3306), conn("old-b", "Legacy B", 3307), conn("old-c", "Legacy C", 3308)], + }); + const preview = await store.parseConnectionsImport(content, null); + const result = await store.applyConnectionsImport( + preview, + preview.connections.filter((connection) => connection.name !== "Legacy B").map((connection) => connection.id), + ); + assert.equal(result.count, 2); + assert.deepEqual(store.connections.map((connection) => connection.name).sort(), ["Legacy A", "Legacy C"]); + } finally { + storage.restore(); + backend.restore(); + } +}); diff --git a/packages/app-tests/sidebarLayout.test.ts b/packages/app-tests/sidebarLayout.test.ts index eae6c71396..211d6aff86 100644 --- a/packages/app-tests/sidebarLayout.test.ts +++ b/packages/app-tests/sidebarLayout.test.ts @@ -13,6 +13,7 @@ import { removeConnectionFromSidebarLayout, emptyLayout, remapSidebarLayoutConnectionIds, + filterSidebarLayoutByConnectionIds, collapseAllGroups, buildConnectionGroupPathMap, } from "../../apps/desktop/src/lib/sidebar/sidebarLayout.ts"; @@ -110,6 +111,80 @@ test("reconcileLayout removes groups with no order entry", () => { assert.equal(result.groups[0].id, "g1"); }); +test("filterSidebarLayoutByConnectionIds keeps selected connections and drops empty groups", () => { + const layout: SidebarLayout = { + groups: [ + { id: "prod", name: "Prod", collapsed: false }, + { id: "dev", name: "Dev", collapsed: false }, + ], + order: [ + { + type: "group", + id: "prod", + children: [ + { type: "connection", id: "a" }, + { type: "connection", id: "b" }, + ], + }, + { + type: "group", + id: "dev", + children: [{ type: "connection", id: "c" }], + }, + { type: "connection", id: "d" }, + ], + }; + + const filtered = filterSidebarLayoutByConnectionIds(layout, ["a", "c"]); + assert.deepEqual( + filtered.groups.map((group) => group.name), + ["Prod", "Dev"], + ); + assert.deepEqual(filtered.order, [ + { type: "group", id: "prod", children: [{ type: "connection", id: "a" }] }, + { type: "group", id: "dev", children: [{ type: "connection", id: "c" }] }, + ]); +}); + +test("filterSidebarLayoutByConnectionIds drops nested groups that become empty", () => { + const layout: SidebarLayout = { + groups: [ + { id: "prod", name: "Prod", collapsed: false }, + { id: "staging", name: "Staging", collapsed: false }, + ], + order: [ + { + type: "group", + id: "prod", + children: [ + { + type: "group", + id: "staging", + children: [{ type: "connection", id: "b" }], + }, + { type: "connection", id: "a" }, + ], + }, + ], + }; + + const filtered = filterSidebarLayoutByConnectionIds(layout, ["a"]); + assert.deepEqual( + filtered.groups.map((group) => group.id), + ["prod"], + ); + assert.deepEqual(filtered.order, [{ type: "group", id: "prod", children: [{ type: "connection", id: "a" }] }]); +}); + +test("filterSidebarLayoutByConnectionIds does not append missing selected ids", () => { + const layout: SidebarLayout = { + groups: [], + order: [{ type: "connection", id: "a" }], + }; + const filtered = filterSidebarLayoutByConnectionIds(layout, ["a", "missing"]); + assert.deepEqual(filtered.order, [{ type: "connection", id: "a" }]); +}); + test("remapSidebarLayoutConnectionIds preserves imported grouping with new connection ids", () => { const layout: SidebarLayout = { groups: [{ id: "g1", name: "Imported", collapsed: false }], @@ -284,7 +359,7 @@ test("collapseAllGroups keeps other groups collapsed after one group is reopened // --- moveConnectionToGroup --- test("moveConnectionToGroup moves connection into a group", () => { - let layout: SidebarLayout = { + const layout: SidebarLayout = { groups: [{ id: "g1", name: "G", collapsed: false }], order: [ { type: "group", id: "g1", connectionIds: [] }, @@ -299,7 +374,7 @@ test("moveConnectionToGroup moves connection into a group", () => { }); test("moveConnectionToGroup moves connection out of a group", () => { - let layout: SidebarLayout = { + const layout: SidebarLayout = { groups: [{ id: "g1", name: "G", collapsed: false }], order: [{ type: "group", id: "g1", connectionIds: ["a"] }], };