mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
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:
@@ -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">→</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">
|
||||
|
||||
@@ -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">→</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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "テーブル構造を編集",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "编辑表结构",
|
||||
|
||||
@@ -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: "編輯資料表結構",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '""')}"`;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user