From 551f696cd54a28280059888fc425f09088309be6 Mon Sep 17 00:00:00 2001 From: zhangsan Date: Wed, 29 Jul 2026 21:07:11 +0800 Subject: [PATCH] feat(transfer): support cross-database table paste --- .../src/components/layout/AppDialogs.vue | 11 ++- .../src/components/objects/ObjectBrowser.vue | 65 ++++++++++++++---- .../objects/ObjectBrowserClipboard.spec.ts | 6 +- .../ObjectBrowser.crossDatabasePaste.spec.ts | 20 ++++++ .../sidebar/SidebarTreeRuntimeHost.vue | 68 +++++++++++++++---- .../__tests__/crossDatabaseTablePaste.spec.ts | 20 ++++++ .../transfer/DataTransferDialog.vue | 54 ++++++++++++--- .../__tests__/DataTransferDialog.spec.ts | 20 ++++++ .../src/composables/useDialogSources.ts | 29 ++++++++ .../table/__tests__/tableClipboard.spec.ts | 26 +++++++ apps/desktop/src/lib/table/tableClipboard.ts | 20 +++++- apps/desktop/src/stores/connectionStore.ts | 10 ++- .../app-tests/sidebarContextMenuHost.test.ts | 2 +- packages/app-tests/tableClipboard.test.ts | 1 + 14 files changed, 309 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src/components/objects/__tests__/ObjectBrowser.crossDatabasePaste.spec.ts create mode 100644 apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts create mode 100644 apps/desktop/src/lib/table/__tests__/tableClipboard.spec.ts diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index e4389314b3..717d9ff8fe 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -174,7 +174,16 @@ watch( @update:open="emit('update:showSqlParameterDialog', $event)" @execute="emit('sqlParametersConfirm', $event)" /> - + ({ ...entry, - schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database), + schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database, entry.connectionId), })); } +function canTransferTableClipboard(): boolean { + const entries = normalizedObjectBrowserTableClipboardEntries(); + const target = pasteTableTargetContext(); + if (entries.length === 0 || props.connection.read_only) return false; + const source = tableClipboardSourceContext(entries); + const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined; + return !!source && !!sourceConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(props.connection.db_type) && !tableClipboardMatchesTarget(entries, target); +} + function pasteTableTargetContext(): TableClipboardContext { return { connectionId: props.connection.id, @@ -1934,9 +1943,29 @@ function pasteTableTargetContext(): TableClipboardContext { }; } -function normalizeObjectBrowserTableClipboardSchema(schema?: string, database = props.database): string | undefined { - if (!isSchemaAware(effectiveDatabaseType.value) && effectiveDatabaseType.value !== "sqlite") return undefined; - return connectionObjectTreeNodeSchema(props.connection, database, schema); +function normalizeObjectBrowserTableClipboardSchema(schema?: string, database = props.database, connectionId = props.connection.id): string | undefined { + const connection = connectionStore.getConfig(connectionId) ?? props.connection; + if (!isSchemaAware(connection.db_type) && connection.db_type !== "sqlite") return undefined; + return connectionObjectTreeNodeSchema(connection, database, schema); +} + +function openTransferFromTableClipboard(): boolean { + const clipboard = connectionStore.treeClipboard; + const entries = normalizedObjectBrowserTableClipboardEntries(); + const target = pasteTableTargetContext(); + if (clipboard?.kind !== "table-copy") return false; + const source = tableClipboardSourceContext(entries); + if (!source || tableClipboardMatchesTarget(entries, target)) return false; + connectionStore.transferSource = { + connectionId: source.connectionId, + database: source.database, + schema: source.schema ?? undefined, + tables: clipboard.tables.map((entry) => entry.tableName), + targetConnectionId: target.connectionId, + targetDatabase: target.database, + targetSchema: target.schema ?? undefined, + }; + return true; } function copySingleTableToClipboard(row: ObjectBrowserRow) { @@ -1956,6 +1985,10 @@ function copySingleTableToClipboard(row: ObjectBrowserRow) { function openPasteTableDialog() { const clipboard = connectionStore.treeClipboard; + if (canTransferTableClipboard()) { + openTransferFromTableClipboard(); + return; + } if (!canPasteTableClipboard() || clipboard?.kind !== "table-copy") { toast(t("contextMenu.noTableToPaste"), 2000); return; @@ -1964,7 +1997,7 @@ function openPasteTableDialog() { pasteTableEntries.value = clipboard.tables.map((entry) => ({ sourceName: entry.tableName, targetName: `${entry.tableName}_copy`, - schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database), + schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database, entry.connectionId), })); showPasteDialog.value = true; } @@ -1979,7 +2012,7 @@ function onObjectBrowserKeydown(event: KeyboardEvent) { return; } if (eventTargetAllowsAppClipboardShortcut(event, "v")) { - if (!canPasteTableClipboard()) return; + if (!canPasteTableClipboard() && !canTransferTableClipboard()) return; event.preventDefault(); event.stopPropagation(); openPasteTableDialog(); @@ -2469,12 +2502,16 @@ function exportDataSubmenu(item: ObjectBrowserRow): ContextMenuItem { } function objectBrowserTableClipboardMenuState(item: ObjectBrowserRow) { - return tableClipboardMenuState(normalizedObjectBrowserTableClipboardEntries(), { - connectionId: props.connection.id, - database: props.database, - schema: normalizeObjectBrowserTableClipboardSchema(item.schema || selectedSchema.value), - tableName: item.name, - }); + return tableClipboardMenuState( + normalizedObjectBrowserTableClipboardEntries(), + { + connectionId: props.connection.id, + database: props.database, + schema: normalizeObjectBrowserTableClipboardSchema(item.schema || selectedSchema.value), + tableName: item.name, + }, + canTransferTableClipboard(), + ); } function tableClipboardMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { diff --git a/apps/desktop/src/components/objects/ObjectBrowserClipboard.spec.ts b/apps/desktop/src/components/objects/ObjectBrowserClipboard.spec.ts index 5b905ab868..7a0d5dc941 100644 --- a/apps/desktop/src/components/objects/ObjectBrowserClipboard.spec.ts +++ b/apps/desktop/src/components/objects/ObjectBrowserClipboard.spec.ts @@ -14,11 +14,11 @@ describe("ObjectBrowser table clipboard context menu", () => { }); it("normalizes copied and target schemas before paste validation", () => { - expect(objectBrowserSource).toMatch(/function normalizedObjectBrowserTableClipboardEntries\(\)[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(entry\.schema, entry\.database\)/); + expect(objectBrowserSource).toMatch(/function normalizedObjectBrowserTableClipboardEntries\(\)[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(entry\.schema, entry\.database, entry\.connectionId\)/); expect(objectBrowserSource).toMatch(/function canPasteTableClipboard\(\)[\s\S]*?tableClipboardMatchesTarget\(normalizedObjectBrowserTableClipboardEntries\(\), pasteTableTargetContext\(\)\)/); expect(objectBrowserSource).toMatch(/function pasteTableTargetContext\(\)[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(selectedSchema\.value\)/); - expect(objectBrowserSource).toMatch(/pasteTableEntries\.value = clipboard\.tables\.map[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(entry\.schema, entry\.database\)/); - expect(objectBrowserSource).toMatch(/function normalizeObjectBrowserTableClipboardSchema[\s\S]*?!isSchemaAware\(effectiveDatabaseType\.value\)[\s\S]*?effectiveDatabaseType\.value !== "sqlite"[\s\S]*?return undefined/); + expect(objectBrowserSource).toMatch(/pasteTableEntries\.value = clipboard\.tables\.map[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(entry\.schema, entry\.database, entry\.connectionId\)/); + expect(objectBrowserSource).toMatch(/function normalizeObjectBrowserTableClipboardSchema[\s\S]*?connectionStore\.getConfig\(connectionId\) \?\? props\.connection[\s\S]*?!isSchemaAware\(connection\.db_type\)[\s\S]*?connection\.db_type !== "sqlite"[\s\S]*?return undefined/); }); it("consumes only the clipboard used by a fully successful paste", () => { diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.crossDatabasePaste.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.crossDatabasePaste.spec.ts new file mode 100644 index 0000000000..4ff9e6816f --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.crossDatabasePaste.spec.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8"); + +describe("ObjectBrowser cross-database table paste", () => { + it("routes a foreign table clipboard to the shared data-transfer dialog", () => { + expect(objectBrowserSource).toContain("function canTransferTableClipboard()"); + expect(objectBrowserSource).toContain("function openTransferFromTableClipboard()"); + expect(objectBrowserSource).toContain("targetConnectionId: target.connectionId"); + expect(objectBrowserSource).toContain("targetDatabase: target.database"); + expect(objectBrowserSource).toMatch(/function objectBrowserTableClipboardMenuState[\s\S]*?tableClipboardMenuState\([\s\S]*?canTransferTableClipboard\(\),\s*\)/); + }); + + it("keeps the local duplicate-table paste path available", () => { + expect(objectBrowserSource).toContain("if (canTransferTableClipboard()) {"); + expect(objectBrowserSource).toContain("if (!canPasteTableClipboard() && !canTransferTableClipboard()) return;"); + expect(objectBrowserSource).toContain("pasteTableMode.value = defaultPasteTableMode(effectiveDatabaseType.value);"); + }); +}); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 11161cc0ba..04497582d1 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -88,6 +88,7 @@ import { supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, + supportsTransfer, usesTreeSchemaMode, } from "@/lib/database/databaseCapabilities"; import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick"; @@ -126,7 +127,7 @@ import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFor import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities"; import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext"; -import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableDataCopyColumnOptions, type TableClipboardContext, type TableClipboardTableContext } from "@/lib/table/tableClipboard"; +import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type TableClipboardContext, type TableClipboardTableContext } from "@/lib/table/tableClipboard"; import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes } from "@/lib/sidebar/sidebarTreeSelection"; import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection"; import { connectionSupportsDatabaseUserAdmin, resolveDatabaseUserAdminProviderForConnection, type DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin"; @@ -855,6 +856,35 @@ function canPasteTreeClipboardToCurrentNode(): boolean { return tableClipboardMatchesTarget(normalizedTreeClipboardTableEntries(), pasteTableTargetContext()); } +function canTransferTreeClipboardToCurrentNode(): boolean { + const entries = normalizedTreeClipboardTableEntries(); + const target = pasteTableTargetContext(); + if (!target || entries.length === 0) return false; + const source = tableClipboardSourceContext(entries); + const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined; + const targetConfig = connectionStore.getConfig(target.connectionId); + return !!source && !!sourceConfig && !!targetConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(targetConfig.db_type) && !targetConfig.read_only && !tableClipboardMatchesTarget(entries, target); +} + +function openTransferFromTreeClipboard(): boolean { + const clipboard = connectionStore.treeClipboard; + const entries = normalizedTreeClipboardTableEntries(); + const target = pasteTableTargetContext(); + if (clipboard?.kind !== "table-copy" || !target) return false; + const source = tableClipboardSourceContext(entries); + if (!source || tableClipboardMatchesTarget(entries, target)) return false; + connectionStore.transferSource = { + connectionId: source.connectionId, + database: source.database, + schema: source.schema ?? undefined, + tables: clipboard.tables.map((entry) => entry.tableName), + targetConnectionId: target.connectionId, + targetDatabase: target.database, + targetSchema: target.schema ?? undefined, + }; + return true; +} + function requestPasteTreeClipboard(): boolean { claimTreeItemDialogOwnership(); ensureDangerDialogRouting(); @@ -870,14 +900,16 @@ function requestPasteTreeClipboard(): boolean { .catch((e: any) => toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000)); return true; } - if (clipboard?.kind !== "table-copy" || !canPasteTreeClipboardToCurrentNode()) return false; + if (clipboard?.kind !== "table-copy") return false; + if (canTransferTreeClipboardToCurrentNode()) return openTransferFromTreeClipboard(); + if (!canPasteTreeClipboardToCurrentNode()) return false; pasteTableMode.value = defaultPasteTableMode(currentDatabaseType()); pasteTableEntries.value = clipboard.tables.map((entry) => ({ sourceName: entry.tableName, targetName: `${entry.tableName}_copy`, connectionId: entry.connectionId, database: entry.database, - schema: entry.schema, + schema: normalizeTreeClipboardSchema(entry.connectionId, entry.database, entry.schema), })); showPasteDialog.value = true; return true; @@ -3044,7 +3076,15 @@ async function confirmPasteTable() { function openPasteTableDialog() { const clipboard = connectionStore.treeClipboard; - if (clipboard?.kind !== "table-copy" || !canPasteTreeClipboardToCurrentNode()) { + if (clipboard?.kind !== "table-copy") { + toast(t("contextMenu.noTableToPaste"), 2000); + return; + } + if (canTransferTreeClipboardToCurrentNode()) { + openTransferFromTreeClipboard(); + return; + } + if (!canPasteTreeClipboardToCurrentNode()) { toast(t("contextMenu.noTableToPaste"), 2000); return; } @@ -3054,7 +3094,7 @@ function openPasteTableDialog() { targetName: `${entry.tableName}_copy`, connectionId: entry.connectionId, database: entry.database, - schema: entry.schema, + schema: normalizeTreeClipboardSchema(entry.connectionId, entry.database, entry.schema), })); showPasteDialog.value = true; } @@ -4374,12 +4414,16 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { function treeTableClipboardMenuItems(node: TreeNode): ContextMenuItem[] { const copyItem: ContextMenuItem = { label: t("contextMenu.copyTable"), action: copySelectedNames, icon: Copy }; if (!node.connectionId || !node.database) return [copyItem]; - const state = tableClipboardMenuState(normalizedTreeClipboardTableEntries(), { - connectionId: node.connectionId, - database: node.database, - schema: normalizeTreeClipboardSchema(node.connectionId, node.database, node.schema), - tableName: node.label, - }); + const state = tableClipboardMenuState( + normalizedTreeClipboardTableEntries(), + { + connectionId: node.connectionId, + database: node.database, + schema: normalizeTreeClipboardSchema(node.connectionId, node.database, node.schema), + tableName: node.label, + }, + canTransferTreeClipboardToCurrentNode(), + ); if (state === "copy") return [copyItem]; const pasteItem: ContextMenuItem = { label: t("contextMenu.pasteTable"), action: openPasteTableDialog, icon: Clipboard }; return state === "paste" ? [pasteItem] : [copyItem, pasteItem]; @@ -4397,7 +4441,7 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea if (canOpenTableImport.value) { items.push({ label: t("contextMenu.importData"), action: openTableImport, icon: Upload }); } - if (canPasteTreeClipboardToCurrentNode()) { + if (canPasteTreeClipboardToCurrentNode() || canTransferTreeClipboardToCurrentNode()) { items.push({ label: t("contextMenu.pasteTable"), action: openPasteTableDialog, icon: Clipboard }); } } diff --git a/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts b/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts new file mode 100644 index 0000000000..d6820d8b86 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const runtimeSource = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8"); + +describe("cross-database table paste", () => { + it("routes a table clipboard from another context to data transfer", () => { + expect(runtimeSource).toContain("function canTransferTreeClipboardToCurrentNode()"); + expect(runtimeSource).toContain("function openTransferFromTreeClipboard()"); + expect(runtimeSource).toContain("targetConnectionId: target.connectionId"); + expect(runtimeSource).toContain("targetDatabase: target.database"); + expect(runtimeSource).toContain("tables: clipboard.tables.map((entry) => entry.tableName)"); + expect(runtimeSource).toMatch(/function treeTableClipboardMenuItems[\s\S]*?tableClipboardMenuState\([\s\S]*?canTransferTreeClipboardToCurrentNode\(\),\s*\)/); + }); + + it("retains the local duplicate-table paste path", () => { + expect(runtimeSource).toContain("if (canTransferTreeClipboardToCurrentNode()) return openTransferFromTreeClipboard();"); + expect(runtimeSource).toContain("pasteTableMode.value = defaultPasteTableMode(currentDatabaseType());"); + }); +}); diff --git a/apps/desktop/src/components/transfer/DataTransferDialog.vue b/apps/desktop/src/components/transfer/DataTransferDialog.vue index 1b244bf2e0..90ad1b1b96 100644 --- a/apps/desktop/src/components/transfer/DataTransferDialog.vue +++ b/apps/desktop/src/components/transfer/DataTransferDialog.vue @@ -26,6 +26,11 @@ const open = defineModel("open", { default: false }); const props = defineProps<{ prefillConnectionId?: string; prefillDatabase?: string; + prefillSchema?: string; + prefillTables?: string[]; + prefillTargetConnectionId?: string; + prefillTargetDatabase?: string; + prefillTargetSchema?: string; }>(); const store = useConnectionStore(); @@ -42,6 +47,8 @@ const sourceTables = ref([]); const selectedTables = ref>(new Set()); const tableSearch = ref(""); const loadingTables = ref(false); +const pendingSourceSchemaPrefill = ref(""); +const pendingSelectedTablesPrefill = ref(null); // Target state const targetConnectionId = ref(""); @@ -49,6 +56,7 @@ const targetDatabase = ref(""); const targetDatabases = ref([]); const targetSchemas = ref([]); const targetSchema = ref(""); +const pendingTargetSchemaPrefill = ref(""); // Options const createTable = ref(true); @@ -77,7 +85,12 @@ function isMongoConnection(id: string): boolean { return connectionType(id) === "mongodb"; } -const canStart = computed(() => sourceConnectionId.value && sourceDatabase.value && targetConnectionId.value && targetDatabase.value && selectedTables.value.size > 0 && sourceConnectionId.value + sourceDatabase.value !== targetConnectionId.value + targetDatabase.value); +const canStart = computed(() => { + const effectiveSourceSchema = sourceSchema.value || sourceDatabase.value; + const effectiveTargetSchema = targetSchema.value || targetDatabase.value; + const sameSourceAndTarget = sourceConnectionId.value === targetConnectionId.value && sourceDatabase.value === targetDatabase.value && effectiveSourceSchema === effectiveTargetSchema; + return sourceConnectionId.value && sourceDatabase.value && targetConnectionId.value && targetDatabase.value && selectedTables.value.size > 0 && !sameSourceAndTarget; +}); function toggleSelectAll() { if (allSelected.value) { @@ -148,6 +161,12 @@ async function loadSchemas(connectionId: string, database: string, side: "source } } +function applyPendingTableSelection() { + const pending = pendingSelectedTablesPrefill.value; + selectedTables.value = pending ? new Set(sourceTables.value.filter((table) => pending.includes(table))) : new Set(sourceTables.value); + pendingSelectedTablesPrefill.value = null; +} + async function loadTables() { if (!sourceConnectionId.value || !sourceDatabase.value) { sourceTables.value = []; @@ -157,7 +176,7 @@ async function loadTables() { try { if (isMongoConnection(sourceConnectionId.value)) { sourceTables.value = (await api.mongoListCollections(sourceConnectionId.value, sourceDatabase.value)).map((c) => c.name); - selectedTables.value = new Set(sourceTables.value); + applyPendingTableSelection(); return; } const config = store.getConfig(sourceConnectionId.value); @@ -165,7 +184,7 @@ async function loadTables() { const schema = needsSchema && sourceSchema.value ? sourceSchema.value : sourceDatabase.value; const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema); sourceTables.value = tables.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE").map((t) => t.name); - selectedTables.value = new Set(sourceTables.value); + applyPendingTableSelection(); } catch { sourceTables.value = []; } finally { @@ -174,6 +193,7 @@ async function loadTables() { } const skipSourceWatch = ref(false); +const skipTargetWatch = ref(false); watch(sourceConnectionId, (id) => { if (skipSourceWatch.value) { @@ -183,6 +203,8 @@ watch(sourceConnectionId, (id) => { sourceDatabase.value = ""; sourceTables.value = []; selectedTables.value.clear(); + pendingSourceSchemaPrefill.value = ""; + pendingSelectedTablesPrefill.value = null; loadDatabases(id, "source"); }); @@ -195,7 +217,8 @@ watch(sourceDatabase, async (db) => { sourceSchemas.value = []; sourceSchema.value = db; } else if (isSchemaAware(config?.db_type)) { - await loadSchemas(sourceConnectionId.value, db, "source"); + await loadSchemas(sourceConnectionId.value, db, "source", pendingSourceSchemaPrefill.value); + pendingSourceSchemaPrefill.value = ""; } else { sourceSchema.value = db; } @@ -205,6 +228,10 @@ watch(sourceDatabase, async (db) => { watch(sourceSchema, () => loadTables()); watch(targetConnectionId, (id) => { + if (skipTargetWatch.value) { + skipTargetWatch.value = false; + return; + } targetDatabase.value = ""; targetSchemas.value = []; targetSchema.value = ""; @@ -218,7 +245,8 @@ watch(targetDatabase, async (db) => { targetSchemas.value = []; targetSchema.value = db; } else if (isSchemaAware(config?.db_type)) { - await loadSchemas(targetConnectionId.value, db, "target"); + await loadSchemas(targetConnectionId.value, db, "target", pendingTargetSchemaPrefill.value); + pendingTargetSchemaPrefill.value = ""; } else { targetSchema.value = db; } @@ -230,13 +258,20 @@ watch( async (val) => { if (val) { resetState(); + pendingSourceSchemaPrefill.value = props.prefillSchema ?? ""; + pendingSelectedTablesPrefill.value = props.prefillTables?.length ? [...props.prefillTables] : null; + pendingTargetSchemaPrefill.value = props.prefillTargetSchema ?? ""; if (props.prefillConnectionId) { skipSourceWatch.value = true; sourceConnectionId.value = props.prefillConnectionId; await loadDatabases(props.prefillConnectionId, "source"); - if (props.prefillDatabase) { - sourceDatabase.value = props.prefillDatabase; - } + if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase; + } + if (props.prefillTargetConnectionId) { + skipTargetWatch.value = true; + targetConnectionId.value = props.prefillTargetConnectionId; + await loadDatabases(props.prefillTargetConnectionId, "target"); + if (props.prefillTargetDatabase) targetDatabase.value = props.prefillTargetDatabase; } } }, @@ -251,12 +286,15 @@ function resetState() { sourceSchema.value = ""; sourceTables.value = []; selectedTables.value.clear(); + pendingSourceSchemaPrefill.value = ""; + pendingSelectedTablesPrefill.value = null; tableSearch.value = ""; targetConnectionId.value = ""; targetDatabase.value = ""; targetDatabases.value = []; targetSchemas.value = []; targetSchema.value = ""; + pendingTargetSchemaPrefill.value = ""; createTable.value = true; transferMode.value = "append"; targetTableNameCase.value = "preserve"; diff --git a/apps/desktop/src/components/transfer/__tests__/DataTransferDialog.spec.ts b/apps/desktop/src/components/transfer/__tests__/DataTransferDialog.spec.ts index 427e972b13..5e07587ab4 100644 --- a/apps/desktop/src/components/transfer/__tests__/DataTransferDialog.spec.ts +++ b/apps/desktop/src/components/transfer/__tests__/DataTransferDialog.spec.ts @@ -20,3 +20,23 @@ describe("DataTransferDialog layout", () => { expect(dialogSource).toContain('class="min-h-0 max-h-[200px] overflow-y-auto rounded-md border"'); }); }); + +describe("DataTransferDialog transfer prefill", () => { + it("accepts source, target, schema, and selected-table prefills", () => { + expect(dialogSource).toContain("prefillSchema?: string;"); + expect(dialogSource).toContain("prefillTables?: string[];"); + expect(dialogSource).toContain("prefillTargetConnectionId?: string;"); + expect(dialogSource).toContain("prefillTargetDatabase?: string;"); + expect(dialogSource).toContain("prefillTargetSchema?: string;"); + }); + + it("keeps only copied tables selected after loading the source list", () => { + expect(dialogSource).toContain("function applyPendingTableSelection()"); + expect(dialogSource).toContain("new Set(sourceTables.value.filter((table) => pending.includes(table)))"); + }); + + it("allows a transfer between different schemas in the same database", () => { + expect(dialogSource).toContain("const sameSourceAndTarget = sourceConnectionId.value === targetConnectionId.value"); + expect(dialogSource).toContain("effectiveSourceSchema === effectiveTargetSchema"); + }); +}); diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts index 8d75230790..b9eaeb7524 100644 --- a/apps/desktop/src/composables/useDialogSources.ts +++ b/apps/desktop/src/composables/useDialogSources.ts @@ -23,6 +23,11 @@ const pendingImportContent = ref(""); const transferPrefillConnectionId = ref(""); const transferPrefillDatabase = ref(""); +const transferPrefillSchema = ref(""); +const transferPrefillTables = ref([]); +const transferPrefillTargetConnectionId = ref(""); +const transferPrefillTargetDatabase = ref(""); +const transferPrefillTargetSchema = ref(""); const schemaDiffPrefillConnectionId = ref(""); const schemaDiffPrefillDatabase = ref(""); const schemaDiffPrefillSchema = ref(""); @@ -62,6 +67,16 @@ const databaseExportAllDatabases = ref(false); let watchersRegistered = false; +function clearTransferPrefill() { + transferPrefillConnectionId.value = ""; + transferPrefillDatabase.value = ""; + transferPrefillSchema.value = ""; + transferPrefillTables.value = []; + transferPrefillTargetConnectionId.value = ""; + transferPrefillTargetDatabase.value = ""; + transferPrefillTargetSchema.value = ""; +} + export function useDialogSources() { const { t } = useI18n(); const connectionStore = useConnectionStore(); @@ -77,12 +92,21 @@ export function useDialogSources() { if (v) { transferPrefillConnectionId.value = v.connectionId; transferPrefillDatabase.value = v.database; + transferPrefillSchema.value = v.schema ?? ""; + transferPrefillTables.value = v.tables ?? []; + transferPrefillTargetConnectionId.value = v.targetConnectionId ?? ""; + transferPrefillTargetDatabase.value = v.targetDatabase ?? ""; + transferPrefillTargetSchema.value = v.targetSchema ?? ""; showTransferDialog.value = true; connectionStore.transferSource = null; } }, ); + watch(showTransferDialog, (open) => { + if (!open) clearTransferPrefill(); + }); + watch( () => connectionStore.schemaDiffSource, (v) => { @@ -307,6 +331,11 @@ export function useDialogSources() { pendingImportContent, transferPrefillConnectionId, transferPrefillDatabase, + transferPrefillSchema, + transferPrefillTables, + transferPrefillTargetConnectionId, + transferPrefillTargetDatabase, + transferPrefillTargetSchema, schemaDiffPrefillConnectionId, schemaDiffPrefillDatabase, schemaDiffPrefillSchema, diff --git a/apps/desktop/src/lib/table/__tests__/tableClipboard.spec.ts b/apps/desktop/src/lib/table/__tests__/tableClipboard.spec.ts new file mode 100644 index 0000000000..dc13df3c06 --- /dev/null +++ b/apps/desktop/src/lib/table/__tests__/tableClipboard.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext } from "@/lib/table/tableClipboard"; + +describe("table clipboard contexts", () => { + const source = { connectionId: "source", database: "app", schema: "public" }; + + it("returns the shared source context for tables copied together", () => { + expect(tableClipboardSourceContext([source, { ...source }])).toEqual(source); + }); + + it("rejects a clipboard that combines different source contexts", () => { + expect(tableClipboardSourceContext([source, { ...source, schema: "audit" }])).toBeNull(); + }); + + it("distinguishes a cross-schema target from the source", () => { + expect(tableClipboardMatchesTarget([source], { ...source, schema: "archive" })).toBe(false); + }); + + it("shows paste for a transferable clipboard from another context", () => { + const sourceTable = { ...source, tableName: "users" }; + const targetTable = { ...sourceTable, database: "archive" }; + + expect(tableClipboardMenuState([sourceTable], targetTable)).toBe("copy"); + expect(tableClipboardMenuState([sourceTable], targetTable, true)).toBe("copy-and-paste"); + }); +}); diff --git a/apps/desktop/src/lib/table/tableClipboard.ts b/apps/desktop/src/lib/table/tableClipboard.ts index 0312c8688f..11e038afc8 100644 --- a/apps/desktop/src/lib/table/tableClipboard.ts +++ b/apps/desktop/src/lib/table/tableClipboard.ts @@ -32,13 +32,27 @@ export function tableClipboardMatchesTarget(entries: TableClipboardContext[], ta return !!target && entries.length > 0 && entries.every((entry) => tableClipboardEntryMatchesTarget(entry, target)); } +/** + * Returns the common source context for a copied table collection. Cross-database + * transfer can only use one source connection/database/schema per request. + */ +export function tableClipboardSourceContext(entries: TableClipboardContext[]): TableClipboardContext | null { + const source = entries[0]; + if (!source || !entries.every((entry) => tableClipboardEntryMatchesTarget(entry, source))) return null; + return { + connectionId: source.connectionId, + database: source.database, + schema: source.schema, + }; +} + export function tableClipboardMatchesSingleSource(entries: TableClipboardTableContext[], source: TableClipboardTableContext): boolean { return entries.length === 1 && tableClipboardEntryMatchesTarget(entries[0]!, source) && entries[0]!.tableName === source.tableName; } -export function tableClipboardMenuState(entries: TableClipboardTableContext[], source: TableClipboardTableContext): TableClipboardMenuState { - if (!tableClipboardMatchesTarget(entries, source)) return "copy"; - return tableClipboardMatchesSingleSource(entries, source) ? "paste" : "copy-and-paste"; +export function tableClipboardMenuState(entries: TableClipboardTableContext[], target: TableClipboardTableContext, canPasteAcrossContext = false): TableClipboardMenuState { + if (!tableClipboardMatchesTarget(entries, target)) return canPasteAcrossContext && entries.length > 0 ? "copy-and-paste" : "copy"; + return tableClipboardMatchesSingleSource(entries, target) ? "paste" : "copy-and-paste"; } export function supportsWholeRowTableDataCopy(databaseType: DatabaseType | undefined): boolean { diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 71c0e698f7..760240ebf6 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -355,7 +355,15 @@ export const useConnectionStore = defineStore("connection", () => { const completionMetadataLimiter = new MetadataTaskLimiter(COMPLETION_METADATA_CONCURRENCY, (event) => { console.debug("[DBX][completion-metadata:limit]", event); }); - const transferSource = ref<{ connectionId: string; database: string } | null>(null); + const transferSource = ref<{ + connectionId: string; + database: string; + schema?: string; + tables?: string[]; + targetConnectionId?: string; + targetDatabase?: string; + targetSchema?: string; + } | null>(null); const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null); const dataCompareSource = ref<{ connectionId: string; diff --git a/packages/app-tests/sidebarContextMenuHost.test.ts b/packages/app-tests/sidebarContextMenuHost.test.ts index 61e7ab4aee..dffc9e86c3 100644 --- a/packages/app-tests/sidebarContextMenuHost.test.ts +++ b/packages/app-tests/sidebarContextMenuHost.test.ts @@ -77,7 +77,7 @@ test("table copy menu uses the shared single and multi-selection clipboard path" const copySelectedNamesBody = functionBody(runtimeHost, "copySelectedNames"); const clipboardMenuBody = functionBody(runtimeHost, "treeTableClipboardMenuItems"); - assert.match(clipboardMenuBody, /tableClipboardMenuState\(normalizedTreeClipboardTableEntries\(\)/); + assert.match(clipboardMenuBody, /tableClipboardMenuState\(\s*normalizedTreeClipboardTableEntries\(\)/); assert.match(clipboardMenuBody, /state === "paste" \? \[pasteItem\] : \[copyItem, pasteItem\]/); assert.match(runtimeHost, /items\.push\(\.\.\.treeTableClipboardMenuItems\(node\)\)/); assert.doesNotMatch(runtimeHost, /function copyTableToClipboard\(/); diff --git a/packages/app-tests/tableClipboard.test.ts b/packages/app-tests/tableClipboard.test.ts index 6e36303ed0..362907afae 100644 --- a/packages/app-tests/tableClipboard.test.ts +++ b/packages/app-tests/tableClipboard.test.ts @@ -30,6 +30,7 @@ test("table clipboard menu state supports paste and replacing the copied source" assert.equal(tableClipboardMenuState([users], users), "paste"); assert.equal(tableClipboardMenuState([users], { ...users, tableName: "orders" }), "copy-and-paste"); assert.equal(tableClipboardMenuState([{ ...users, schema: "audit" }], users), "copy"); + assert.equal(tableClipboardMenuState([{ ...users, schema: "audit" }], users, true), "copy-and-paste"); }); test("whole-row table data copy is enabled for known database types", () => {