diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts index 994eb09366..d7847059fd 100644 --- a/apps/desktop/src/composables/useDialogSources.ts +++ b/apps/desktop/src/composables/useDialogSources.ts @@ -2,6 +2,7 @@ import { ref, watch } from "vue"; 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"; const showTransferDialog = ref(false); @@ -310,7 +311,7 @@ export function useDialogSources() { : t("configExport.importNone"), 4000, ); - if (layout && count > 0) { + if (hasSidebarLayoutEntries(layout)) { pendingImportLayout.value = layout; showImportLayoutConfirm.value = true; } @@ -325,7 +326,7 @@ export function useDialogSources() { const { count, layout } = await connectionStore.importConnectionsFromFile(pendingImportContent.value, passphrase); showConfigPassphraseDialog.value = false; toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000); - if (layout && count > 0) { + if (hasSidebarLayoutEntries(layout)) { pendingImportLayout.value = layout; showImportLayoutConfirm.value = true; } diff --git a/apps/desktop/src/lib/sidebar/sidebarLayout.ts b/apps/desktop/src/lib/sidebar/sidebarLayout.ts index d452f6d698..05dcbaa2a1 100644 --- a/apps/desktop/src/lib/sidebar/sidebarLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarLayout.ts @@ -6,6 +6,10 @@ export function emptyLayout(): SidebarLayout { return { groups: [], order: [] }; } +export function hasSidebarLayoutEntries(layout: SidebarLayout | null | undefined): layout is SidebarLayout { + return !!layout && (layout.groups.length > 0 || layout.order.length > 0); +} + function folderPathSegments(path: string | undefined): string[] { return (path ?? "").split("/").filter((segment) => segment.length > 0); } @@ -121,6 +125,58 @@ export function remapSidebarLayoutConnectionIds(layout: SidebarLayout, connectio }; } +/** + * Merge an imported layout into the current one instead of replacing it. + * Folders are matched by name within the same level so repeated imports reuse + * them, and connections outside the imported set keep their current placement. + */ +export function mergeSidebarLayout(current: SidebarLayout, imported: SidebarLayout): SidebarLayout { + const importedGroups = new Map(imported.groups.map((group) => [group.id, group])); + const groups = current.groups.map((group) => ({ ...group })); + const groupNameById = new Map(groups.map((group) => [group.id, group.name])); + const order = cloneEntries(current.order); + + // The imported layout owns the placement of the connections it lists, so drop + // their current entries first — otherwise they would end up listed twice. + const importedConnectionIds: string[] = []; + const collectConnections = (entries: SidebarOrderEntry[]) => { + for (const entry of entries) { + if (entry.type === "connection") importedConnectionIds.push(entry.id); + else collectConnections(entryChildren(entry)); + } + }; + collectConnections(imported.order); + for (const id of importedConnectionIds) removeEntry(order, id); + + const merge = (target: SidebarOrderEntry[], source: SidebarOrderEntry[]) => { + for (const entry of source) { + if (entry.type === "connection") { + target.push({ type: "connection", id: entry.id }); + continue; + } + + const importedGroup = importedGroups.get(entry.id); + if (!importedGroup) continue; + + let destination = target.find((candidate): candidate is Extract => candidate.type === "group" && groupNameById.get(candidate.id) === importedGroup.name); + if (!destination) { + // A fresh id keeps the merged tree free of collisions with the group ids + // the current layout already uses. + const id = uuid(); + destination = { type: "group", id, children: [] }; + groups.push({ ...importedGroup, id }); + groupNameById.set(id, importedGroup.name); + target.push(destination); + } + destination.children ??= []; + merge(destination.children, entryChildren(entry)); + } + }; + merge(order, imported.order); + + return { groups, order }; +} + export function connectionSidebarSearchAliases(config: Pick): string[] { return [config.host, config.username].filter((value) => value.trim().length > 0); } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 482c433645..43da6253e7 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -53,6 +53,7 @@ import { collapseAllGroups as collapseAllGroupsOp, moveConnectionToGroup as moveConnectionToGroupOp, remapSidebarLayoutConnectionIds, + mergeSidebarLayout, reorderEntry as reorderEntryOp, buildConnectionGroupPathMap, connectionSidebarSearchAliases, @@ -7857,7 +7858,7 @@ export const useConnectionStore = defineStore("connection", () => { function applySidebarLayout(layout: SidebarLayout) { const reconciledLayout = reconcileLayout( connections.value.map((c) => c.id), - layout, + mergeSidebarLayout(sidebarLayout.value, layout), ); updateLayoutAndRebuild(reconciledLayout); } diff --git a/packages/app-tests/connectionImportRoundTrip.test.ts b/packages/app-tests/connectionImportRoundTrip.test.ts new file mode 100644 index 0000000000..9e9a52ef9d --- /dev/null +++ b/packages/app-tests/connectionImportRoundTrip.test.ts @@ -0,0 +1,203 @@ +import { test } from "vitest"; +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"; + +const PASSPHRASE = "round-trip-pass"; + +function installMemoryStorage() { + const values = new Map(); + const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + }, + }); + return { + restore() { + if (original) Object.defineProperty(globalThis, "localStorage", original); + else Reflect.deleteProperty(globalThis, "localStorage"); + }, + }; +} + +/** Capture the blob the browser export path hands to the download anchor. */ +function installExportCapture() { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document"); + const originalCreate = URL.createObjectURL; + const originalRevoke = URL.revokeObjectURL; + let captured: Blob | null = null; + + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { createElement: () => ({ href: "", download: "", click() {} }) }, + }); + URL.createObjectURL = ((blob: Blob) => { + captured = blob; + return "blob:capture"; + }) as typeof URL.createObjectURL; + URL.revokeObjectURL = (() => {}) as typeof URL.revokeObjectURL; + + return { + async content() { + assert.ok(captured, "export did not produce a file blob"); + return await captured!.text(); + }, + restore() { + if (originalDocument) Object.defineProperty(globalThis, "document", originalDocument); + else Reflect.deleteProperty(globalThis, "document"); + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + }, + }; +} + +function installBackend(initialConnections: ConnectionConfig[], initialLayout: SidebarLayout | null) { + const originalFetch = globalThis.fetch; + const state = { connections: initialConnections, layout: initialLayout }; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const json = (value: unknown) => new Response(JSON.stringify(value), { status: 200, headers: { "Content-Type": "application/json" } }); + if (url === "/api/connection/list") return json(state.connections); + if (url === "/api/layout/sidebar") { + if (init?.method === "POST") { + state.layout = JSON.parse(String(init.body ?? "null")); + return json(null); + } + return json(state.layout); + } + if (url === "/api/connection/save") { + state.connections = JSON.parse(String(init?.body ?? "[]")); + return json(null); + } + return json(null); + }) as typeof fetch; + + return { + state, + restore() { + globalThis.fetch = originalFetch; + }, + }; +} + +function conn(id: string, name: string, port: number): ConnectionConfig { + return { id, name, db_type: "mysql", host: "127.0.0.1", port, username: "root", password: "secret" }; +} + +/** Nested "Folder/Child" -> label listing, so folder structure is compared as data. */ +function outline(nodes: TreeNode[]): string[] { + const lines: string[] = []; + const walk = (list: TreeNode[], prefix: string) => { + for (const node of list) { + const path = prefix ? `${prefix}/${node.label}` : node.label; + lines.push(node.type === "connection-group" ? `[${path}]` : path); + if (node.type === "connection-group") walk(node.children ?? [], path); + } + }; + walk(nodes, ""); + return lines; +} + +/** Build "machine A" through the real store API and export it through the real export path. */ +async function exportFromMachineA(): Promise { + const backend = installBackend([], null); + 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), prod); + await store.addConnection(conn("a-2", "Prod DB 2", 3307), prod); + await store.addConnection(conn("a-3", "Dev DB 1", 3308), dev); + await store.addConnection(conn("a-4", "Scratch", 3309), null); + + assert.deepEqual(outline(store.treeNodes), ["[Prod]", "Prod/Prod DB 1", "Prod/Prod DB 2", "[Dev]", "Dev/Dev DB 1", "Scratch"]); + + await store.exportConnectionsToFile(PASSPHRASE); + return await capture.content(); + } finally { + storage.restore(); + capture.restore(); + backend.restore(); + } +} + +test("importing a dbx export merges into the existing sidebar instead of replacing it", async () => { + const exported = await exportFromMachineA(); + + const localLayout: SidebarLayout = { + groups: [{ id: "local-group", name: "Local Folder", collapsed: false }], + order: [{ type: "group", id: "local-group", children: [{ type: "connection", id: "local-1" }] }], + }; + const backend = installBackend([conn("local-1", "Local DB", 5432)], localLayout); + const storage = installMemoryStorage(); + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + assert.deepEqual(outline(store.treeNodes), ["[Local Folder]", "Local Folder/Local DB"]); + + const result = await store.importConnectionsFromFile(exported, PASSPHRASE); + assert.equal(result.count, 4); + assert.ok(result.layout); + store.applySidebarLayout(result.layout!); + + // 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.equal(store.connections.length, 5); + } finally { + storage.restore(); + backend.restore(); + } +}); + +test("re-importing the same dbx export does not duplicate connections", async () => { + const exported = await exportFromMachineA(); + const backend = installBackend([], null); + const storage = installMemoryStorage(); + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + const first = await store.importConnectionsFromFile(exported, PASSPHRASE); + assert.equal(first.count, 4); + assert.equal(hasSidebarLayoutEntries(first.layout), true); + if (hasSidebarLayoutEntries(first.layout)) store.applySidebarLayout(first.layout); + const second = await store.importConnectionsFromFile(exported, PASSPHRASE); + assert.equal(second.count, 0); + assert.equal(hasSidebarLayoutEntries(second.layout), true); + if (hasSidebarLayoutEntries(second.layout)) store.applySidebarLayout(second.layout); + + assert.equal(store.connections.length, 4); + assert.deepEqual(outline(store.treeNodes), ["[Prod]", "Prod/Prod DB 1", "Prod/Prod DB 2", "[Dev]", "Dev/Dev DB 1", "Scratch"]); + } finally { + storage.restore(); + backend.restore(); + } +});