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", () => {