From 0c91fecf5d03df4331b033873e93e854af9e9dfb Mon Sep 17 00:00:00 2001 From: miracle Date: Wed, 1 Jul 2026 12:49:37 +0800 Subject: [PATCH] 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 --- .../src/components/objects/ObjectBrowser.vue | 183 +++++++++++++++- .../src/components/sidebar/ConnectionTree.vue | 70 +++++- .../src/components/sidebar/TreeItem.vue | 202 ++++++++++++++++-- apps/desktop/src/i18n/locales/en.ts | 15 ++ apps/desktop/src/i18n/locales/es.ts | 15 ++ apps/desktop/src/i18n/locales/it.ts | 15 ++ apps/desktop/src/i18n/locales/ja.ts | 15 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 15 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 15 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 15 ++ apps/desktop/src/lib/api.ts | 1 + apps/desktop/src/lib/clipboard.ts | 5 + apps/desktop/src/lib/dbAdminSql.ts | 14 ++ apps/desktop/src/lib/http.ts | 6 +- apps/desktop/src/lib/tableClipboard.ts | 66 ++++++ apps/desktop/src/lib/tauri.ts | 6 +- .../__tests__/connectionStore.mq.spec.ts | 92 +++++++- apps/desktop/src/stores/connectionStore.ts | 22 +- crates/dbx-core/src/db/sqlserver.rs | 7 +- crates/dbx-core/src/db_admin_sql.rs | 104 +++++++++ crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/query.rs | 10 + packages/app-tests/clipboard.test.ts | 16 +- packages/app-tests/tableClipboard.test.ts | 61 ++++++ src-tauri/src/commands/query.rs | 5 + src-tauri/src/lib.rs | 1 + 26 files changed, 943 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/lib/tableClipboard.ts create mode 100644 packages/app-tests/tableClipboard.test.ts diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index b4a217e5f2..e83753b668 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -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>(new Set()); const expandedPartitionParentIds = ref>(new Set()); const showBatchDropConfirm = ref(false); const batchDropPreviewSql = ref(""); +// Paste table dialog state +const showPasteDialog = ref(false); +const pasteTableMode = ref("structure-and-data"); +const pasteTableEntries = ref<{ sourceName: string; targetName: string; schema?: string }[]>([]); +const pasteTableDataCopySupported = computed(() => supportsWholeRowTableDataCopy(effectiveDatabaseType.value)); const objectColumnWidths = ref>({ 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[] { diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 05d3824fa8..97cc0c627f 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -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(); +const rootRef = ref(); const pointerInsideTree = ref(false); const treeScrollerRef = ref | null>(null); const plainTreeScrollerRef = ref(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 });