fix(desktop): align table copy and paste menus
This commit is contained in:
parent
5cc9d63cad
commit
8841a41261
|
|
@ -46,6 +46,7 @@ import {
|
|||
X,
|
||||
} from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
|
@ -78,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, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/table/tableClipboard";
|
||||
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, 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";
|
||||
|
|
@ -1902,7 +1903,7 @@ function copySelectedTablesToClipboard() {
|
|||
tables: selectedRows.map((row) => ({
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(row.schema || selectedSchema.value),
|
||||
tableName: row.name,
|
||||
})),
|
||||
};
|
||||
|
|
@ -1910,18 +1911,31 @@ function copySelectedTablesToClipboard() {
|
|||
}
|
||||
|
||||
function canPasteTableClipboard(): boolean {
|
||||
return tableClipboardMatchesTarget(normalizedObjectBrowserTableClipboardEntries(), pasteTableTargetContext());
|
||||
}
|
||||
|
||||
function normalizedObjectBrowserTableClipboardEntries() {
|
||||
const clipboard = connectionStore.treeClipboard;
|
||||
return clipboard?.kind === "table-copy" && tableClipboardMatchesTarget(clipboard.tables, pasteTableTargetContext());
|
||||
if (clipboard?.kind !== "table-copy") return [];
|
||||
return clipboard.tables.map((entry) => ({
|
||||
...entry,
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database),
|
||||
}));
|
||||
}
|
||||
|
||||
function pasteTableTargetContext(): TableClipboardContext {
|
||||
return {
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema: selectedSchema.value,
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(selectedSchema.value),
|
||||
};
|
||||
}
|
||||
|
||||
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 copySingleTableToClipboard(row: ObjectBrowserRow) {
|
||||
connectionStore.treeClipboard = {
|
||||
kind: "table-copy",
|
||||
|
|
@ -1929,7 +1943,7 @@ function copySingleTableToClipboard(row: ObjectBrowserRow) {
|
|||
{
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(row.schema || selectedSchema.value),
|
||||
tableName: row.name,
|
||||
},
|
||||
],
|
||||
|
|
@ -1947,7 +1961,7 @@ function openPasteTableDialog() {
|
|||
pasteTableEntries.value = clipboard.tables.map((entry) => ({
|
||||
sourceName: entry.tableName,
|
||||
targetName: `${entry.tableName}_copy`,
|
||||
schema: entry.schema,
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database),
|
||||
}));
|
||||
showPasteDialog.value = true;
|
||||
}
|
||||
|
|
@ -1972,11 +1986,14 @@ function onObjectBrowserKeydown(event: KeyboardEvent) {
|
|||
async function confirmPasteTable() {
|
||||
const entries = pasteTableEntries.value.filter((entry) => entry.targetName.trim());
|
||||
if (entries.length === 0) return;
|
||||
const clipboardAtPasteStart = connectionStore.treeClipboard;
|
||||
const mode = pasteTableMode.value;
|
||||
const copyData = pasteTableModeCopiesData(mode) && pasteTableDataCopySupported.value;
|
||||
showPasteDialog.value = false;
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let pasteCancelled = false;
|
||||
let hasMutatedTable = false;
|
||||
for (const entry of entries) {
|
||||
const targetName = entry.targetName.trim();
|
||||
const schema = entry.schema || selectedSchema.value;
|
||||
|
|
@ -1986,7 +2003,11 @@ async function confirmPasteTable() {
|
|||
const plan = await buildDuplicateStructurePlan(entry.sourceName, targetName, schema, sourceColumns);
|
||||
sourceColumns = plan.sourceColumns;
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(plan.sql, () => executeDuplicateStructurePlan(plan, schema));
|
||||
if (!executed) return;
|
||||
if (!executed) {
|
||||
pasteCancelled = true;
|
||||
break;
|
||||
}
|
||||
hasMutatedTable = true;
|
||||
}
|
||||
if (copyData) {
|
||||
sourceColumns ??= await api.getColumns(props.connection.id, props.database, schema || "", entry.sourceName, props.catalog);
|
||||
|
|
@ -2002,7 +2023,11 @@ async function confirmPasteTable() {
|
|||
...dataCopyColumnOptions,
|
||||
});
|
||||
const executed = await executeObjectBrowserSqlWithProductionGuard(dataSql, () => api.executeQuery(props.connection.id, props.database, dataSql, schema));
|
||||
if (!executed) return;
|
||||
if (!executed) {
|
||||
pasteCancelled = true;
|
||||
break;
|
||||
}
|
||||
hasMutatedTable = true;
|
||||
}
|
||||
successCount++;
|
||||
} catch (e: any) {
|
||||
|
|
@ -2010,7 +2035,22 @@ async function confirmPasteTable() {
|
|||
console.error(`Failed to paste table "${entry.sourceName}" -> "${targetName}":`, e);
|
||||
}
|
||||
}
|
||||
if (pasteCancelled) {
|
||||
if (hasMutatedTable) {
|
||||
try {
|
||||
await reload();
|
||||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
|
||||
toast(t("contextMenu.pasteTableCancelledAfterPartial"), 5000);
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.pasteTableRefreshFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (failCount === 0) {
|
||||
if (connectionStore.treeClipboard === clipboardAtPasteStart) {
|
||||
connectionStore.treeClipboard = null;
|
||||
}
|
||||
toast(t("contextMenu.batchPasteSuccess", { count: successCount }), 3000);
|
||||
} else {
|
||||
toast(t("contextMenu.batchPastePartialFail", { success: successCount, failed: failCount }), 5000);
|
||||
|
|
@ -2425,6 +2465,23 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
function tableClipboardMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
const copyItem: ContextMenuItem = { label: t("contextMenu.copyTable"), action: () => copySingleTableToClipboard(item), icon: Copy };
|
||||
const state = objectBrowserTableClipboardMenuState(item);
|
||||
if (state === "copy") return [copyItem];
|
||||
const pasteItem: ContextMenuItem = { label: t("contextMenu.pasteTable"), action: openPasteTableDialog, icon: Clipboard };
|
||||
return state === "paste" ? [pasteItem] : [copyItem, pasteItem];
|
||||
}
|
||||
|
||||
function isSelectedBatchTableContext(item: ObjectBrowserRow): boolean {
|
||||
return item.type === "TABLE" && selectedTableCount.value > 1 && selectedTableIds.value.has(item.id);
|
||||
}
|
||||
|
|
@ -2454,7 +2511,7 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
{ label: t("contextMenu.exportStructure"), action: () => exportStructure(item), icon: FileCode },
|
||||
{ label: "", separator: true },
|
||||
{ label: t("contextMenu.duplicateStructure"), action: () => requestDuplicateStructure(item), icon: CopyPlus },
|
||||
{ label: t("contextMenu.copyTable"), action: () => copySingleTableToClipboard(item), icon: Copy },
|
||||
...tableClipboardMenuItems(item),
|
||||
{ label: "", separator: true },
|
||||
...(supportsTruncateTable.value
|
||||
? [
|
||||
|
|
@ -2768,7 +2825,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
</div>
|
||||
<RecycleScroller ref="listScrollerRef" class="object-browser-scroller min-h-0 flex-1" :style="{ minWidth: `${objectGridMinWidth}px` }" :items="filteredRows" :item-size="34" :buffer="600" :skip-hover="true" key-field="id">
|
||||
<template #default="{ item }">
|
||||
<CustomContextMenu :items="getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
|
||||
<CustomContextMenu :items="() => getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
class="grid h-[34px] cursor-pointer items-center gap-3 border-b px-3 hover:bg-accent/50"
|
||||
:class="{
|
||||
|
|
@ -2827,7 +2884,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
<RecycleScroller ref="gridScrollerRef" v-if="gridRows.length > 0" class="object-browser-grid-scroller h-full" :items="gridRows" :item-size="objectGridRowHeight" :buffer="600" :skip-hover="true" key-field="key">
|
||||
<template #default="{ item: row }">
|
||||
<div class="object-browser-grid-row" :style="{ gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`, height: `${objectGridRowHeight - OBJECT_GRID_GAP}px` }">
|
||||
<CustomContextMenu v-for="item in row.cards" :key="item.id" :items="getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
|
||||
<CustomContextMenu v-for="item in row.cards" :key="item.id" :items="() => getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
class="relative flex h-full min-h-0 cursor-pointer flex-col items-center gap-1 rounded-lg border bg-card p-3 text-center transition-all hover:border-primary/40 hover:shadow-sm"
|
||||
:class="{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import objectBrowserSource from "./ObjectBrowser.vue?raw";
|
||||
|
||||
describe("ObjectBrowser table clipboard context menu", () => {
|
||||
it("resolves recycled row menus when they are opened", () => {
|
||||
const lazyMenuBindings = objectBrowserSource.match(/:items="\(\) => getObjectBrowserMenuItems\(item\)"/g);
|
||||
|
||||
expect(lazyMenuBindings).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps paste on the copied row and allows replacing it from another row", () => {
|
||||
expect(objectBrowserSource).toMatch(/function tableClipboardMenuItems\(item: ObjectBrowserRow\)[\s\S]*?objectBrowserTableClipboardMenuState\(item\)[\s\S]*?state === "copy"[\s\S]*?state === "paste" \? \[pasteItem\] : \[copyItem, pasteItem\]/);
|
||||
expect(objectBrowserSource).toContain("...tableClipboardMenuItems(item)");
|
||||
});
|
||||
|
||||
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 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/);
|
||||
});
|
||||
|
||||
it("consumes only the clipboard used by a fully successful paste", () => {
|
||||
expect(objectBrowserSource).toMatch(/async function confirmPasteTable\(\)[\s\S]*?const clipboardAtPasteStart = connectionStore\.treeClipboard[\s\S]*?if \(failCount === 0\)[\s\S]*?connectionStore\.treeClipboard === clipboardAtPasteStart[\s\S]*?connectionStore\.treeClipboard = null/);
|
||||
});
|
||||
|
||||
it("refreshes created tables and retains the clipboard when a later paste step is cancelled", () => {
|
||||
expect(objectBrowserSource).toMatch(/let pasteCancelled = false[\s\S]*?let hasMutatedTable = false/);
|
||||
expect(objectBrowserSource).toMatch(/if \(!executed\) \{[\s\S]*?pasteCancelled = true;[\s\S]*?break;/);
|
||||
expect(objectBrowserSource).toMatch(/if \(pasteCancelled\) \{[\s\S]*?if \(hasMutatedTable\)[\s\S]*?await reload\(\)[\s\S]*?refreshObjectListTreeNode[\s\S]*?pasteTableCancelledAfterPartial[\s\S]*?return;/);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@ import { copyToClipboard } from "@/lib/common/clipboard";
|
|||
import { connectionPasteTargetGroupId, copySelectedConnectionsToClipboards, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
|
||||
import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch";
|
||||
import { usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
|
||||
import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { activeTabSidebarTarget, findSidebarNodeForActiveTab, findSidebarNodeForTarget, findNodePathForTarget, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection, type ActiveTabSidebarTarget, type SidebarNodeScrollAlign } from "@/lib/sidebar/sidebarActiveTabTarget";
|
||||
import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget";
|
||||
import { createFlatTreeIndex, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree";
|
||||
|
|
@ -1528,7 +1528,7 @@ function copySelectedSidebarNames(): boolean {
|
|||
tables: tableNodes.map((node) => ({
|
||||
connectionId: node.connectionId!,
|
||||
database: node.database!,
|
||||
schema: node.schema,
|
||||
schema: connectionObjectTreeNodeSchema(store.getConfig(node.connectionId!), node.database!, node.schema),
|
||||
tableName: node.label,
|
||||
})),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,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, tableDataCopyColumnOptions, type TableClipboardContext } from "@/lib/table/tableClipboard";
|
||||
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, 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";
|
||||
|
|
@ -824,13 +824,25 @@ function pasteTableTargetContext(): TableClipboardContext | null {
|
|||
return {
|
||||
connectionId: activeNode.value.connectionId,
|
||||
database: activeNode.value.database,
|
||||
schema: activeNode.value.schema,
|
||||
schema: normalizeTreeClipboardSchema(activeNode.value.connectionId, activeNode.value.database, activeNode.value.schema),
|
||||
};
|
||||
}
|
||||
|
||||
function canPasteTreeClipboardToCurrentNode(): boolean {
|
||||
function normalizeTreeClipboardSchema(connectionId: string, database: string, schema?: string): string | undefined {
|
||||
return connectionObjectTreeNodeSchema(connectionStore.getConfig(connectionId), database, schema);
|
||||
}
|
||||
|
||||
function normalizedTreeClipboardTableEntries(): TableClipboardTableContext[] {
|
||||
const clipboard = connectionStore.treeClipboard;
|
||||
return clipboard?.kind === "table-copy" && tableClipboardMatchesTarget(clipboard.tables, pasteTableTargetContext());
|
||||
if (clipboard?.kind !== "table-copy") return [];
|
||||
return clipboard.tables.map((entry) => ({
|
||||
...entry,
|
||||
schema: normalizeTreeClipboardSchema(entry.connectionId, entry.database, entry.schema),
|
||||
}));
|
||||
}
|
||||
|
||||
function canPasteTreeClipboardToCurrentNode(): boolean {
|
||||
return tableClipboardMatchesTarget(normalizedTreeClipboardTableEntries(), pasteTableTargetContext());
|
||||
}
|
||||
|
||||
function requestPasteTreeClipboard(): boolean {
|
||||
|
|
@ -1291,7 +1303,6 @@ async function refresh() {
|
|||
|
||||
async function copyName() {
|
||||
const node = activeNode.value;
|
||||
updateTreeClipboardForNodes([node]);
|
||||
try {
|
||||
await copyToClipboard(copyNameForTreeNode(node));
|
||||
toast(t("connection.copied"), 2000);
|
||||
|
|
@ -1336,7 +1347,7 @@ function updateTreeClipboardForNodes(nodes: TreeNode[]) {
|
|||
tables: tableNodes.map((node) => ({
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
schema: node.schema,
|
||||
schema: normalizeTreeClipboardSchema(node.connectionId, node.database, node.schema),
|
||||
tableName: node.label,
|
||||
})),
|
||||
};
|
||||
|
|
@ -2882,12 +2893,25 @@ async function confirmDuplicateStructure() {
|
|||
async function confirmPasteTable() {
|
||||
const entries = pasteTableEntries.value.filter((entry) => entry.targetName.trim());
|
||||
if (entries.length === 0) return;
|
||||
const clipboardAtPasteStart = connectionStore.treeClipboard;
|
||||
const mode = pasteTableMode.value;
|
||||
const copyData = pasteTableModeCopiesData(mode) && pasteTableDataCopySupported.value;
|
||||
showPasteDialog.value = false;
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let pasteFailCount = 0;
|
||||
let refreshFailCount = 0;
|
||||
let refreshError: unknown;
|
||||
let pasteCancelled = false;
|
||||
let hasMutatedTable = false;
|
||||
const refreshTargets = new Map<string, { connectionId: string; database: string; schema?: string }>();
|
||||
const queueRefreshTarget = (entry: (typeof entries)[number]) => {
|
||||
const refreshKey = `${entry.connectionId}:${entry.database}:${entry.schema || ""}`;
|
||||
refreshTargets.set(refreshKey, {
|
||||
connectionId: entry.connectionId,
|
||||
database: entry.database,
|
||||
schema: entry.schema,
|
||||
});
|
||||
};
|
||||
for (const entry of entries) {
|
||||
const targetName = entry.targetName.trim();
|
||||
try {
|
||||
|
|
@ -2900,7 +2924,13 @@ async function confirmPasteTable() {
|
|||
sourceName: entry.sourceName,
|
||||
targetName,
|
||||
});
|
||||
await executeTreeNodeSqlWithProductionGuard(entry, structureSql, { database: entry.database, schema: entry.schema });
|
||||
const structureExecuted = await executeTreeNodeSqlWithProductionGuard(entry, structureSql, { database: entry.database, schema: entry.schema });
|
||||
if (!structureExecuted) {
|
||||
pasteCancelled = true;
|
||||
break;
|
||||
}
|
||||
hasMutatedTable = true;
|
||||
queueRefreshTarget(entry);
|
||||
}
|
||||
if (copyData) {
|
||||
const sourceColumns = await api.getColumns(entry.connectionId, entry.database, entry.schema || "", entry.sourceName);
|
||||
|
|
@ -2915,17 +2945,17 @@ async function confirmPasteTable() {
|
|||
targetName,
|
||||
...dataCopyColumnOptions,
|
||||
});
|
||||
await executeTreeNodeSqlWithProductionGuard(entry, dataSql, { database: entry.database, schema: entry.schema });
|
||||
const dataExecuted = await executeTreeNodeSqlWithProductionGuard(entry, dataSql, { database: entry.database, schema: entry.schema });
|
||||
if (!dataExecuted) {
|
||||
pasteCancelled = true;
|
||||
break;
|
||||
}
|
||||
hasMutatedTable = true;
|
||||
queueRefreshTarget(entry);
|
||||
}
|
||||
successCount++;
|
||||
const refreshKey = `${entry.connectionId}:${entry.database}:${entry.schema || ""}`;
|
||||
refreshTargets.set(refreshKey, {
|
||||
connectionId: entry.connectionId,
|
||||
database: entry.database,
|
||||
schema: entry.schema,
|
||||
});
|
||||
} catch (e: any) {
|
||||
failCount++;
|
||||
pasteFailCount++;
|
||||
console.error(`Failed to paste table "${entry.sourceName}" -> "${targetName}":`, e);
|
||||
}
|
||||
}
|
||||
|
|
@ -2933,14 +2963,32 @@ async function confirmPasteTable() {
|
|||
try {
|
||||
await connectionStore.refreshObjectListTreeNode(refreshTarget.connectionId, refreshTarget.database, refreshTarget.schema);
|
||||
} catch (e: any) {
|
||||
failCount++;
|
||||
refreshFailCount++;
|
||||
refreshError ??= e;
|
||||
console.error(`Failed to refresh pasted tables for "${refreshTarget.database}"${refreshTarget.schema ? ` schema "${refreshTarget.schema}"` : ""}:`, e);
|
||||
}
|
||||
}
|
||||
if (failCount === 0) {
|
||||
if (pasteCancelled) {
|
||||
if (hasMutatedTable && refreshFailCount === 0) {
|
||||
toast(t("contextMenu.pasteTableCancelledAfterPartial"), 5000);
|
||||
}
|
||||
if (refreshFailCount > 0) {
|
||||
const refreshMessage = refreshError instanceof Error ? refreshError.message : String(refreshError);
|
||||
toast(t("contextMenu.pasteTableRefreshFailed", { message: translateBackendError(t, refreshMessage) }), 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pasteFailCount === 0) {
|
||||
if (connectionStore.treeClipboard === clipboardAtPasteStart) {
|
||||
connectionStore.treeClipboard = null;
|
||||
}
|
||||
toast(t("contextMenu.batchPasteSuccess", { count: successCount }), 3000);
|
||||
} else {
|
||||
toast(t("contextMenu.batchPastePartialFail", { success: successCount, failed: failCount }), 5000);
|
||||
toast(t("contextMenu.batchPastePartialFail", { success: successCount, failed: pasteFailCount }), 5000);
|
||||
}
|
||||
if (refreshFailCount > 0) {
|
||||
const refreshMessage = refreshError instanceof Error ? refreshError.message : String(refreshError);
|
||||
toast(t("contextMenu.pasteTableRefreshFailed", { message: translateBackendError(t, refreshMessage) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4131,7 +4179,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
|||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.duplicateStructure"), action: duplicateStructure, icon: CopyPlus });
|
||||
// Keep menu copy aligned with keyboard copy so frozen multi-selection and single-row fallback stay compatible.
|
||||
items.push({ label: t("contextMenu.copyTable"), action: copySelectedNames, icon: Copy });
|
||||
items.push(...treeTableClipboardMenuItems(node));
|
||||
if (supportsTruncate.value) {
|
||||
destructiveActions.push({
|
||||
label: truncateMenuLabel(t("contextMenu.truncateTable")),
|
||||
|
|
@ -4264,6 +4312,20 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
if (state === "copy") return [copyItem];
|
||||
const pasteItem: ContextMenuItem = { label: t("contextMenu.pasteTable"), action: openPasteTableDialog, icon: Clipboard };
|
||||
return state === "paste" ? [pasteItem] : [copyItem, pasteItem];
|
||||
}
|
||||
|
||||
function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
const { node, items } = context;
|
||||
// 9. Group Labels (group-columns, group-tables, etc.)
|
||||
|
|
|
|||
|
|
@ -97,6 +97,74 @@ describe("CustomContextMenu lifecycle", () => {
|
|||
app.unmount();
|
||||
});
|
||||
|
||||
it("resolves lazy menu items again for every open", async () => {
|
||||
let copied = false;
|
||||
const items = vi.fn(() =>
|
||||
copied
|
||||
? [
|
||||
{
|
||||
label: "Paste Table",
|
||||
action: () => {
|
||||
copied = false;
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Copy Table",
|
||||
action: () => {
|
||||
copied = true;
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
const root = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
CustomContextMenu,
|
||||
{ items },
|
||||
{
|
||||
default: ({ onContextMenu }: { onContextMenu: (event: MouseEvent) => void }) => h("div", { id: "context-target", onContextmenu: onContextMenu }, "Target"),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
mountedContainers.push(container);
|
||||
document.body.append(container);
|
||||
const app = createApp(root);
|
||||
app.mount(container);
|
||||
|
||||
const target = container.querySelector("#context-target");
|
||||
target?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(document.body.textContent).toContain("Copy Table");
|
||||
|
||||
const copyAction = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("Copy Table"));
|
||||
copyAction?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(copied).toBe(true);
|
||||
|
||||
target?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(document.body.textContent).toContain("Paste Table");
|
||||
expect(document.body.textContent).not.toContain("Copy Table");
|
||||
|
||||
const pasteAction = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("Paste Table"));
|
||||
pasteAction?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(copied).toBe(false);
|
||||
|
||||
target?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(document.body.textContent).toContain("Copy Table");
|
||||
expect(document.body.textContent).not.toContain("Paste Table");
|
||||
expect(items).toHaveBeenCalledTimes(3);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("keeps the menu open when scrolling inside a scrollable submenu", async () => {
|
||||
const children = Array.from({ length: 40 }, (_, index) => ({ label: `Copy option ${index}` }));
|
||||
const root = defineComponent({
|
||||
|
|
|
|||
|
|
@ -2118,6 +2118,8 @@ export default {
|
|||
batchPasteTitle: "Paste Tables",
|
||||
batchPasteSuccess: "Successfully pasted {count} tables",
|
||||
batchPastePartialFail: "Pasted {success} tables, {failed} failed",
|
||||
pasteTableRefreshFailed: "Tables were pasted, but the object list could not be refreshed: {message}",
|
||||
pasteTableCancelledAfterPartial: "Paste was cancelled. Tables created before cancellation were refreshed; the clipboard was kept.",
|
||||
noTableToPaste: "No tables available to paste",
|
||||
pasteTableClipboardUpdated: "Table copied to clipboard",
|
||||
createDatabase: "Create Database",
|
||||
|
|
|
|||
|
|
@ -2054,6 +2054,8 @@ export default withEnglishFallback({
|
|||
batchPasteTitle: "Pegar tablas",
|
||||
batchPasteSuccess: "{count} tablas pegadas correctamente",
|
||||
batchPastePartialFail: "{success} tablas pegadas, {failed} fallidas",
|
||||
pasteTableRefreshFailed: "Las tablas se pegaron, pero no se pudo actualizar la lista de objetos: {message}",
|
||||
pasteTableCancelledAfterPartial: "Se canceló el pegado. Las tablas creadas antes de cancelarlo se actualizaron y se conservó el portapapeles.",
|
||||
noTableToPaste: "No hay tablas disponibles para pegar",
|
||||
pasteTableClipboardUpdated: "Tabla copiada al portapapeles",
|
||||
createDatabase: "Crear base de datos",
|
||||
|
|
|
|||
|
|
@ -2052,6 +2052,8 @@ export default withEnglishFallback({
|
|||
batchPasteTitle: "Incolla tabelle",
|
||||
batchPasteSuccess: "{count} tabelle incollate correttamente",
|
||||
batchPastePartialFail: "{success} tabelle incollate, {failed} fallite",
|
||||
pasteTableRefreshFailed: "Le tabelle sono state incollate, ma non è stato possibile aggiornare l'elenco degli oggetti: {message}",
|
||||
pasteTableCancelledAfterPartial: "L'incollaggio è stato annullato. Le tabelle create prima dell'annullamento sono state aggiornate e gli appunti sono stati mantenuti.",
|
||||
noTableToPaste: "Nessuna tabella disponibile da incollare",
|
||||
pasteTableClipboardUpdated: "Tabella copiata negli appunti",
|
||||
createDatabase: "Crea Database",
|
||||
|
|
|
|||
|
|
@ -2049,6 +2049,8 @@ export default withEnglishFallback({
|
|||
batchPasteTitle: "テーブルの貼り付け",
|
||||
batchPasteSuccess: "{count}テーブルを貼り付けました",
|
||||
batchPastePartialFail: "{success}テーブルを貼り付け、{failed}テーブルに失敗しました",
|
||||
pasteTableRefreshFailed: "テーブルは貼り付けられましたが、オブジェクト一覧を更新できませんでした: {message}",
|
||||
pasteTableCancelledAfterPartial: "貼り付けをキャンセルしました。キャンセル前に作成されたテーブルは更新され、クリップボードは保持されています。",
|
||||
noTableToPaste: "貼り付け可能なテーブルがありません",
|
||||
pasteTableClipboardUpdated: "テーブルをクリップボードにコピーしました",
|
||||
createDatabase: "データベースを作成",
|
||||
|
|
|
|||
|
|
@ -2054,6 +2054,8 @@ export default withEnglishFallback({
|
|||
batchPasteTitle: "Colar tabelas",
|
||||
batchPasteSuccess: "{count} tabelas coladas com sucesso",
|
||||
batchPastePartialFail: "{success} tabelas coladas, {failed} falharam",
|
||||
pasteTableRefreshFailed: "As tabelas foram coladas, mas não foi possível atualizar a lista de objetos: {message}",
|
||||
pasteTableCancelledAfterPartial: "A colagem foi cancelada. As tabelas criadas antes do cancelamento foram atualizadas e a área de transferência foi mantida.",
|
||||
noTableToPaste: "Nenhuma tabela disponível para colar",
|
||||
pasteTableClipboardUpdated: "Tabela copiada para a área de transferência",
|
||||
createDatabase: "Criar Banco de Dados",
|
||||
|
|
|
|||
|
|
@ -2119,6 +2119,8 @@ export default withEnglishFallback({
|
|||
batchPasteTitle: "粘贴表",
|
||||
batchPasteSuccess: "成功粘贴 {count} 张表",
|
||||
batchPastePartialFail: "粘贴成功 {success} 张表,{failed} 张失败",
|
||||
pasteTableRefreshFailed: "表已粘贴,但对象列表刷新失败:{message}",
|
||||
pasteTableCancelledAfterPartial: "粘贴已取消。取消前已创建的表已刷新,复制状态已保留。",
|
||||
noTableToPaste: "没有可粘贴的表",
|
||||
pasteTableClipboardUpdated: "表已复制到剪贴板",
|
||||
createDatabase: "新建数据库",
|
||||
|
|
|
|||
|
|
@ -2053,6 +2053,8 @@ export default withEnglishFallback({
|
|||
batchPasteTitle: "貼上資料表",
|
||||
batchPasteSuccess: "成功貼上 {count} 張資料表",
|
||||
batchPastePartialFail: "貼上成功 {success} 張資料表,{failed} 張失敗",
|
||||
pasteTableRefreshFailed: "資料表已貼上,但無法重新整理物件清單:{message}",
|
||||
pasteTableCancelledAfterPartial: "貼上已取消。取消前已建立的資料表已重新整理,剪貼簿狀態已保留。",
|
||||
noTableToPaste: "沒有可貼上的資料表",
|
||||
pasteTableClipboardUpdated: "資料表已複製到剪貼簿",
|
||||
createDatabase: "建立資料庫",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ export interface TableClipboardContext {
|
|||
schema?: string | null;
|
||||
}
|
||||
|
||||
export interface TableClipboardTableContext extends TableClipboardContext {
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export type TableClipboardMenuState = "copy" | "paste" | "copy-and-paste";
|
||||
|
||||
export interface TableDataCopyColumnOptions {
|
||||
columns: string[];
|
||||
postgresOverridingSystemValue: boolean;
|
||||
|
|
@ -26,6 +32,15 @@ export function tableClipboardMatchesTarget(entries: TableClipboardContext[], ta
|
|||
return !!target && entries.length > 0 && entries.every((entry) => tableClipboardEntryMatchesTarget(entry, target));
|
||||
}
|
||||
|
||||
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 supportsWholeRowTableDataCopy(databaseType: DatabaseType | undefined): boolean {
|
||||
return !!databaseType;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,16 +62,60 @@ test("tree host owns sidebar data-open generations", () => {
|
|||
|
||||
test("table copy menu uses the shared single and multi-selection clipboard path", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const copyNameBody = functionBody(runtimeHost, "copyName");
|
||||
const copySelectedNamesBody = functionBody(runtimeHost, "copySelectedNames");
|
||||
const clipboardMenuBody = functionBody(runtimeHost, "treeTableClipboardMenuItems");
|
||||
|
||||
assert.match(runtimeHost, /label: t\("contextMenu\.copyTable"\), action: copySelectedNames, icon: Copy/);
|
||||
assert.match(clipboardMenuBody, /tableClipboardMenuState\(normalizedTreeClipboardTableEntries\(\)/);
|
||||
assert.match(clipboardMenuBody, /state === "paste" \? \[pasteItem\] : \[copyItem, pasteItem\]/);
|
||||
assert.match(runtimeHost, /items\.push\(\.\.\.treeTableClipboardMenuItems\(node\)\)/);
|
||||
assert.doesNotMatch(runtimeHost, /function copyTableToClipboard\(/);
|
||||
assert.doesNotMatch(copyNameBody, /updateTreeClipboardForNodes/);
|
||||
assert.match(copySelectedNamesBody, /const selectedNodes = selectedTreeNodesInVisibleOrder\(\)/);
|
||||
assert.match(copySelectedNamesBody, /selectedNodes\.length > 1 && selectedNodes\.some\(\(node\) => node\.id === activeNode\.value\.id\) \? selectedNodes : \[activeNode\.value\]/);
|
||||
assert.match(copySelectedNamesBody, /updateTreeClipboardForNodes\(nodes\)/);
|
||||
assert.match(copySelectedNamesBody, /copyToClipboard\(nodes\.map\(copyNameForTreeNode\)\.join\("\\n"\)\)/);
|
||||
});
|
||||
|
||||
test("successful tree table paste consumes only the clipboard used to start it", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable");
|
||||
|
||||
assert.match(confirmPasteTableBody, /const clipboardAtPasteStart = connectionStore\.treeClipboard/);
|
||||
assert.match(confirmPasteTableBody, /if \(pasteFailCount === 0\)/);
|
||||
assert.match(confirmPasteTableBody, /connectionStore\.treeClipboard === clipboardAtPasteStart/);
|
||||
assert.match(confirmPasteTableBody, /connectionStore\.treeClipboard = null/);
|
||||
});
|
||||
|
||||
test("tree table paste keeps the clipboard when production confirmation is cancelled", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable");
|
||||
|
||||
assert.match(confirmPasteTableBody, /const structureExecuted = await executeTreeNodeSqlWithProductionGuard[\s\S]*?if \(!structureExecuted\) \{[\s\S]*?pasteCancelled = true;[\s\S]*?break;/);
|
||||
assert.match(confirmPasteTableBody, /const dataExecuted = await executeTreeNodeSqlWithProductionGuard[\s\S]*?if \(!dataExecuted\) \{[\s\S]*?pasteCancelled = true;[\s\S]*?break;/);
|
||||
assert.match(confirmPasteTableBody, /queueRefreshTarget\(entry\)/);
|
||||
assert.match(confirmPasteTableBody, /if \(pasteCancelled\) \{[\s\S]*?if \(hasMutatedTable && refreshFailCount === 0\)[\s\S]*?pasteTableCancelledAfterPartial[\s\S]*?return;/);
|
||||
});
|
||||
|
||||
test("tree table paste consumes the clipboard even if only the object-list refresh fails", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable");
|
||||
|
||||
assert.match(confirmPasteTableBody, /let pasteFailCount = 0/);
|
||||
assert.match(confirmPasteTableBody, /let refreshFailCount = 0/);
|
||||
assert.match(confirmPasteTableBody, /pasteFailCount\+\+/);
|
||||
assert.match(confirmPasteTableBody, /refreshFailCount\+\+/);
|
||||
assert.match(confirmPasteTableBody, /if \(pasteFailCount === 0\)[\s\S]*?connectionStore\.treeClipboard = null/);
|
||||
assert.match(confirmPasteTableBody, /if \(refreshFailCount > 0\)[\s\S]*?pasteTableRefreshFailed/);
|
||||
});
|
||||
|
||||
test("sidebar keyboard table copy uses the same normalized schema as the context menu", () => {
|
||||
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
|
||||
const copySelectedSidebarNamesBody = functionBody(connectionTree, "copySelectedSidebarNames");
|
||||
|
||||
assert.match(copySelectedSidebarNamesBody, /schema: connectionObjectTreeNodeSchema\(store\.getConfig\(node\.connectionId!\), node\.database!, node\.schema\)/);
|
||||
});
|
||||
|
||||
test("batch table paste refreshes each object list after all tables are processed", () => {
|
||||
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
|
||||
const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts";
|
||||
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableDataCopyColumnOptions } from "../../apps/desktop/src/lib/table/tableClipboard.ts";
|
||||
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesSingleSource, tableClipboardMatchesTarget, tableClipboardMenuState, tableDataCopyColumnOptions } from "../../apps/desktop/src/lib/table/tableClipboard.ts";
|
||||
|
||||
test("table clipboard entries must match the paste target context", () => {
|
||||
const target = { connectionId: "c1", database: "app", schema: "public" };
|
||||
|
|
@ -14,6 +14,24 @@ test("table clipboard entries must match the paste target context", () => {
|
|||
assert.equal(tableClipboardMatchesTarget([{ connectionId: "c1", database: "app" }], null), false);
|
||||
});
|
||||
|
||||
test("table clipboard identifies only its exact single source table", () => {
|
||||
const users = { connectionId: "c1", database: "app", schema: "public", tableName: "users" };
|
||||
|
||||
assert.equal(tableClipboardMatchesSingleSource([users], users), true);
|
||||
assert.equal(tableClipboardMatchesSingleSource([users], { ...users, tableName: "orders" }), false);
|
||||
assert.equal(tableClipboardMatchesSingleSource([{ ...users, schema: "audit" }], users), false);
|
||||
assert.equal(tableClipboardMatchesSingleSource([users, { ...users, tableName: "orders" }], users), false);
|
||||
});
|
||||
|
||||
test("table clipboard menu state supports paste and replacing the copied source", () => {
|
||||
const users = { connectionId: "c1", database: "app", schema: "public", tableName: "users" };
|
||||
|
||||
assert.equal(tableClipboardMenuState([], users), "copy");
|
||||
assert.equal(tableClipboardMenuState([users], users), "paste");
|
||||
assert.equal(tableClipboardMenuState([users], { ...users, tableName: "orders" }), "copy-and-paste");
|
||||
assert.equal(tableClipboardMenuState([{ ...users, schema: "audit" }], users), "copy");
|
||||
});
|
||||
|
||||
test("whole-row table data copy is enabled for known database types", () => {
|
||||
assert.equal(supportsWholeRowTableDataCopy("mysql"), true);
|
||||
assert.equal(supportsWholeRowTableDataCopy("postgres"), true);
|
||||
|
|
|
|||
Loading…
Reference in New Issue