feat(editor): add table copy/paste keyboard shortcuts

* feat: add table copy/paste with configurable structure and data options

- Implement Navicat-style table copy/paste functionality
- Support multi-table selection and batch operations
- Configurable paste modes: structure+data (default), structure only, data only
- Backend: add build_copy_table_data_sql API (INSERT INTO ... SELECT)
- TreeItem.vue: copy/paste context menus, paste dialog with mode selection
- ObjectBrowser.vue: batch toolbar buttons, context menu, paste dialog
- Refactor TreeClipboard type to support multi-table entries
- Add i18n keys for all 7 locale files (en, zh-CN, zh-TW, ja, es, it, pt-BR)

* Fix table paste shortcuts

* Fix Kafka MQ sidebar detection

* Restrict table paste scope

* Support data copy for primary SQL databases
This commit is contained in:
miracle authored and GitHub committed 2026-07-01 12:49:37 +08:00
1 parent 5d2ce9f189
commit 0c91fecf5d
26 files changed
+943 -34

No files matched your search

@@ -8,6 +8,7 @@ import {
ArrowUp,
Braces,
CheckSquare,
Clipboard,
Code2,
Copy,
CopyPlus,
@@ -51,14 +52,15 @@ import { isSchemaAware } from "@/lib/databaseCapabilities";
import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { buildTableSelectSql } from "@/lib/tableSelectSql";
import { buildDropObjectSql, buildDuplicateTableStructureSql, buildEmptyTableSql, buildTruncateTableSql, type TableAdminSqlOptions } from "@/lib/dbAdminSql";
import { buildDropObjectSql, buildDuplicateTableStructureSql, buildCopyTableDataSql, buildEmptyTableSql, buildTruncateTableSql, type TableAdminSqlOptions } from "@/lib/dbAdminSql";
import { useToast } from "@/composables/useToast";
import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, objectSourceSaveExecutionMode, supportsSourceBackedRoutineRename } from "@/lib/objectSourceEditor";
import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql";
import { buildViewDdl } from "@/lib/viewDdl";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { generateDatabaseExportId } from "@/lib/databaseExport";
import { copyToClipboard } from "@/lib/clipboard";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/clipboard";
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/tableClipboard";
import { formatSqlInsert } from "@/lib/exportFormats";
import { buildSingleDdlExportFileContent } from "@/lib/ddlExport";
import { fetchTableDataForExport } from "@/lib/tableDataExport";
@@ -152,6 +154,11 @@ const selectedTableIds = ref<Set<string>>(new Set());
const expandedPartitionParentIds = ref<Set<string>>(new Set());
const showBatchDropConfirm = ref(false);
const batchDropPreviewSql = ref("");
// Paste table dialog state
const showPasteDialog = ref(false);
const pasteTableMode = ref<PasteTableMode>("structure-and-data");
const pasteTableEntries = ref<{ sourceName: string; targetName: string; schema?: string }[]>([]);
const pasteTableDataCopySupported = computed(() => supportsWholeRowTableDataCopy(effectiveDatabaseType.value));
const objectColumnWidths = ref<Record<ObjectBrowserColumnKey, number>>({
select: 34,
name: 360,
@@ -964,6 +971,132 @@ async function confirmDuplicateStructure() {
}
}
function copySelectedTablesToClipboard() {
const selectedRows = selectedTableRows.value;
if (selectedRows.length === 0) return;
connectionStore.treeClipboard = {
kind: "table-copy",
tables: selectedRows.map((row) => ({
connectionId: props.connection.id,
database: props.database,
schema: row.schema || selectedSchema.value,
tableName: row.name,
})),
};
toast(t("contextMenu.pasteTableClipboardUpdated"), 2000);
}
function canPasteTableClipboard(): boolean {
const clipboard = connectionStore.treeClipboard;
return clipboard?.kind === "table-copy" && tableClipboardMatchesTarget(clipboard.tables, pasteTableTargetContext());
}
function pasteTableTargetContext(): TableClipboardContext {
return {
connectionId: props.connection.id,
database: props.database,
schema: selectedSchema.value,
};
}
function copySingleTableToClipboard(row: ObjectBrowserRow) {
connectionStore.treeClipboard = {
kind: "table-copy",
tables: [
{
connectionId: props.connection.id,
database: props.database,
schema: row.schema || selectedSchema.value,
tableName: row.name,
},
],
};
toast(t("contextMenu.pasteTableClipboardUpdated"), 2000);
}
function openPasteTableDialog() {
const clipboard = connectionStore.treeClipboard;
if (!canPasteTableClipboard() || !clipboard) {
toast(t("contextMenu.noTableToPaste"), 2000);
return;
}
pasteTableMode.value = defaultPasteTableMode(effectiveDatabaseType.value);
pasteTableEntries.value = clipboard.tables.map((entry) => ({
sourceName: entry.tableName,
targetName: `${entry.tableName}_copy`,
schema: entry.schema,
}));
showPasteDialog.value = true;
}
function onObjectBrowserKeydown(event: KeyboardEvent) {
if (event.defaultPrevented) return;
if (eventTargetAllowsAppClipboardShortcut(event, "c")) {
if (selectedTableCount.value === 0) return;
event.preventDefault();
event.stopPropagation();
copySelectedTablesToClipboard();
return;
}
if (eventTargetAllowsAppClipboardShortcut(event, "v")) {
if (!canPasteTableClipboard()) return;
event.preventDefault();
event.stopPropagation();
openPasteTableDialog();
}
}
async function confirmPasteTable() {
const entries = pasteTableEntries.value.filter((entry) => entry.targetName.trim());
if (entries.length === 0) return;
const mode = pasteTableMode.value;
const copyData = pasteTableModeCopiesData(mode) && pasteTableDataCopySupported.value;
showPasteDialog.value = false;
let successCount = 0;
let failCount = 0;
for (const entry of entries) {
const targetName = entry.targetName.trim();
const schema = entry.schema || selectedSchema.value;
try {
if (mode === "structure-and-data" || mode === "structure-only") {
const structureSql = await buildDuplicateTableStructureSql({
databaseType: effectiveDatabaseType.value,
schema,
sourceName: entry.sourceName,
targetName,
});
await api.executeQuery(props.connection.id, props.database, structureSql, schema);
}
if (copyData) {
const sourceColumns = await api.getColumns(props.connection.id, props.database, schema || "", entry.sourceName);
const dataCopyColumnOptions = tableDataCopyColumnOptions(effectiveDatabaseType.value, sourceColumns);
if (dataCopyColumnOptions.columns.length === 0) {
throw new Error("No writable columns available for table data copy.");
}
const dataSql = await buildCopyTableDataSql({
databaseType: effectiveDatabaseType.value,
schema,
sourceName: entry.sourceName,
targetName,
...dataCopyColumnOptions,
});
await api.executeQuery(props.connection.id, props.database, dataSql, schema);
}
successCount++;
} catch (e: any) {
failCount++;
console.error(`Failed to paste table "${entry.sourceName}" -> "${targetName}":`, e);
}
}
if (failCount === 0) {
toast(t("contextMenu.batchPasteSuccess", { count: successCount }), 3000);
} else {
toast(t("contextMenu.batchPastePartialFail", { success: successCount, failed: failCount }), 5000);
}
await reload();
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
}
function tableAdminSqlOptions(row: ObjectBrowserRow): TableAdminSqlOptions {
return {
databaseType: effectiveDatabaseType.value,
@@ -1290,6 +1423,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 },
{ label: "", separator: true },
...(supportsTruncateTable.value
? [
@@ -1378,7 +1512,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</script>
<template>
<div ref="rootRef" class="flex h-full min-h-0 flex-col bg-background">
<div ref="rootRef" data-object-browser-root class="flex h-full min-h-0 flex-col bg-background outline-none" tabindex="0" @keydown="onObjectBrowserKeydown">
<div class="flex h-10 shrink-0 items-center gap-2 border-b px-3">
<div class="flex min-w-0 flex-1 items-center gap-2">
<Table2 class="h-4 w-4 text-muted-foreground" />
@@ -1417,6 +1551,10 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="loadingObjects" @click="reload">
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingObjects }" />
</Button>
<Button v-if="canPasteTableClipboard()" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openPasteTableDialog">
<Clipboard class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.pasteTableSelected") }}
</Button>
</div>
<div v-if="selectedTableCount > 0" class="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/30 px-3 text-xs">
<div class="min-w-0 flex-1 truncate text-muted-foreground">
@@ -1426,6 +1564,10 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Download class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.exportSelected") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="copySelectedTablesToClipboard">
<Clipboard class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.copyTableSelected") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.dropSelected") }}
@@ -1705,6 +1847,41 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</DialogContent>
</Dialog>
<Dialog v-model:open="showPasteDialog">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{{ pasteTableEntries.length > 1 ? t("contextMenu.batchPasteTitle") : t("contextMenu.pasteTableConfirmTitle") }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="flex gap-2">
<label class="flex items-center gap-1.5 text-sm cursor-pointer" :class="{ 'opacity-50 cursor-not-allowed': !pasteTableDataCopySupported }">
<input v-model="pasteTableMode" type="radio" value="structure-and-data" class="accent-primary" :disabled="!pasteTableDataCopySupported" />
{{ t("contextMenu.pasteOptionStructureAndData") }}
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input v-model="pasteTableMode" type="radio" value="structure-only" class="accent-primary" />
{{ t("contextMenu.pasteOptionStructureOnly") }}
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer" :class="{ 'opacity-50 cursor-not-allowed': !pasteTableDataCopySupported }">
<input v-model="pasteTableMode" type="radio" value="data-only" class="accent-primary" :disabled="!pasteTableDataCopySupported" />
{{ t("contextMenu.pasteOptionDataOnly") }}
</label>
</div>
<div class="space-y-2 max-h-64 overflow-y-auto">
<div v-for="(entry, idx) in pasteTableEntries" :key="idx" class="flex items-center gap-2">
<span class="text-sm text-muted-foreground truncate min-w-0 flex-shrink basis-1/3" :title="entry.sourceName">{{ entry.sourceName }}</span>
<span class="text-xs text-muted-foreground flex-shrink-0">&rarr;</span>
<Input v-model="entry.targetName" class="flex-1 h-8 text-sm" :placeholder="t('contextMenu.duplicateNamePlaceholder')" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showPasteDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="pasteTableEntries.every((e) => !e.targetName.trim())" @click="confirmPasteTable">{{ t("dangerDialog.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DdlViewDialog v-if="ddlDialogTarget" :connection-id="props.connection.id" :database="props.database" :schema="ddlDialogTarget.schema || selectedSchema" :table-name="ddlDialogTarget.name" :dialect="sourceDialect" :format-dialect="sourceFormatDialect" v-model:open="showDdlDialog" />
</template>
@@ -5,9 +5,12 @@ import { Search, X, ListFilter, Crosshair, Server, Database, FolderTree, Table2,
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import type { TreeNode, TreeNodeType } from "@/types/database";
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree } from "@/lib/sidebarSearchTree";
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
import { copyNameForTreeNode } from "@/lib/treeNodeClick";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/clipboard";
import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebarTypeSearch";
import { usesTreeSchemaMode } from "@/lib/databaseFeatureSupport";
import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
@@ -24,9 +27,11 @@ const { t } = useI18n();
const store = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const searchQuery = ref("");
const deferredSearchQuery = ref("");
const searchInputRef = ref<HTMLInputElement>();
const rootRef = ref<HTMLElement>();
const pointerInsideTree = ref(false);
const treeScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
const plainTreeScrollerRef = ref<HTMLElement | null>(null);
@@ -763,7 +768,25 @@ function focusSearchAtEnd() {
}
function onWindowKeydown(event: KeyboardEvent) {
if (!pointerInsideTree.value || event.defaultPrevented || isEditableSidebarTypeSearchTarget(event.target)) return;
if (event.defaultPrevented) return;
if (sidebarShortcutTargetIsActive(event.target)) {
if (eventTargetAllowsAppClipboardShortcut(event, "c")) {
if (copySelectedSidebarNames()) {
event.preventDefault();
event.stopPropagation();
}
return;
}
if (eventTargetAllowsAppClipboardShortcut(event, "v")) {
if (requestSelectedSidebarPasteTable()) {
event.preventDefault();
event.stopPropagation();
}
return;
}
}
if (!pointerInsideTree.value || isEditableSidebarTypeSearchTarget(event.target)) return;
if (isCancelSearchShortcut(event)) {
if (!searchQuery.value) return;
event.preventDefault();
@@ -778,6 +801,49 @@ function onWindowKeydown(event: KeyboardEvent) {
focusSearchAtEnd();
}
function sidebarShortcutTargetIsActive(target: EventTarget | null): boolean {
const root = rootRef.value;
if (!root) return false;
if (target instanceof Node && root.contains(target)) return true;
const active = document.activeElement;
return pointerInsideTree.value && (!active || active === document.body || root.contains(active));
}
function selectedSidebarNodesInVisibleOrder(): TreeNode[] {
const selectedIds = new Set(store.selectedTreeNodeIds);
return visibleNodes.value.filter((node) => selectedIds.has(node.id));
}
function copySelectedSidebarNames(): boolean {
const nodes = selectedSidebarNodesInVisibleOrder();
if (nodes.length === 0) return false;
const tableNodes = nodes.filter((node) => node.type === "table" && !!node.connectionId && !!node.database);
store.treeClipboard =
tableNodes.length > 0
? {
kind: "table-copy",
tables: tableNodes.map((node) => ({
connectionId: node.connectionId!,
database: node.database!,
schema: node.schema,
tableName: node.label,
})),
}
: null;
copyToClipboard(nodes.map(copyNameForTreeNode).join("\n"))
.then(() => toast(t("connection.copied"), 2000))
.catch((e: any) => toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000));
return true;
}
function requestSelectedSidebarPasteTable(): boolean {
const clipboard = store.treeClipboard;
const selectedNodeId = store.selectedTreeNodeId;
if (clipboard?.kind !== "table-copy" || clipboard.tables.length === 0 || !selectedNodeId) return false;
window.dispatchEvent(new CustomEvent("dbx:sidebar-request-paste-table", { detail: { nodeId: selectedNodeId } }));
return true;
}
onMounted(() => {
window.addEventListener("keydown", onWindowKeydown);
});
@@ -794,7 +860,7 @@ defineExpose({ focusSearch, createNewGroup });
</script>
<template>
<div class="h-full min-h-0 flex flex-col text-sm select-none" @pointerenter="pointerInsideTree = true" @pointerleave="pointerInsideTree = false">
<div ref="rootRef" class="h-full min-h-0 flex flex-col text-sm select-none" @pointerenter="pointerInsideTree = true" @pointerleave="pointerInsideTree = false">
<div class="sticky top-0 z-10 bg-background px-2 py-1">
<div class="relative flex items-center gap-1">
<div class="relative flex-1">
+184 -18
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch, onBeforeUnmount, inject } from "vue";
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, inject } from "vue";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
@@ -89,6 +89,7 @@ import {
buildDropTableSql,
buildDropTableChildObjectSql,
buildDuplicateTableStructureSql,
buildCopyTableDataSql,
buildEmptyTableSql,
buildSetSchemaCommentSql,
buildTruncateTableSql,
@@ -108,6 +109,7 @@ import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionObjectT
import { hexToRgba } from "@/lib/color";
import { focusSidebarRenameInput } from "@/lib/sidebarRenameFocus";
import { hasTreeNodeDatabaseContext } from "@/lib/treeNodeContext";
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/tableClipboard";
import { sidebarDisplayTableName } from "@/lib/sidebarTableNameDisplay";
import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebarTreeSelection";
import { selectedConnectionDeleteTargets } from "@/lib/sidebarConnectionSelection";
@@ -747,20 +749,41 @@ function isPasteTreeClipboardShortcut(event: KeyboardEvent): boolean {
return (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "v";
}
function pasteTableTargetContext(): TableClipboardContext | null {
if (!props.node.connectionId || !props.node.database) return null;
return {
connectionId: props.node.connectionId,
database: props.node.database,
schema: props.node.schema,
};
}
function canPasteTreeClipboardToCurrentNode(): boolean {
const clipboard = connectionStore.treeClipboard;
return clipboard?.kind === "table-copy" && tableClipboardMatchesTarget(clipboard.tables, pasteTableTargetContext());
}
function requestPasteTreeClipboard(): boolean {
const clipboard = connectionStore.treeClipboard;
if (clipboard?.kind !== "table-structure") return false;
duplicateStructure({
id: `clipboard:${clipboard.connectionId}:${clipboard.database}:${clipboard.schema || ""}:${clipboard.tableName}`,
type: "table",
label: clipboard.tableName,
connectionId: clipboard.connectionId,
database: clipboard.database,
schema: clipboard.schema,
});
if (clipboard?.kind !== "table-copy" || !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,
}));
showPasteDialog.value = true;
return true;
}
function onSidebarRequestPasteTable(event: Event) {
const nodeId = (event as CustomEvent<{ nodeId?: string }>).detail?.nodeId;
if (nodeId !== props.node.id) return;
requestPasteTreeClipboard();
}
function requestRefreshSelectedNode(): boolean {
if (!canRefreshTreeNodeShortcut()) return false;
void refresh();
@@ -1407,17 +1430,18 @@ async function copySelectedNames() {
function updateTreeClipboardForNodes(nodes: TreeNode[]) {
const tableNodes = nodes.filter((node): node is DuplicateStructureSource => node.type === "table" && !!node.connectionId && !!node.database && typeof node.label === "string");
if (nodes.length !== 1 || tableNodes.length !== 1) {
if (tableNodes.length === 0) {
connectionStore.treeClipboard = null;
return;
}
const table = tableNodes[0]!;
connectionStore.treeClipboard = {
kind: "table-structure",
connectionId: table.connectionId,
database: table.database,
schema: table.schema,
tableName: table.label,
kind: "table-copy",
tables: tableNodes.map((node) => ({
connectionId: node.connectionId,
database: node.database,
schema: node.schema,
tableName: node.label,
})),
};
}
@@ -1462,6 +1486,12 @@ const showDuplicateDialog = ref(false);
const duplicateTableName = ref("");
const duplicateStructureSource = ref<DuplicateStructureSource | null>(null);
// Paste table dialog state
const showPasteDialog = ref(false);
const pasteTableMode = ref<PasteTableMode>("structure-and-data");
const pasteTableEntries = ref<{ sourceName: string; targetName: string; connectionId: string; database: string; schema?: string }[]>([]);
const pasteTableDataCopySupported = computed(() => supportsWholeRowTableDataCopy(currentDatabaseType()));
const ddlTarget = ref<TreeNode | null>(null);
const showDdlDialog = ref(false);
const ddlDialect = computed(() => {
@@ -2542,6 +2572,96 @@ async function confirmDuplicateStructure() {
}
}
async function confirmPasteTable() {
const entries = pasteTableEntries.value.filter((entry) => entry.targetName.trim());
if (entries.length === 0) return;
const mode = pasteTableMode.value;
const copyData = pasteTableModeCopiesData(mode) && pasteTableDataCopySupported.value;
showPasteDialog.value = false;
let successCount = 0;
let failCount = 0;
const refreshedConnections = new Set<string>();
for (const entry of entries) {
const targetName = entry.targetName.trim();
try {
await connectionStore.ensureConnected(entry.connectionId);
const databaseType = entry.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(entry.connectionId)) : undefined;
if (mode === "structure-and-data" || mode === "structure-only") {
const structureSql = await buildDuplicateTableStructureSql({
databaseType,
schema: entry.schema,
sourceName: entry.sourceName,
targetName,
});
await api.executeQuery(entry.connectionId, entry.database, structureSql, entry.schema);
}
if (copyData) {
const sourceColumns = await api.getColumns(entry.connectionId, entry.database, entry.schema || "", entry.sourceName);
const dataCopyColumnOptions = tableDataCopyColumnOptions(databaseType, sourceColumns);
if (dataCopyColumnOptions.columns.length === 0) {
throw new Error("No writable columns available for table data copy.");
}
const dataSql = await buildCopyTableDataSql({
databaseType,
schema: entry.schema,
sourceName: entry.sourceName,
targetName,
...dataCopyColumnOptions,
});
await api.executeQuery(entry.connectionId, entry.database, dataSql, entry.schema);
}
successCount++;
const refreshKey = `${entry.connectionId}:${entry.database}:${entry.schema || ""}`;
if (!refreshedConnections.has(refreshKey)) {
refreshedConnections.add(refreshKey);
await connectionStore.refreshObjectListTreeNode(entry.connectionId, entry.database, entry.schema);
}
} catch (e: any) {
failCount++;
console.error(`Failed to paste table "${entry.sourceName}" -> "${targetName}":`, e);
}
}
if (failCount === 0) {
toast(t("contextMenu.batchPasteSuccess", { count: successCount }), 3000);
} else {
toast(t("contextMenu.batchPastePartialFail", { success: successCount, failed: failCount }), 5000);
}
}
function copyTableToClipboard() {
const node = props.node;
if (node.type !== "table" || !node.connectionId || !node.database) return;
connectionStore.treeClipboard = {
kind: "table-copy",
tables: [
{
connectionId: node.connectionId,
database: node.database,
schema: node.schema,
tableName: node.label,
},
],
};
toast(t("contextMenu.pasteTableClipboardUpdated"), 2000);
}
function openPasteTableDialog() {
const clipboard = connectionStore.treeClipboard;
if (clipboard?.kind !== "table-copy" || !canPasteTreeClipboardToCurrentNode()) {
toast(t("contextMenu.noTableToPaste"), 2000);
return;
}
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,
}));
showPasteDialog.value = true;
}
function createTable() {
const node = props.node;
if (!node.connectionId || !node.database) return;
@@ -3437,7 +3557,14 @@ function onRowMouseDown(event: MouseEvent) {
}
}
onBeforeUnmount(() => finishTableReferenceDrag());
onMounted(() => {
window.addEventListener("dbx:sidebar-request-paste-table", onSidebarRequestPasteTable);
});
onBeforeUnmount(() => {
window.removeEventListener("dbx:sidebar-request-paste-table", onSidebarRequestPasteTable);
finishTableReferenceDrag();
});
// ---- CustomContextMenu ----
@@ -3917,6 +4044,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
if (isTableNotView.value) {
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.duplicateStructure"), action: duplicateStructure, icon: CopyPlus });
items.push({ label: t("contextMenu.copyTable"), action: copyTableToClipboard, icon: Copy });
if (supportsTruncate.value) {
destructiveActions.push({
label: t("contextMenu.truncateTable"),
@@ -4033,6 +4161,9 @@ function treeItemMenuItems(): ContextMenuItem[] {
const canLoadAllObjectGroup = node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views";
if (node.type === "group-tables" && canCreateTable.value) {
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
if (canPasteTreeClipboardToCurrentNode()) {
items.push({ label: t("contextMenu.pasteTable"), action: openPasteTableDialog, icon: Clipboard });
}
}
if (node.type === "group-views" && node.connectionId && node.database) {
items.push({ label: t("contextMenu.createView"), action: createView, icon: Plus });
@@ -4323,6 +4454,41 @@ function treeItemMenuItems(): ContextMenuItem[] {
</DialogContent>
</Dialog>
<Dialog v-model:open="showPasteDialog">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{{ pasteTableEntries.length > 1 ? t("contextMenu.batchPasteTitle") : t("contextMenu.pasteTableConfirmTitle") }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="flex gap-2">
<label class="flex items-center gap-1.5 text-sm cursor-pointer" :class="{ 'opacity-50 cursor-not-allowed': !pasteTableDataCopySupported }">
<input v-model="pasteTableMode" type="radio" value="structure-and-data" class="accent-primary" :disabled="!pasteTableDataCopySupported" />
{{ t("contextMenu.pasteOptionStructureAndData") }}
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input v-model="pasteTableMode" type="radio" value="structure-only" class="accent-primary" />
{{ t("contextMenu.pasteOptionStructureOnly") }}
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer" :class="{ 'opacity-50 cursor-not-allowed': !pasteTableDataCopySupported }">
<input v-model="pasteTableMode" type="radio" value="data-only" class="accent-primary" :disabled="!pasteTableDataCopySupported" />
{{ t("contextMenu.pasteOptionDataOnly") }}
</label>
</div>
<div class="space-y-2 max-h-64 overflow-y-auto">
<div v-for="(entry, idx) in pasteTableEntries" :key="idx" class="flex items-center gap-2">
<span class="text-sm text-muted-foreground truncate min-w-0 flex-shrink basis-1/3" :title="entry.sourceName">{{ entry.sourceName }}</span>
<span class="text-xs text-muted-foreground flex-shrink-0">&rarr;</span>
<Input v-model="entry.targetName" class="flex-1 h-8 text-sm" :placeholder="t('contextMenu.duplicateNamePlaceholder')" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showPasteDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="pasteTableEntries.every((e) => !e.targetName.trim())" @click="confirmPasteTable">{{ t("dangerDialog.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateDatabaseDialog">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>