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
2026-07-01 12:49:37 +08:00
committed by GitHub
parent 5d2ce9f189
commit 0c91fecf5d
26 changed files with 943 additions and 34 deletions
@@ -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>
+15
View File
@@ -1340,6 +1340,19 @@ export default {
tableOperationFailed: "Operation failed: {message}",
duplicateNameTitle: "Clone as New Table",
duplicateNamePlaceholder: "New table name",
copyTable: "Copy Table",
pasteTable: "Paste Table",
pasteOptionStructureAndData: "Structure and Data",
pasteOptionStructureOnly: "Structure Only",
pasteOptionDataOnly: "Data Only",
pasteTableSuccess: 'Table pasted as "{name}"',
pasteTableConfirmTitle: "Paste Table",
pasteTableConfirmMessage: "Paste table from clipboard",
batchPasteTitle: "Paste Tables",
batchPasteSuccess: "Successfully pasted {count} tables",
batchPastePartialFail: "Pasted {success} tables, {failed} failed",
noTableToPaste: "No tables available to paste",
pasteTableClipboardUpdated: "Table copied to clipboard",
createDatabase: "Create Database",
createDuckDbFile: "Create Database File",
dropDatabase: "Drop Database",
@@ -1513,6 +1526,8 @@ export default {
confirmBatchDropTitle: "Drop Selected Tables",
confirmBatchDropMessage: "Are you sure you want to drop {count} selected tables? This cannot be undone.",
batchDropSuccess: "Dropped {count} tables",
copyTableSelected: "Copy",
pasteTableSelected: "Paste",
},
structureEditor: {
title: "Edit Table Structure",
+15
View File
@@ -1322,6 +1322,19 @@ export default withEnglishFallback({
tableOperationFailed: "Error en la operación: {message}",
duplicateNameTitle: "Clonar como tabla nueva",
duplicateNamePlaceholder: "Nombre de la nueva tabla",
copyTable: "Copiar tabla",
pasteTable: "Pegar tabla",
pasteOptionStructureAndData: "Estructura y datos",
pasteOptionStructureOnly: "Solo estructura",
pasteOptionDataOnly: "Solo datos",
pasteTableSuccess: 'Tabla pegada como "{name}"',
pasteTableConfirmTitle: "Pegar tabla",
pasteTableConfirmMessage: "Pegar tabla desde el portapapeles",
batchPasteTitle: "Pegar tablas",
batchPasteSuccess: "{count} tablas pegadas correctamente",
batchPastePartialFail: "{success} tablas pegadas, {failed} fallidas",
noTableToPaste: "No hay tablas disponibles para pegar",
pasteTableClipboardUpdated: "Tabla copiada al portapapeles",
createDatabase: "Crear base de datos",
createDuckDbFile: "Crear archivo de base de datos",
dropDatabase: "Eliminar base de datos",
@@ -1495,6 +1508,8 @@ export default withEnglishFallback({
confirmBatchDropTitle: "Eliminar tablas seleccionadas",
confirmBatchDropMessage: "¿Seguro que quieres eliminar {count} tablas seleccionadas? Esta acción no se puede deshacer.",
batchDropSuccess: "{count} tablas eliminadas",
copyTableSelected: "Copiar",
pasteTableSelected: "Pegar",
},
structureEditor: {
title: "Editar estructura de tabla",
+15
View File
@@ -1341,6 +1341,19 @@ export default withEnglishFallback({
tableOperationFailed: "Operazione non riuscita: {message}",
duplicateNameTitle: "Clona as Nuova Tabella",
duplicateNamePlaceholder: "Nuovo nome tabella",
copyTable: "Copia tabella",
pasteTable: "Incolla tabella",
pasteOptionStructureAndData: "Struttura e dati",
pasteOptionStructureOnly: "Solo struttura",
pasteOptionDataOnly: "Solo dati",
pasteTableSuccess: 'Tabella incollata come "{name}"',
pasteTableConfirmTitle: "Incolla tabella",
pasteTableConfirmMessage: "Incolla tabella dagli appunti",
batchPasteTitle: "Incolla tabelle",
batchPasteSuccess: "{count} tabelle incollate correttamente",
batchPastePartialFail: "{success} tabelle incollate, {failed} fallite",
noTableToPaste: "Nessuna tabella disponibile da incollare",
pasteTableClipboardUpdated: "Tabella copiata negli appunti",
createDatabase: "Crea Database",
createDuckDbFile: "Crea File Database",
dropDatabase: "Elimina Database",
@@ -1514,6 +1527,8 @@ export default withEnglishFallback({
confirmBatchDropTitle: "Elimina Tabelle Selezionate",
confirmBatchDropMessage: "Sei sicuro di voler eliminare {count} tabelle selezionate? Questa azione non può essere annullata.",
batchDropSuccess: "Eliminate {count} tabelle",
copyTableSelected: "Copia",
pasteTableSelected: "Incolla",
},
structureEditor: {
title: "Modifica Struttura Tabella",
+15
View File
@@ -1319,6 +1319,19 @@ export default withEnglishFallback({
tableOperationFailed: "操作に失敗しました: {message}",
duplicateNameTitle: "新しいテーブルとして複製",
duplicateNamePlaceholder: "新しいテーブル名",
copyTable: "テーブルをコピー",
pasteTable: "テーブルを貼り付け",
pasteOptionStructureAndData: "構造とデータ",
pasteOptionStructureOnly: "構造のみ",
pasteOptionDataOnly: "データのみ",
pasteTableSuccess: "テーブルを「{name}」として貼り付けました",
pasteTableConfirmTitle: "テーブルの貼り付け",
pasteTableConfirmMessage: "クリップボードからテーブルを貼り付け",
batchPasteTitle: "テーブルの貼り付け",
batchPasteSuccess: "{count}テーブルを貼り付けました",
batchPastePartialFail: "{success}テーブルを貼り付け、{failed}テーブルに失敗しました",
noTableToPaste: "貼り付け可能なテーブルがありません",
pasteTableClipboardUpdated: "テーブルをクリップボードにコピーしました",
createDatabase: "データベースを作成",
createDuckDbFile: "データベースファイルを作成",
dropDatabase: "データベースを削除",
@@ -1529,6 +1542,8 @@ export default withEnglishFallback({
confirmBatchDropTitle: "選択したテーブルを削除",
confirmBatchDropMessage: "選択した{count}テーブルを削除しますか?この操作は取り消せません。",
batchDropSuccess: "{count}テーブルを削除しました",
copyTableSelected: "コピー",
pasteTableSelected: "貼り付け",
},
structureEditor: {
title: "テーブル構造を編集",
+15
View File
@@ -1334,6 +1334,19 @@ export default withEnglishFallback({
tableOperationFailed: "Falha na operação: {message}",
duplicateNameTitle: "Clonar como Nova Tabela",
duplicateNamePlaceholder: "Nome da nova tabela",
copyTable: "Copiar tabela",
pasteTable: "Colar tabela",
pasteOptionStructureAndData: "Estrutura e dados",
pasteOptionStructureOnly: "Apenas estrutura",
pasteOptionDataOnly: "Apenas dados",
pasteTableSuccess: 'Tabela colada como "{name}"',
pasteTableConfirmTitle: "Colar tabela",
pasteTableConfirmMessage: "Colar tabela da área de transferência",
batchPasteTitle: "Colar tabelas",
batchPasteSuccess: "{count} tabelas coladas com sucesso",
batchPastePartialFail: "{success} tabelas coladas, {failed} falharam",
noTableToPaste: "Nenhuma tabela disponível para colar",
pasteTableClipboardUpdated: "Tabela copiada para a área de transferência",
createDatabase: "Criar Banco de Dados",
createDuckDbFile: "Criar Arquivo de Banco de Dados",
dropDatabase: "Remover Banco de Dados",
@@ -1507,6 +1520,8 @@ export default withEnglishFallback({
confirmBatchDropTitle: "Remover tabelas selecionadas",
confirmBatchDropMessage: "Tem certeza de que deseja remover {count} tabelas selecionadas? Esta ação não pode ser desfeita.",
batchDropSuccess: "{count} tabelas removidas",
copyTableSelected: "Copiar",
pasteTableSelected: "Colar",
},
structureEditor: {
title: "Editar estrutura da tabela",
+15
View File
@@ -1343,6 +1343,19 @@ export default withEnglishFallback({
tableOperationFailed: "操作失败:{message}",
duplicateNameTitle: "克隆为新表",
duplicateNamePlaceholder: "新表名",
copyTable: "复制表",
pasteTable: "粘贴表",
pasteOptionStructureAndData: "结构和数据",
pasteOptionStructureOnly: "仅结构",
pasteOptionDataOnly: "仅数据",
pasteTableSuccess: "已将表粘贴为「{name}」",
pasteTableConfirmTitle: "粘贴表",
pasteTableConfirmMessage: "从剪贴板粘贴表",
batchPasteTitle: "粘贴表",
batchPasteSuccess: "成功粘贴 {count} 张表",
batchPastePartialFail: "粘贴成功 {success} 张表,{failed} 张失败",
noTableToPaste: "没有可粘贴的表",
pasteTableClipboardUpdated: "表已复制到剪贴板",
createDatabase: "新建数据库",
createDuckDbFile: "新建数据库文件",
dropDatabase: "删除数据库",
@@ -1516,6 +1529,8 @@ export default withEnglishFallback({
confirmBatchDropTitle: "删除所选表",
confirmBatchDropMessage: "确定要删除已选择的 {count} 张表吗?此操作不可撤销。",
batchDropSuccess: "已删除 {count} 张表",
copyTableSelected: "复制",
pasteTableSelected: "粘贴",
},
structureEditor: {
title: "编辑表结构",
+15
View File
@@ -1323,6 +1323,19 @@ export default withEnglishFallback({
tableOperationFailed: "操作失敗:{message}",
duplicateNameTitle: "克隆為新資料表",
duplicateNamePlaceholder: "新資料表名稱",
copyTable: "複製資料表",
pasteTable: "貼上資料表",
pasteOptionStructureAndData: "結構和資料",
pasteOptionStructureOnly: "僅結構",
pasteOptionDataOnly: "僅資料",
pasteTableSuccess: "已將資料表貼上為「{name}」",
pasteTableConfirmTitle: "貼上資料表",
pasteTableConfirmMessage: "從剪貼簿貼上資料表",
batchPasteTitle: "貼上資料表",
batchPasteSuccess: "成功貼上 {count} 張資料表",
batchPastePartialFail: "貼上成功 {success} 張資料表,{failed} 張失敗",
noTableToPaste: "沒有可貼上的資料表",
pasteTableClipboardUpdated: "資料表已複製到剪貼簿",
createDatabase: "建立資料庫",
createDuckDbFile: "建立資料庫檔案",
dropDatabase: "刪除資料庫",
@@ -1451,6 +1464,8 @@ export default withEnglishFallback({
confirmBatchDropTitle: "刪除所選資料表",
confirmBatchDropMessage: "確定要刪除已選擇的 {count} 張資料表嗎?此操作無法復原。",
batchDropSuccess: "已刪除 {count} 張資料表",
copyTableSelected: "複製",
pasteTableSelected: "貼上",
},
structureEditor: {
title: "編輯資料表結構",
+1
View File
@@ -168,6 +168,7 @@ export const buildDropDatabaseSql = forward("buildDropDatabaseSql");
export const buildCreateSchemaSql = forward("buildCreateSchemaSql");
export const buildDropSchemaSql = forward("buildDropSchemaSql");
export const buildDuplicateTableStructureSql = forward("buildDuplicateTableStructureSql");
export const buildCopyTableDataSql = forward("buildCopyTableDataSql");
export const buildExecutableObjectSourceStatements = forward("buildExecutableObjectSourceStatements");
export const buildExecutableObjectSourceSql = forward("buildExecutableObjectSourceSql");
export const buildEditableObjectSource = forward("buildEditableObjectSource");
+5
View File
@@ -86,6 +86,11 @@ export function eventTargetAllowsNativeClipboard(event: ClipboardShortcutEvent,
return isPlainClipboardShortcut(event, "c") && hasNativeClipboardSelection(env);
}
export function eventTargetAllowsAppClipboardShortcut(event: ClipboardShortcutEvent, key = "v"): boolean {
if (!isPlainClipboardShortcut(event, key)) return false;
return !closestElement(event.target, EDITABLE_CLIPBOARD_TARGET_SELECTOR);
}
export async function readTextFromClipboard(env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment): Promise<string> {
if (isTauriRuntime(env as unknown as Record<string, unknown>)) {
try {
+14
View File
@@ -45,6 +45,16 @@ export interface DuplicateTableStructureSqlOptions {
targetName: string;
}
export interface CopyTableDataSqlOptions {
databaseType?: DatabaseType;
schema?: string | null;
sourceName: string;
targetName: string;
columns?: string[];
postgresOverridingSystemValue?: boolean;
sqlserverIdentityInsert?: boolean;
}
export function buildDropObjectSql(options: DropObjectSqlOptions): Promise<string> {
return api.buildDropObjectSql(options);
}
@@ -101,6 +111,10 @@ export function buildDuplicateTableStructureSql(options: DuplicateTableStructure
return api.buildDuplicateTableStructureSql(options);
}
export function buildCopyTableDataSql(options: CopyTableDataSqlOptions): Promise<string> {
return api.buildCopyTableDataSql(options);
}
function quotePostgresIdentifier(value: string): string {
return `"${value.replace(/"/g, '""')}"`;
}
+5 -1
View File
@@ -108,7 +108,7 @@ import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceI
import type { BuildViewDdlInput } from "@/lib/viewDdl";
import type { BuildRenameObjectSqlOptions } from "@/lib/objectRenameSql";
import type { CreateDatabaseSqlOptions } from "@/lib/createDatabaseSql";
import type { DatabaseNameSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/dbAdminSql";
import type { DatabaseNameSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/dbAdminSql";
import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/databaseExport";
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataCompare";
import { apiUrl } from "@/lib/webPath";
@@ -760,6 +760,10 @@ export async function buildDuplicateTableStructureSql(options: DuplicateTableStr
return post("/api/query/build-duplicate-table-structure-sql", { options });
}
export async function buildCopyTableDataSql(options: CopyTableDataSqlOptions): Promise<string> {
return post("/api/query/build-copy-table-data-sql", { options });
}
export async function buildExecutableObjectSourceStatements(input: BuildEditableObjectSourceSqlInput): Promise<string[]> {
return post("/api/query/build-executable-object-source-statements", { input });
}
+66
View File
@@ -0,0 +1,66 @@
import type { ColumnInfo, DatabaseType } from "@/types/database";
export type PasteTableMode = "structure-and-data" | "structure-only" | "data-only";
export interface TableClipboardContext {
connectionId: string;
database: string;
schema?: string | null;
}
export interface TableDataCopyColumnOptions {
columns: string[];
postgresOverridingSystemValue: boolean;
sqlserverIdentityInsert: boolean;
}
function normalizeSchema(schema: string | null | undefined): string {
return schema?.trim() ?? "";
}
export function tableClipboardEntryMatchesTarget(entry: TableClipboardContext, target: TableClipboardContext): boolean {
return entry.connectionId === target.connectionId && entry.database === target.database && normalizeSchema(entry.schema) === normalizeSchema(target.schema);
}
export function tableClipboardMatchesTarget(entries: TableClipboardContext[], target: TableClipboardContext | null): boolean {
return !!target && entries.length > 0 && entries.every((entry) => tableClipboardEntryMatchesTarget(entry, target));
}
export function supportsWholeRowTableDataCopy(databaseType: DatabaseType | undefined): boolean {
return !!databaseType;
}
export function defaultPasteTableMode(databaseType: DatabaseType | undefined): PasteTableMode {
return supportsWholeRowTableDataCopy(databaseType) ? "structure-and-data" : "structure-only";
}
export function pasteTableModeCopiesData(mode: PasteTableMode): boolean {
return mode === "structure-and-data" || mode === "data-only";
}
export function tableDataCopyColumnOptions(databaseType: DatabaseType | undefined, columns: ColumnInfo[]): TableDataCopyColumnOptions {
const writableColumns = columns.filter((column) => isWritableTableDataCopyColumn(databaseType, column));
return {
columns: writableColumns.map((column) => column.name),
postgresOverridingSystemValue: databaseType === "postgres" && writableColumns.some(isIdentityColumn),
sqlserverIdentityInsert: databaseType === "sqlserver" && writableColumns.some(isIdentityColumn),
};
}
function isWritableTableDataCopyColumn(databaseType: DatabaseType | undefined, column: ColumnInfo): boolean {
const extra = (column.extra ?? "").toLowerCase();
if (databaseType === "mysql") {
return !extra.includes("generated");
}
if (databaseType === "postgres") {
return !extra.includes("generated always as (");
}
if (databaseType === "sqlserver") {
return !extra.includes("computed");
}
return !extra.includes("computed") && !(extra.includes("generated") && !extra.includes("identity"));
}
function isIdentityColumn(column: ColumnInfo): boolean {
return (column.extra ?? "").toLowerCase().includes("identity");
}
+5 -1
View File
@@ -55,7 +55,7 @@ import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceI
import type { BuildViewDdlInput } from "@/lib/viewDdl";
import type { BuildRenameObjectSqlOptions } from "@/lib/objectRenameSql";
import type { CreateDatabaseSqlOptions } from "@/lib/createDatabaseSql";
import type { DatabaseNameSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/dbAdminSql";
import type { DatabaseNameSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/dbAdminSql";
import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/databaseExport";
export interface AgentDriverInfo {
@@ -747,6 +747,10 @@ export async function buildDuplicateTableStructureSql(options: DuplicateTableStr
return invoke("build_duplicate_table_structure_sql", { options });
}
export async function buildCopyTableDataSql(options: CopyTableDataSqlOptions): Promise<string> {
return invoke("build_copy_table_data_sql", { options });
}
export async function buildExecutableObjectSourceStatements(input: BuildEditableObjectSourceSqlInput): Promise<string[]> {
return invoke("build_executable_object_source_statements", { input });
}
@@ -33,6 +33,28 @@ function mqConnection(): ConnectionConfig {
} as ConnectionConfig;
}
function kafkaConnection(): ConnectionConfig {
return {
...mqConnection(),
name: "Apache Kafka",
driver_profile: "kafka",
driver_label: "Apache Kafka",
external_config: {
systemKind: "kafka",
adminUrl: "",
auth: { kind: "none" },
extra: { bootstrapServers: "127.0.0.1:9092" },
},
} as ConnectionConfig;
}
function kafkaExternalConfigOnlyConnection(): ConnectionConfig {
const connection = kafkaConnection();
delete connection.driver_profile;
delete connection.driver_label;
return connection;
}
describe("connectionStore MQ sidebar tree", () => {
beforeEach(() => {
vi.resetModules();
@@ -83,6 +105,72 @@ describe("connectionStore MQ sidebar tree", () => {
expect(node.isExpanded).toBe(true);
});
it("adds a Kafka topics child with a topics initial tab", async () => {
vi.doMock("@/lib/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listDatabases: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
mqListTenants: vi.fn(),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = kafkaConnection();
const node: TreeNode = {
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: false,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [node];
await store.refreshTreeNode(node);
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_kafka", initialTab: "topics" }]);
});
it("detects Kafka from external config when driver profile is missing", async () => {
const mqListTenants = vi.fn();
vi.doMock("@/lib/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listDatabases: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
mqListTenants,
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = kafkaExternalConfigOnlyConnection();
const node: TreeNode = {
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: false,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [node];
await store.refreshTreeNode(node);
expect(mqListTenants).not.toHaveBeenCalled();
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_kafka", initialTab: "topics" }]);
});
it("reuses an in-flight connection attempt instead of recording stale superseded errors", async () => {
let resolveConnect: ((value: string) => void) | undefined;
const connectDb = vi.fn(
@@ -124,10 +212,12 @@ describe("connectionStore MQ sidebar tree", () => {
const firstTabId = queryStore.openMqAdmin("mq-1", { tenant: "public" });
expect(queryStore.tabs.find((tab) => tab.id === firstTabId)?.mqTenant).toBe("public");
expect(queryStore.tabs.find((tab) => tab.id === firstTabId)?.mqInitialTab).toBeUndefined();
const secondTabId = queryStore.openMqAdmin("mq-1", { tenant: "tenant-a" });
const secondTabId = queryStore.openMqAdmin("mq-1", { tenant: "tenant-a", initialTab: "topics" });
expect(secondTabId).toBe(firstTabId);
expect(queryStore.tabs.find((tab) => tab.id === firstTabId)?.mqTenant).toBe("tenant-a");
expect(queryStore.tabs.find((tab) => tab.id === firstTabId)?.mqInitialTab).toBe("topics");
});
it("preserves the selected tenant when duplicating an MQ admin tab", async () => {
+16 -6
View File
@@ -58,6 +58,7 @@ import { kvRootNodeLabel } from "@/lib/kvRootPresentation";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redisKeyPattern";
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint";
import i18n from "@/i18n";
import type { MqAdminConfig } from "@/types/mq";
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection";
@@ -75,6 +76,13 @@ function sidebarObjectGroupPageSize(): number {
const size = settingsStore.desktopSettings.sidebar_table_page_size;
return typeof size === "number" && size > 0 ? size : 500;
}
function isKafkaMqConnection(config: ConnectionConfig | undefined): boolean {
if (!config || config.db_type !== "mq") return false;
if (config.driver_profile === "kafka") return true;
return (config.external_config as Partial<MqAdminConfig> | undefined)?.systemKind === "kafka";
}
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
interface LocateTableTarget {
@@ -127,14 +135,18 @@ function ensureSqlServerLinkedRootNode(connectionId: string, children: TreeNode[
// Temporary storage for DataGrip import payload (used to read Keychain passwords after import)
let pendingDataGripPayload: { format: "datagrip-import"; dataSources: string; dataSourcesLocal?: string } | null = null;
interface TreeClipboardTableStructure {
kind: "table-structure";
interface TreeClipboardTableEntry {
connectionId: string;
database: string;
schema?: string;
tableName: string;
}
export interface TreeClipboard {
kind: "table-copy";
tables: TreeClipboardTableEntry[];
}
interface LoadTreeOptions {
force?: boolean;
expectedSidebarSearchQuery?: string;
@@ -160,7 +172,7 @@ export const useConnectionStore = defineStore("connection", () => {
const selectedTreeNodeId = ref<string | null>(null);
const selectedTreeNodeIds = ref<string[]>([]);
const treeSelectionAnchorId = ref<string | null>(null);
const treeClipboard = ref<TreeClipboardTableStructure | null>(null);
const treeClipboard = ref<TreeClipboard | null>(null);
watch(activeConnectionId, (id) => {
if (id) localStorage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, id);
@@ -1627,9 +1639,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (useCachedChildren(node, options)) return;
const config = getConfig(connectionId);
const isKafka = config?.driver_profile === "kafka";
if (isKafka) {
if (isKafkaMqConnection(config)) {
// Kafka has no tenant/namespace concept. Create a synthetic child
// that opens the MQ admin console directly when clicked.
setChildren(node, [
+6 -1
View File
@@ -1417,7 +1417,11 @@ fn sqlserver_columns_sql(schema: &str, table: &str) -> String {
ELSE CONVERT(INT, c.max_length) \
END AS CHARACTER_MAXIMUM_LENGTH, \
CONVERT(INT, c.scale) AS DATETIME_PRECISION, \
CASE WHEN ic.column_id IS NOT NULL THEN 'identity(' + CONVERT(VARCHAR(38), ic.seed_value) + ',' + CONVERT(VARCHAR(38), ic.increment_value) + ')' ELSE NULL END AS COLUMN_EXTRA, \
CASE \
WHEN c.is_computed = 1 THEN 'computed' \
WHEN ic.column_id IS NOT NULL THEN 'identity(' + CONVERT(VARCHAR(38), ic.seed_value) + ',' + CONVERT(VARCHAR(38), ic.increment_value) + ')' \
ELSE NULL \
END AS COLUMN_EXTRA, \
ep.value AS COLUMN_COMMENT \
FROM sys.objects o \
JOIN sys.schemas s ON s.schema_id = o.schema_id \
@@ -1963,6 +1967,7 @@ mod tests {
assert!(sql.contains("ep.major_id = c.object_id"));
assert!(sql.contains("ep.minor_id = c.column_id"));
assert!(sql.contains("MS_Description"));
assert!(sql.contains("c.is_computed = 1 THEN 'computed'"));
}
#[test]
+104
View File
@@ -116,6 +116,23 @@ pub struct DuplicateTableStructureSqlOptions {
pub target_name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CopyTableDataSqlOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_type: Option<DatabaseType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
pub source_name: String,
pub target_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub columns: Option<Vec<String>>,
#[serde(default)]
pub postgres_overriding_system_value: bool,
#[serde(default)]
pub sqlserver_identity_insert: bool,
}
const MYSQL_COMPATIBLE_PROFILES: &[&str] =
&["mysql", "mariadb", "tidb", "oceanbase", "doris", "starrocks", "custom_mysql"];
@@ -309,6 +326,32 @@ pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOp
format!("CREATE TABLE {target} AS SELECT * FROM {source} WHERE 0;")
}
pub fn build_copy_table_data_sql(options: CopyTableDataSqlOptions) -> String {
let source = qualified_name(options.database_type, options.schema.as_deref(), &options.source_name);
let target = qualified_name(options.database_type, options.schema.as_deref(), &options.target_name);
let Some(columns) = options.columns.filter(|columns| !columns.is_empty()) else {
return format!("INSERT INTO {target} SELECT * FROM {source};");
};
let column_list = columns
.iter()
.map(|column| quote_table_identifier(options.database_type, column))
.collect::<Vec<_>>()
.join(", ");
let postgres_override = if options.postgres_overriding_system_value
&& matches!(options.database_type, Some(DatabaseType::Postgres | DatabaseType::Gaussdb | DatabaseType::Kwdb))
{
" OVERRIDING SYSTEM VALUE"
} else {
""
};
let insert_sql =
format!("INSERT INTO {target} ({column_list}){postgres_override} SELECT {column_list} FROM {source};");
if options.sqlserver_identity_insert && options.database_type == Some(DatabaseType::SqlServer) {
return format!("SET IDENTITY_INSERT {target} ON;\n{insert_sql}\nSET IDENTITY_INSERT {target} OFF;");
}
insert_sql
}
pub fn supports_object_rename(database_type: Option<DatabaseType>, object_type: DatabaseObjectType) -> bool {
let Some(database_type) = database_type else {
return false;
@@ -766,6 +809,15 @@ mod tests {
}),
"CREATE TABLE `users_copy` LIKE `users`;"
);
assert_eq!(
build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
database_type: Some(DatabaseType::Postgres),
schema: Some("public".to_string()),
source_name: "users".to_string(),
target_name: "users_copy".to_string(),
}),
"CREATE TABLE \"public\".\"users_copy\" (LIKE \"public\".\"users\" INCLUDING ALL);"
);
assert_eq!(
build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
database_type: Some(DatabaseType::Kwdb),
@@ -813,6 +865,58 @@ mod tests {
);
}
#[test]
fn builds_copy_table_data_sql() {
assert_eq!(
build_copy_table_data_sql(CopyTableDataSqlOptions {
database_type: Some(DatabaseType::Sqlite),
schema: None,
source_name: "users".to_string(),
target_name: "users_copy".to_string(),
columns: None,
postgres_overriding_system_value: false,
sqlserver_identity_insert: false,
}),
"INSERT INTO \"users_copy\" SELECT * FROM \"users\";"
);
assert_eq!(
build_copy_table_data_sql(CopyTableDataSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
source_name: "users".to_string(),
target_name: "users_copy".to_string(),
columns: Some(vec!["id".to_string(), "name".to_string()]),
postgres_overriding_system_value: false,
sqlserver_identity_insert: false,
}),
"INSERT INTO `users_copy` (`id`, `name`) SELECT `id`, `name` FROM `users`;"
);
assert_eq!(
build_copy_table_data_sql(CopyTableDataSqlOptions {
database_type: Some(DatabaseType::Postgres),
schema: Some("public".to_string()),
source_name: "users".to_string(),
target_name: "users_copy".to_string(),
columns: Some(vec!["id".to_string(), "name".to_string()]),
postgres_overriding_system_value: true,
sqlserver_identity_insert: false,
}),
"INSERT INTO \"public\".\"users_copy\" (\"id\", \"name\") OVERRIDING SYSTEM VALUE SELECT \"id\", \"name\" FROM \"public\".\"users\";"
);
assert_eq!(
build_copy_table_data_sql(CopyTableDataSqlOptions {
database_type: Some(DatabaseType::SqlServer),
schema: Some("dbo".to_string()),
source_name: "users".to_string(),
target_name: "users_copy".to_string(),
columns: Some(vec!["id".to_string(), "name".to_string()]),
postgres_overriding_system_value: false,
sqlserver_identity_insert: true,
}),
"SET IDENTITY_INSERT [dbo].[users_copy] ON;\nINSERT INTO [dbo].[users_copy] ([id], [name]) SELECT [id], [name] FROM [dbo].[users];\nSET IDENTITY_INSERT [dbo].[users_copy] OFF;"
);
}
#[test]
fn builds_mysql_table_and_view_rename_sql() {
assert_eq!(
+1
View File
@@ -331,6 +331,7 @@ async fn main() {
.route("/query/build-create-schema-sql", post(routes::query::build_create_schema_sql))
.route("/query/build-drop-schema-sql", post(routes::query::build_drop_schema_sql))
.route("/query/build-duplicate-table-structure-sql", post(routes::query::build_duplicate_table_structure_sql))
.route("/query/build-copy-table-data-sql", post(routes::query::build_copy_table_data_sql))
.route(
"/query/build-executable-object-source-statements",
post(routes::query::build_executable_object_source_statements),
+10
View File
@@ -174,6 +174,12 @@ pub struct BuildDuplicateTableStructureSqlRequest {
pub options: dbx_core::db_admin_sql::DuplicateTableStructureSqlOptions,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildCopyTableDataSqlRequest {
pub options: dbx_core::db_admin_sql::CopyTableDataSqlOptions,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildExecutableObjectSourceRequest {
@@ -622,6 +628,10 @@ pub async fn build_duplicate_table_structure_sql(
Json(dbx_core::db_admin_sql::build_duplicate_table_structure_sql(req.options))
}
pub async fn build_copy_table_data_sql(Json(req): Json<BuildCopyTableDataSqlRequest>) -> Json<String> {
Json(dbx_core::db_admin_sql::build_copy_table_data_sql(req.options))
}
pub async fn build_executable_object_source_statements(
Json(req): Json<BuildExecutableObjectSourceRequest>,
) -> Result<Json<Vec<String>>, AppError> {
+15 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { copyToClipboard, eventTargetAllowsNativeClipboard, hasNativeClipboardSelection, isPlainClipboardShortcut, readTextFromClipboard } from "../../apps/desktop/src/lib/clipboard.ts";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut, eventTargetAllowsNativeClipboard, hasNativeClipboardSelection, isPlainClipboardShortcut, readTextFromClipboard } from "../../apps/desktop/src/lib/clipboard.ts";
test("copyToClipboard falls back when navigator clipboard is unavailable", async () => {
const appended: unknown[] = [];
@@ -76,6 +76,20 @@ test("eventTargetAllowsNativeClipboard lets editable targets keep clipboard shor
assert.equal(eventTargetAllowsNativeClipboard({ key: "v", ctrlKey: true, target: inputTarget }), true);
});
test("eventTargetAllowsAppClipboardShortcut ignores editable targets only", () => {
const inputTarget = {
closest: (selector: string) => (selector.includes("input") ? {} : null),
} as unknown as EventTarget;
const buttonTarget = {
closest: () => null,
} as unknown as EventTarget;
assert.equal(eventTargetAllowsAppClipboardShortcut({ key: "v", ctrlKey: true, target: inputTarget }), false);
assert.equal(eventTargetAllowsAppClipboardShortcut({ key: "v", ctrlKey: true, target: buttonTarget }), true);
assert.equal(eventTargetAllowsAppClipboardShortcut({ key: "v", ctrlKey: true, shiftKey: true, target: buttonTarget }), false);
assert.equal(eventTargetAllowsAppClipboardShortcut({ key: "c", metaKey: true, target: buttonTarget }, "c"), true);
});
test("hasNativeClipboardSelection detects selections inside one native clipboard region", () => {
const region = {};
const element = {
+61
View File
@@ -0,0 +1,61 @@
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/tableClipboard.ts";
test("table clipboard entries must match the paste target context", () => {
const target = { connectionId: "c1", database: "app", schema: "public" };
assert.equal(tableClipboardMatchesTarget([{ connectionId: "c1", database: "app", schema: "public" }], target), true);
assert.equal(tableClipboardMatchesTarget([{ connectionId: "c2", database: "app", schema: "public" }], target), false);
assert.equal(tableClipboardMatchesTarget([{ connectionId: "c1", database: "other", schema: "public" }], target), false);
assert.equal(tableClipboardMatchesTarget([{ connectionId: "c1", database: "app", schema: "audit" }], target), false);
assert.equal(tableClipboardMatchesTarget([], target), false);
assert.equal(tableClipboardMatchesTarget([{ connectionId: "c1", database: "app" }], null), false);
});
test("whole-row table data copy is enabled for known database types", () => {
assert.equal(supportsWholeRowTableDataCopy("mysql"), true);
assert.equal(supportsWholeRowTableDataCopy("postgres"), true);
assert.equal(supportsWholeRowTableDataCopy("sqlserver"), true);
assert.equal(supportsWholeRowTableDataCopy(undefined), false);
assert.equal(supportsWholeRowTableDataCopy("sqlite"), true);
assert.equal(defaultPasteTableMode("mysql"), "structure-and-data");
assert.equal(defaultPasteTableMode(undefined), "structure-only");
assert.equal(defaultPasteTableMode("sqlite"), "structure-and-data");
assert.equal(pasteTableModeCopiesData("structure-and-data"), true);
assert.equal(pasteTableModeCopiesData("data-only"), true);
assert.equal(pasteTableModeCopiesData("structure-only"), false);
});
test("table data copy uses only writable columns for first-class databases", () => {
const column = (name: string, extra: string | null): ColumnInfo => ({
name,
data_type: "int",
is_nullable: true,
column_default: null,
is_primary_key: name === "id",
extra,
comment: null,
numeric_precision: null,
numeric_scale: null,
character_maximum_length: null,
});
const columns = [column("id", "identity(1,1)"), column("name", null), column("full_name", "computed")];
assert.deepEqual(tableDataCopyColumnOptions("sqlserver", columns), {
columns: ["id", "name"],
postgresOverridingSystemValue: false,
sqlserverIdentityInsert: true,
});
assert.deepEqual(tableDataCopyColumnOptions("postgres", [{ ...columns[0], extra: "generated always as identity" }, { ...columns[1] }, { ...columns[2], extra: "generated always as (name) stored" }]), {
columns: ["id", "name"],
postgresOverridingSystemValue: true,
sqlserverIdentityInsert: false,
});
assert.deepEqual(tableDataCopyColumnOptions("mysql", [{ ...columns[0], extra: "auto_increment" }, { ...columns[1] }, { ...columns[2], extra: "STORED GENERATED" }]), {
columns: ["id", "name"],
postgresOverridingSystemValue: false,
sqlserverIdentityInsert: false,
});
});
+5
View File
@@ -344,6 +344,11 @@ pub fn build_duplicate_table_structure_sql(
Ok(dbx_core::db_admin_sql::build_duplicate_table_structure_sql(options))
}
#[tauri::command]
pub fn build_copy_table_data_sql(options: dbx_core::db_admin_sql::CopyTableDataSqlOptions) -> Result<String, String> {
Ok(dbx_core::db_admin_sql::build_copy_table_data_sql(options))
}
#[tauri::command]
pub fn build_executable_object_source_statements(
input: dbx_core::object_source_sql::EditableObjectSourceSqlInput,
+1
View File
@@ -741,6 +741,7 @@ pub fn run() {
commands::query::build_create_schema_sql,
commands::query::build_drop_schema_sql,
commands::query::build_duplicate_table_structure_sql,
commands::query::build_copy_table_data_sql,
commands::query::build_executable_object_source_statements,
commands::query::build_executable_object_source_sql,
commands::query::build_editable_object_source,