feat(transfer): support cross-database table paste

This commit is contained in:
zhangsan authored and GitHub committed 2026-07-29 21:07:11 +08:00
1 parent d928412bf6
commit 551f696cd5
14 files changed
+309 -43

No files matched your search

@@ -174,7 +174,16 @@ watch(
@update:open="emit('update:showSqlParameterDialog', $event)"
@execute="emit('sqlParametersConfirm', $event)"
/>
<DataTransferDialog v-model:open="dialogs.showTransferDialog.value" :prefill-connection-id="dialogs.transferPrefillConnectionId.value" :prefill-database="dialogs.transferPrefillDatabase.value" />
<DataTransferDialog
v-model:open="dialogs.showTransferDialog.value"
:prefill-connection-id="dialogs.transferPrefillConnectionId.value"
:prefill-database="dialogs.transferPrefillDatabase.value"
:prefill-schema="dialogs.transferPrefillSchema.value"
:prefill-tables="dialogs.transferPrefillTables.value"
:prefill-target-connection-id="dialogs.transferPrefillTargetConnectionId.value"
:prefill-target-database="dialogs.transferPrefillTargetDatabase.value"
:prefill-target-schema="dialogs.transferPrefillTargetSchema.value"
/>
<SchemaDiffDialog v-if="dialogs.showSchemaDiffDialog.value" v-model:open="dialogs.showSchemaDiffDialog.value" :prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value" :prefill-database="dialogs.schemaDiffPrefillDatabase.value" :prefill-schema="dialogs.schemaDiffPrefillSchema.value" />
<DataCompareDialog
v-if="dialogs.showDataCompareDialog.value"
@@ -56,7 +56,7 @@ import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDia
import * as api from "@/lib/backend/api";
import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities";
import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
@@ -79,7 +79,7 @@ import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRe
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/table/tableClipboard";
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/table/tableClipboard";
import { formatSqlInsert } from "@/lib/export/exportFormats";
import { buildSingleDdlExportFileContent } from "@/lib/export/ddlExport";
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
@@ -1922,10 +1922,19 @@ function normalizedObjectBrowserTableClipboardEntries() {
if (clipboard?.kind !== "table-copy") return [];
return clipboard.tables.map((entry) => ({
...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[] {
@@ -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", () => {
@@ -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);");
});
});
@@ -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 });
}
}
@@ -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());");
});
});
@@ -26,6 +26,11 @@ const open = defineModel<boolean>("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<string[]>([]);
const selectedTables = ref<Set<string>>(new Set());
const tableSearch = ref("");
const loadingTables = ref(false);
const pendingSourceSchemaPrefill = ref("");
const pendingSelectedTablesPrefill = ref<string[] | null>(null);
// Target state
const targetConnectionId = ref("");
@@ -49,6 +56,7 @@ const targetDatabase = ref("");
const targetDatabases = ref<string[]>([]);
const targetSchemas = ref<string[]>([]);
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";
@@ -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");
});
});
@@ -23,6 +23,11 @@ const pendingImportContent = ref("");
const transferPrefillConnectionId = ref("");
const transferPrefillDatabase = ref("");
const transferPrefillSchema = ref("");
const transferPrefillTables = ref<string[]>([]);
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,
@@ -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");
});
});
+17 -3
View File
@@ -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 {
+9 -1
View File
@@ -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;