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

@@ -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);");
});
});