diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue
index 4e7a9be443..4906aa4c95 100644
--- a/apps/desktop/src/App.vue
+++ b/apps/desktop/src/App.vue
@@ -944,8 +944,10 @@ function sendSelectionToAi(sql: string) {
let addToAiRequestId = 0;
-async function addToAi(node: TreeNode) {
- if ((node.type !== "connection" && node.type !== "database" && node.type !== "table") || !node.connectionId) return;
+async function addToAi(nodesInput: TreeNode | TreeNode[]) {
+ const nodes = Array.isArray(nodesInput) ? nodesInput : [nodesInput];
+ const node = nodes[0];
+ if (!node || (node.type !== "connection" && node.type !== "database" && node.type !== "table") || !node.connectionId) return;
const connection = connectionStore.getConfig(node.connectionId);
if (!connection) return;
const requestId = ++addToAiRequestId;
@@ -984,10 +986,12 @@ async function addToAi(node: TreeNode) {
queryStore.createTab(node.connectionId, target.database, undefined, "query", target.schema, undefined, target.catalog);
}
+ const tableMentions = nodes.filter((entry) => entry.type === "table" && !!entry.label).map((entry) => ({ schema: entry.schema, table: entry.label }));
+
openRightSidebarPanel("ai");
invokeWhenAiReady((handle) => {
if (contextChanged) handle.clearContextReferences();
- if (node.type === "table") handle.addTableMention({ schema: node.schema, table: node.label });
+ for (const mention of tableMentions) handle.addTableMention(mention);
});
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
diff --git a/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue b/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue
index 4f01494e91..235eacc655 100644
--- a/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue
+++ b/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue
@@ -272,6 +272,7 @@ const props = defineProps<{
prefillDatabase?: string;
prefillSchema?: string;
focusTableName?: string;
+ focusTableNames?: string[];
}>();
const emit = defineEmits<{
@@ -395,6 +396,8 @@ const tableMap = computed(() => new Map(tables.value.map((table) => [table.name,
const allRelationships = computed(() => buildDiagramRelationships(tables.value, customRelationships.value));
const relatedTableNames = computed(() => {
+ const selected = props.focusTableNames?.filter((name) => name.trim());
+ if (selected && selected.length > 1) return new Set(selected);
const focus = props.focusTableName;
const names = new Set();
if (!focus) return names;
@@ -411,6 +414,9 @@ const visibleTables = computed(() => {
tables.value.filter((table) => !table.pendingDrop),
tableSearch.value,
);
+ if ((props.focusTableNames?.length ?? 0) > 1 && !showAllTables.value && !tableSearch.value.trim()) {
+ return filtered.filter((table) => relatedTableNames.value.has(table.name));
+ }
if (props.focusTableName && !showAllTables.value && !tableSearch.value.trim()) {
return filtered.filter((table) => relatedTableNames.value.has(table.name));
}
diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
index 18c326dde4..f36f4aa680 100644
--- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue
+++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
@@ -149,6 +149,7 @@ import {
import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keyboardShortcuts";
import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
+import { COLUMN_NAME_COPY_SEPARATOR_LABELS, COLUMN_NAME_COPY_SEPARATOR_OPTIONS, isColumnNameCopySeparator, type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
@@ -524,6 +525,8 @@ const editShowTableDdlHoverPreview = ref(settingsStore.editorSettings.showTableD
const editClickTableNavigationTarget = ref(settingsStore.editorSettings.clickTableNavigationTarget);
const editUpdateNotificationsEnabled = ref(settingsStore.editorSettings.updateNotificationsEnabled);
const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"));
+const editSidebarCopyTableNameSeparator = ref(settingsStore.editorSettings.sidebarCopyTableNameSeparator);
+const editSidebarCopyTableNameIncludeSchema = ref(settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema);
const editRedisKeyTemplates = ref(normalizeRedisKeyTemplates(settingsStore.editorSettings.redisKeyTemplates).join("\n"));
const editSidebarObjectInfoMode = ref(settingsStore.editorSettings.sidebarObjectInfoMode);
const editSidebarAllowHorizontalScroll = ref(settingsStore.editorSettings.sidebarAllowHorizontalScroll);
@@ -672,6 +675,8 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
sidebarIndent: editSidebarIndent.value,
sidebarFontSize: editSidebarFontSize.value,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value),
+ sidebarCopyTableNameSeparator: editSidebarCopyTableNameSeparator.value,
+ sidebarCopyTableNameIncludeSchema: editSidebarCopyTableNameIncludeSchema.value,
redisKeyTemplates: normalizeRedisKeyTemplates(editRedisKeyTemplates.value),
exportBatchSize: editExportBatchSize.value,
globalDateTimeDisplayFormat: editGlobalDateTimeDisplayFormat.value,
@@ -1066,6 +1071,8 @@ function syncEditorSettingsDraftFromStore() {
editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget;
editUpdateNotificationsEnabled.value = settingsStore.editorSettings.updateNotificationsEnabled;
editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n");
+ editSidebarCopyTableNameSeparator.value = settingsStore.editorSettings.sidebarCopyTableNameSeparator;
+ editSidebarCopyTableNameIncludeSchema.value = settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema;
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(settingsStore.editorSettings.redisKeyTemplates).join("\n");
editSidebarObjectInfoMode.value = settingsStore.editorSettings.sidebarObjectInfoMode;
editSidebarAllowHorizontalScroll.value = settingsStore.editorSettings.sidebarAllowHorizontalScroll;
@@ -1332,6 +1339,8 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editSidebarIndent.value = DEFAULT_EDITOR_SETTINGS.sidebarIndent;
editSidebarFontSize.value = DEFAULT_EDITOR_SETTINGS.sidebarFontSize;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
+ editSidebarCopyTableNameSeparator.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameSeparator;
+ editSidebarCopyTableNameIncludeSchema.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameIncludeSchema;
editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems };
} else if (tab === "data") {
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
@@ -1466,6 +1475,8 @@ function resetAllDefaults() {
editSidebarIndent.value = DEFAULT_EDITOR_SETTINGS.sidebarIndent;
editSidebarFontSize.value = DEFAULT_EDITOR_SETTINGS.sidebarFontSize;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
+ editSidebarCopyTableNameSeparator.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameSeparator;
+ editSidebarCopyTableNameIncludeSchema.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameIncludeSchema;
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n");
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat;
@@ -5733,6 +5744,40 @@ onUnmounted(() => {
{{ t("settings.sidebarHiddenTablePrefixesDescription") }}
+
+
+
+
+ {{ t("settings.sidebarCopyTableNameSeparatorDescription") }}
+
+
+
+
+
+
+
+
+ {{ t("settings.sidebarCopyTableNameIncludeSchemaDescription") }}
+
+
+
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue
index 6585c91d0b..8e8c541176 100644
--- a/apps/desktop/src/components/editor/QueryEditor.vue
+++ b/apps/desktop/src/components/editor/QueryEditor.vue
@@ -3360,7 +3360,11 @@ function hasDroppedTableReference(event: DragEvent) {
function insertTableReferencePayload(currentView: EditorViewType, payload: QueryEditorTableReferencePayload, coords?: { clientX: number; clientY: number }): boolean {
if (props.readOnly) return false;
- const insertText = tableReferenceInsertText(payload, props.databaseType);
+ const insertText = tableReferenceInsertText(payload, props.databaseType, {
+ tableNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
+ columnNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
+ includeTableSchema: settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema,
+ });
const dropPos = coords ? currentView.posAtCoords({ x: coords.clientX, y: coords.clientY }) : null;
const selection = currentView.state.selection.main;
const from = dropPos ?? selection.from;
diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue
index c87afef9fd..91639a2ba4 100644
--- a/apps/desktop/src/components/layout/AppDialogs.vue
+++ b/apps/desktop/src/components/layout/AppDialogs.vue
@@ -272,6 +272,7 @@ watch(
:prefill-database="dialogs.diagramPrefillDatabase.value"
:prefill-schema="dialogs.diagramPrefillSchema.value"
:focus-table-name="dialogs.diagramFocusTableName.value"
+ :focus-table-names="dialogs.diagramFocusTableNames.value"
@open-target="emit('openDiagramTarget', $event)"
/>
diff --git a/apps/desktop/src/components/layout/AppSidebar.vue b/apps/desktop/src/components/layout/AppSidebar.vue
index c559beb738..6aae3a1395 100644
--- a/apps/desktop/src/components/layout/AppSidebar.vue
+++ b/apps/desktop/src/components/layout/AppSidebar.vue
@@ -26,7 +26,7 @@ const emit = defineEmits<{
startResize: [event: MouseEvent];
collapse: [];
"open-settings": [initialTab: string];
- "add-to-ai": [node: TreeNode];
+ "add-to-ai": [nodes: TreeNode | TreeNode[]];
}>();
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
@@ -293,7 +293,7 @@ defineExpose({ focusSearch, locateTabInSidebar });
- emit('open-settings', initialTab)" @add-to-ai="(node) => emit('add-to-ai', node)" />
+ emit('open-settings', initialTab)" @add-to-ai="(nodes) => emit('add-to-ai', nodes)" />
diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue
index 7ae30e1de0..b41f0c3b1b 100644
--- a/apps/desktop/src/components/sidebar/ConnectionTree.vue
+++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue
@@ -29,10 +29,11 @@ import { createSidebarSearchLoadingTracker } from "@/lib/sidebar/sidebarSearchLo
import { buildTableTreeNodes } from "@/lib/table/tableTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut";
-import { copyNameForTreeNode, objectSourceTargetForTreeNode } from "@/lib/sidebar/treeNodeClick";
+import { objectSourceTargetForTreeNode } from "@/lib/sidebar/treeNodeClick";
import { supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { copyToClipboard } from "@/lib/common/clipboard";
import { connectionPasteTargetGroupId, copySelectedConnectionsToClipboards, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
+import { formatSidebarTableCopyText } from "@/lib/sidebar/sidebarTableNameCopy";
import { pruneTreeSelectionToVisibleNodeIds } from "@/lib/sidebar/sidebarTreeSelection";
import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch";
import { isInternalDorisCatalog, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
@@ -116,7 +117,7 @@ const sidebarContextMenuRef = ref<{ close: () => void } | null>(null);
const sidebarContextMenuItems = ref([]);
const emit = defineEmits<{
"open-settings": [initialTab: string];
- "add-to-ai": [node: TreeNode];
+ "add-to-ai": [nodes: TreeNode | TreeNode[]];
}>();
const sidebarContextMenuTarget = ref(null);
@@ -1959,7 +1960,7 @@ function openSidebarDdlForSelection(): boolean {
const selectedNodeId = store.selectedTreeNodeId;
const node = selectedNodeId ? flatTreeIndex.value.nodeById.get(selectedNodeId) : null;
if (!node || !sidebarNodeSupportsDdlView(node)) return false;
- openSidebarDdl(node);
+ void sidebarTreeRuntimeHostRef.value?.openDdlForSelection?.(node, store.selectedTreeNodeIds);
return true;
}
@@ -2335,7 +2336,17 @@ function copySelectedSidebarNames(): boolean {
})),
}
: null;
- copyToClipboard(nodes.map(copyNameForTreeNode).join("\n"))
+ const activeNodeId = store.selectedTreeNodeId;
+ const activeNode = activeNodeId ? (flatTreeIndex.value.nodeById.get(activeNodeId) ?? nodes[0]!) : nodes[0]!;
+ const config = activeNode.connectionId ? store.getConfig(activeNode.connectionId) : undefined;
+ const copyText = formatSidebarTableCopyText(activeNode, nodes, {
+ separator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
+ includeSchema: settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema,
+ databaseType: activeNode.connectionId ? effectiveDatabaseTypeForConnection(config) : undefined,
+ driverProfile: config?.driver_profile,
+ identifierQuote: activeNode.connectionId ? store.connectionIdentifierQuote?.(activeNode.connectionId) : undefined,
+ });
+ copyToClipboard(copyText)
.then(() => toast(t("connection.copied"), 2000))
.catch((e: any) => toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000));
return true;
@@ -2420,7 +2431,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
@open-visible-schemas="openSidebarVisibleSchemas"
@open-visible-nacos-namespaces="openSidebarVisibleNacosNamespaces"
@open-table-name-filters="openSidebarTableNameFilters"
- @add-to-ai="(node) => emit('add-to-ai', node)"
+ @add-to-ai="(nodes) => emit('add-to-ai', nodes)"
@request-connection-rename="startRenamingConnectionNode"
@request-group-rename="startRenamingCreatedGroup"
@request-saved-sql-rename="startRenamingSavedSqlNode"
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
index 275e4dccb0..44a83815f8 100644
--- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
+++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
@@ -88,6 +88,7 @@ import { buildXuguSchedulerJobSql, type XuguSchedulerJobAction } from "@/lib/dat
import { elasticsearchClearIndexPreview, isElasticsearchClearConfirmed, isElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions";
import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates";
+import { joinExportedDdls } from "@/lib/export/ddlExport";
import { qualifiedTableName } from "@/lib/table/tableSelectSql";
import { driverStoreFocusForInstallError } from "@/lib/connection/agentDriverInstallHint";
import {
@@ -177,8 +178,10 @@ import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty";
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
import { runBatchTableDrop } from "@/lib/table/batchTableDrop";
-import { buildSidebarDdlTemplateSql, sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate";
-import { sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
+import { buildSidebarDdlTemplateSql } from "@/lib/sidebar/sidebarDdlTemplate";
+import { resolveSidebarDdlTargets } from "@/lib/sidebar/sidebarDdlTargets";
+import { sidebarTableDataExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
+import { formatSidebarTableCopyText, type FormatSidebarTableNamesOptions } from "@/lib/sidebar/sidebarTableNameCopy";
import { supportsScheduledDatabaseBackup } from "@/lib/backup/scheduledDatabaseBackup";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -389,6 +392,7 @@ const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabas
queryStore,
settingsStore,
tableChildObjectName: tableChildDropObjectName,
+ acceptedSelectionIds: () => acceptedSelectionIds,
});
const emit = defineEmits<{
@@ -409,7 +413,7 @@ const emit = defineEmits<{
"open-visible-schemas": [node: TreeNode];
"open-visible-nacos-namespaces": [node: TreeNode];
"open-table-name-filters": [node: TreeNode];
- "add-to-ai": [node: TreeNode];
+ "add-to-ai": [nodes: TreeNode | TreeNode[]];
"open-danger-dialog": [request: SidebarDangerDialogRequest];
"open-dialog-controller": [controller: Record | null];
"open-install-extension": [node: TreeNode];
@@ -637,6 +641,21 @@ function databaseTypeForNode(node: TreeNode): DatabaseType | undefined {
return node.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)) : undefined;
}
+function sidebarTableCopyFormatOptions(node: TreeNode = activeNode.value): FormatSidebarTableNamesOptions {
+ const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
+ return {
+ separator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
+ includeSchema: settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema,
+ databaseType: node.connectionId ? effectiveDatabaseTypeForConnection(config) : undefined,
+ driverProfile: config?.driver_profile,
+ identifierQuote: node.connectionId ? connectionStore.connectionIdentifierQuote?.(node.connectionId) : undefined,
+ };
+}
+
+function formatSelectedTableNamesForClipboard(selectedNodes: readonly TreeNode[] = selectedTreeNodesInVisibleOrder()): string {
+ return formatSidebarTableCopyText(activeNode.value, selectedNodes, sidebarTableCopyFormatOptions());
+}
+
function tableStructureDatabaseTypeForNode(node: TreeNode): DatabaseType | undefined {
return node.connectionId ? tableStructureDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)) : undefined;
}
@@ -1755,8 +1774,7 @@ function openRedisInstanceInfo() {
queryStore.createTab(node.connectionId, "0", `${dbName} - ${t("contextMenu.instanceInfo")}`, "redis-dashboard");
}
-async function loadTemplateContext(allowView = false) {
- const node = activeNode.value;
+async function loadTemplateContext(allowView = false, node: TreeNode = activeNode.value) {
if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return null;
const isTableNode = node.type === "table";
const isReadableObject = isTableNode || (allowView && (node.type === "view" || node.type === "materialized_view"));
@@ -1791,6 +1809,20 @@ async function loadTemplateContext(allowView = false) {
return { node, dbType, driverProfile: config?.driver_profile, identifierQuote, tableSchema, columns, tableType };
}
+async function openMultiTableSqlTemplate(targets: Array, buildSql: (context: NonNullable>>) => string, allowView: boolean, titlePrefix: string) {
+ const parts: string[] = [];
+ const tabTarget = targets.find((target) => target.id === activeNode.value.id) ?? targets[0]!;
+ for (const target of targets) {
+ const context = await loadTemplateContext(allowView, target);
+ if (!context) continue;
+ parts.push(buildSql(context));
+ }
+ if (!parts.length) return;
+ const sql = parts.length === 1 ? parts[0]! : joinExportedDdls(parts);
+ const title = targets.length === 1 ? undefined : `${titlePrefix} - ${targets.map((target) => target.label).join(", ")}`;
+ openSqlTemplateTab(tabTarget.connectionId, tabTarget.database, tabTarget.schema, tabTarget.catalog, sql, title);
+}
+
function openSqlTemplateTab(connectionId: string, database: string, schema: string | undefined, catalog: string | undefined, sql: string, title?: string) {
const tabId = queryStore.createTab(connectionId, database, title, "query", schema, undefined, catalog, { forceWordWrap: true });
queryStore.updateSql(tabId, sql);
@@ -1798,6 +1830,27 @@ function openSqlTemplateTab(connectionId: string, database: string, schema: stri
async function newSelectTemplate() {
try {
+ const targets = selectedDdlTargets().filter((target) => target.type === "table" || target.type === "view" || target.type === "materialized_view");
+ if (targets.length > 1) {
+ await openMultiTableSqlTemplate(
+ targets,
+ (context) =>
+ buildTableSelectTemplate({
+ databaseType: context.dbType,
+ driverProfile: context.driverProfile,
+ identifierQuote: context.identifierQuote,
+ catalog: context.node.catalog,
+ database: context.node.database,
+ schema: context.tableSchema,
+ includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
+ tableName: context.node.label,
+ columns: context.columns,
+ }),
+ true,
+ "SELECT",
+ );
+ return;
+ }
const context = await loadTemplateContext(true);
if (!context) return;
const sql = buildTableSelectTemplate({
@@ -1819,6 +1872,28 @@ async function newSelectTemplate() {
async function newInsertTemplate() {
try {
+ const targets = selectedDdlTargets().filter((target) => target.type === "table");
+ if (targets.length > 1) {
+ await openMultiTableSqlTemplate(
+ targets,
+ (context) =>
+ buildTableInsertTemplate({
+ databaseType: context.dbType,
+ driverProfile: context.driverProfile,
+ identifierQuote: context.identifierQuote,
+ catalog: context.node.catalog,
+ database: context.node.database,
+ schema: context.tableSchema,
+ includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
+ tableName: context.node.label,
+ columns: context.columns,
+ tableType: context.tableType,
+ }),
+ false,
+ "INSERT",
+ );
+ return;
+ }
const context = await loadTemplateContext(false);
if (!context) return;
const sql = buildTableInsertTemplate({
@@ -1841,6 +1916,27 @@ async function newInsertTemplate() {
async function newUpdateTemplate() {
try {
+ const targets = selectedDdlTargets().filter((target) => target.type === "table");
+ if (targets.length > 1) {
+ await openMultiTableSqlTemplate(
+ targets,
+ (context) =>
+ buildTableUpdateTemplate({
+ databaseType: context.dbType,
+ driverProfile: context.driverProfile,
+ identifierQuote: context.identifierQuote,
+ catalog: context.node.catalog,
+ database: context.node.database,
+ schema: context.tableSchema,
+ includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
+ tableName: context.node.label,
+ columns: context.columns,
+ }),
+ false,
+ "UPDATE",
+ );
+ return;
+ }
const context = await loadTemplateContext(false);
if (!context) return;
const sql = buildTableUpdateTemplate({
@@ -1862,6 +1958,27 @@ async function newUpdateTemplate() {
async function newDeleteTemplate() {
try {
+ const targets = selectedDdlTargets().filter((target) => target.type === "table");
+ if (targets.length > 1) {
+ await openMultiTableSqlTemplate(
+ targets,
+ (context) =>
+ buildTableDeleteTemplate({
+ databaseType: context.dbType,
+ driverProfile: context.driverProfile,
+ identifierQuote: context.identifierQuote,
+ catalog: context.node.catalog,
+ database: context.node.database,
+ schema: context.tableSchema,
+ includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
+ tableName: context.node.label,
+ columns: context.columns,
+ }),
+ false,
+ "DELETE",
+ );
+ return;
+ }
const context = await loadTemplateContext(false);
if (!context) return;
const sql = buildTableDeleteTemplate({
@@ -1881,41 +1998,44 @@ async function newDeleteTemplate() {
}
}
+async function openSidebarMultiTableDdlTab(targets: Array) {
+ const tabTarget = targets.find((target) => target.id === activeNode.value.id) ?? targets[0]!;
+ const sql = await buildSidebarDdlTemplateSql(
+ targets,
+ async (target) => {
+ await connectionStore.ensureConnected(target.connectionId);
+ const schema = target.schema || target.database;
+ if (target.type === "table") return api.getTableDisplayDdl(target.connectionId, target.database, schema, target.label, undefined, target.catalog);
+ if (target.type === "materialized_view") return api.getTableDisplayDdl(target.connectionId, target.database, schema, target.label, "MATERIALIZED_VIEW", target.catalog);
+ const result = await api.getObjectSource(target.connectionId, target.database, schema, target.label, "VIEW");
+ return buildViewDdl({
+ databaseType: databaseTypeForNode(target),
+ identifierQuote: connectionStore.connectionIdentifierQuote?.(target.connectionId),
+ schema,
+ name: target.label,
+ source: result.source,
+ });
+ },
+ (ddl, target) => formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseTypeForNode(target)), settingsStore.editorSettings.sqlFormatter),
+ );
+ connectionStore.activeConnectionId = tabTarget.connectionId;
+ const title = `DDL - ${targets.map((target) => target.label).join(", ")}`;
+ openSqlTemplateTab(tabTarget.connectionId, tabTarget.database, tabTarget.schema, tabTarget.catalog, sql, title);
+}
+
async function generateDdlTemplate() {
if (currentDatabaseType() === "victoriametrics") return;
const targets = selectedDdlTargets();
if (!targets.length) return;
- const tabTarget = targets.find((target) => target.id === activeNode.value.id) ?? targets[0]!;
try {
- const sql = await buildSidebarDdlTemplateSql(
- targets,
- async (target) => {
- await connectionStore.ensureConnected(target.connectionId);
- const schema = target.schema || target.database;
- if (target.type === "table") return api.getTableDisplayDdl(target.connectionId, target.database, schema, target.label, undefined, target.catalog);
- if (target.type === "materialized_view") return api.getTableDisplayDdl(target.connectionId, target.database, schema, target.label, "MATERIALIZED_VIEW", target.catalog);
- const result = await api.getObjectSource(target.connectionId, target.database, schema, target.label, "VIEW");
- return buildViewDdl({
- databaseType: databaseTypeForNode(target),
- identifierQuote: connectionStore.connectionIdentifierQuote?.(target.connectionId),
- schema,
- name: target.label,
- source: result.source,
- });
- },
- (ddl, target) => formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseTypeForNode(target)), settingsStore.editorSettings.sqlFormatter),
- );
- connectionStore.activeConnectionId = tabTarget.connectionId;
- const title = `DDL - ${targets.map((target) => target.label).join(", ")}`;
- openSqlTemplateTab(tabTarget.connectionId, tabTarget.database, tabTarget.schema, tabTarget.catalog, sql, title);
+ await openSidebarMultiTableDdlTab(targets);
} catch (e: any) {
toast(e?.message || String(e), 5000);
}
}
function selectedDdlTargets() {
- const targets = sidebarStructureExportTargets(activeNode.value, connectionStore.treeNodes, acceptedSelectionIds ?? connectionStore.selectedTreeNodeIds);
- return sidebarDdlTargetsForExecutionContext(activeNode.value as TreeNode & { connectionId: string; database: string }, targets);
+ return resolveSidebarDdlTargets(activeNode.value, connectionStore.treeNodes, acceptedSelectionIds ?? connectionStore.selectedTreeNodeIds);
}
async function openDdl() {
@@ -1923,12 +2043,35 @@ async function openDdl() {
const targets = selectedDdlTargets();
if (!targets.length) return;
if (targets.length > 1) {
- await exportStructure();
+ try {
+ await openSidebarMultiTableDdlTab(targets);
+ } catch (e: any) {
+ toast(e?.message || String(e), 5000);
+ }
return;
}
emit("open-ddl", targets[0]!);
}
+async function openDdlForSelection(node: TreeNode, selectedNodeIds: readonly string[]): Promise {
+ if (node.type !== "table" && node.type !== "view" && node.type !== "materialized_view") return false;
+ activateRuntimeNode(node);
+ const dbType = node.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)) : undefined;
+ if (dbType === "victoriametrics") return false;
+ const targets = resolveSidebarDdlTargets(node, connectionStore.treeNodes, selectedNodeIds);
+ if (!targets.length) return false;
+ if (targets.length > 1) {
+ try {
+ await openSidebarMultiTableDdlTab(targets);
+ } catch (e: any) {
+ toast(e?.message || String(e), 5000);
+ }
+ return true;
+ }
+ emit("open-ddl", targets[0]!);
+ return true;
+}
+
function openElasticsearchIndexMetadata(kind: ElasticsearchIndexMetadataKind) {
emit("open-elasticsearch-index-metadata", createSidebarActionTarget(activeNode.value), kind);
}
@@ -1944,9 +2087,8 @@ async function refresh() {
}
async function copyName() {
- const node = activeNode.value;
try {
- await copyToClipboard(copyNameForTreeNode(node));
+ await copyToClipboard(formatSelectedTableNamesForClipboard());
toast(t("connection.copied"), 2000);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
@@ -2038,7 +2180,7 @@ async function copySelectedNames() {
}
updateTreeClipboardForNodes(nodes);
try {
- await copyToClipboard(nodes.map(copyNameForTreeNode).join("\n"));
+ await copyToClipboard(formatSelectedTableNamesForClipboard(selectedNodes));
toast(t("connection.copied"), 2000);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
@@ -4844,7 +4986,26 @@ const shortcutRefresh = "F5";
const shortcutDelete = "Delete";
+function selectedDataExportTargetCount(): number {
+ return sidebarTableDataExportTargets(activeNode.value, connectionStore.treeNodes, acceptedSelectionIds ?? connectionStore.selectedTreeNodeIds).length;
+}
+
+function selectedAiTableTargets(): Array {
+ return resolveSidebarDdlTargets(activeNode.value, connectionStore.treeNodes, acceptedSelectionIds ?? connectionStore.selectedTreeNodeIds).filter((target) => target.type === "table");
+}
+
+function addToAiMenuItem(node: TreeNode): ContextMenuItem {
+ const tables = node.type === "table" ? selectedAiTableTargets() : [];
+ const count = tables.length;
+ return {
+ label: count > 1 ? t("contextMenu.addToAiMultiple", { count }) : t("contextMenu.addToAi"),
+ action: () => emit("add-to-ai", count > 1 ? tables : node),
+ icon: Sparkles,
+ };
+}
+
function exportDataSubmenu(includeSqlInsert = true): ContextMenuItem {
+ const count = selectedDataExportTargetCount();
const children: ContextMenuItem[] = [
{ label: "CSV", action: () => exportData("csv") },
{ label: "JSON", action: () => exportData("json") },
@@ -4852,7 +5013,7 @@ function exportDataSubmenu(includeSqlInsert = true): ContextMenuItem {
if (includeSqlInsert) children.push({ label: "SQL INSERT", action: () => exportData("sql") });
children.push({ label: "XLSX", action: () => exportDataXlsx() });
return {
- label: t("contextMenu.exportData"),
+ label: count > 1 ? t("contextMenu.exportDataMultiple", { count }) : t("contextMenu.exportData"),
icon: Upload,
children,
};
@@ -4974,7 +5135,7 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
if (supportsQueryActions) {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (supportsAiContext) {
- items.push({ label: t("contextMenu.addToAi"), action: () => emit("add-to-ai", node), icon: Sparkles });
+ items.push(addToAiMenuItem(node));
}
}
const connectionWorkspace = node.connectionId ? driverProfileDatabaseWorkspace(connectionStore.getConfig(node.connectionId)?.driver_profile) : undefined;
@@ -5190,7 +5351,7 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
if (supportsConnectionQueryActions(currentDatabaseType())) {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (node.type === "database" && supportsAiAssistantContext(currentDatabaseType())) {
- items.push({ label: t("contextMenu.addToAi"), action: () => emit("add-to-ai", node), icon: Sparkles });
+ items.push(addToAiMenuItem(node));
}
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
@@ -5524,7 +5685,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.push({ label: t("contextMenu.openInNewDataTab"), action: openDataInNewTabImmediately, icon: CopyPlus });
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (supportsAiAssistantContext(currentDatabaseType())) {
- items.push({ label: t("contextMenu.addToAi"), action: () => emit("add-to-ai", node), icon: Sparkles });
+ items.push(addToAiMenuItem(node));
}
items.push({ label: "", separator: true });
items.push(exportDataSubmenu(false));
@@ -5535,7 +5696,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.push(copyNameMenuItem());
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (node.type === "table" && supportsAiAssistantContext(currentDatabaseType())) {
- items.push({ label: t("contextMenu.addToAi"), action: () => emit("add-to-ai", node), icon: Sparkles });
+ items.push(addToAiMenuItem(node));
}
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.viewData"), action: openDataImmediately, icon: TableProperties });
@@ -6088,6 +6249,7 @@ defineExpose({
handleRowKeydown,
openPrimaryVisibleFilter,
openDataInNewTab,
+ openDdlForSelection,
requestPaste,
toggleNode,
});
diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue
index a895229f10..510c31b994 100644
--- a/apps/desktop/src/components/sidebar/TreeItem.vue
+++ b/apps/desktop/src/components/sidebar/TreeItem.vue
@@ -53,7 +53,17 @@ import { Switch } from "@/components/ui/switch";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { ColumnInfo, ConnectionConfig, CustomTypeTreeMemberMeta, DatabaseType, TreeNode, TriggerInfo } from "@/types/database";
import { alignedCommentLeadingWidth, canTreeNodePin, canTreeNodeShowExpander, sidebarTreeNodeComment, trailingCommentAvailableWidth, trailingCommentGapPx, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
-import { clearActiveTableReferencePayload, createTableReferenceDragEndEvent, createTableReferenceDropEvent, createTableReferenceHoverEvent, createTableReferencePayload, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
+import {
+ clearActiveTableReferencePayload,
+ createColumnReferencePayload,
+ createMultiTableReferencePayload,
+ createTableReferenceDragEndEvent,
+ createTableReferenceDropEvent,
+ createTableReferenceHoverEvent,
+ createTableReferencePayload,
+ setActiveTableReferencePayload,
+ type QueryEditorTableReferencePayload,
+} from "@/lib/editor/queryEditorTableDrop";
import { AI_ASSISTANT_TABLE_DROP_ROOT_SELECTOR } from "@/lib/ai/aiTableReferenceDrop";
import { beginTableReferenceDragFeedback, isOverSqlEditorTarget, type TableReferenceDragFeedback } from "@/lib/editor/tableReferenceDragFeedback";
import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
@@ -64,7 +74,8 @@ import { encodeSpannerResourcePath } from "@/lib/connection/spannerResourcePath"
import { hexToRgba } from "@/lib/common/color";
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelTooltip";
-import { filterSidebarModifierSelectionIds, supportsSidebarModifierSelection, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
+import { filterSidebarModifierSelectionIds, selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes, supportsSidebarModifierSelection, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
+import { resolveSidebarColumnDragNames, resolveSidebarTableCopyTargets, type SidebarTableCopyTarget } from "@/lib/sidebar/sidebarTableNameCopy";
import { applyConnectionMultiSelection, applyTreeNodeSelection, connectionMultiSelectionAfterToggle } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { connectionBearingGroupIdsUnder, connectionIdsUnderGroup } from "@/lib/sidebar/sidebarLayout";
import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState";
@@ -1172,13 +1183,31 @@ let referenceDragFeedback: TableReferenceDragFeedback | null = null;
let suppressNextTableReferenceClick = false;
+function selectedTreeNodesInVisibleOrder(): TreeNode[] {
+ return orderSelectedTreeNodes(visibleTreeNodes(), connectionStore.selectedTreeNodeIds);
+}
+
function tableReferenceDragLabel(payload: QueryEditorTableReferencePayload): string {
- if (payload.referenceType === "column" && payload.columnName) return payload.columnName;
+ if (payload.referenceType === "column") {
+ const names = payload.columnNames?.length ? payload.columnNames : payload.columnName ? [payload.columnName] : [];
+ if (names.length <= 3) return names.join(", ");
+ return t("grid.columnDragChipMany", { names: names.slice(0, 2).join(", "), count: names.length });
+ }
+ const tableNames = payload.tableReferences?.map((entry) => entry.tableName) ?? (payload.tableName ? [payload.tableName] : []);
+ if (tableNames.length <= 3) return tableNames.join(", ");
+ if (tableNames.length > 1) return t("grid.columnDragChipMany", { names: tableNames.slice(0, 2).join(", "), count: tableNames.length });
return payload.tableName || payload.database;
}
function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null {
if (!canDragTableReference.value) return null;
+ const selectedNodes = selectedTreeNodesInVisibleOrder();
+ const tableCopyOptions = {
+ tableNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
+ includeTableSchema: settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema,
+ databaseType: currentDatabaseType(),
+ driverProfile: currentDriverProfile(),
+ };
if (activeNode.value.type === "database") {
return createTableReferencePayload({
connectionId: activeNode.value.connectionId,
@@ -1189,16 +1218,24 @@ function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null {
});
}
if (activeNode.value.type === "column") {
- const columnName = columnNameForDrag(activeNode.value);
- if (!activeNode.value.tableName || !columnName) return null;
- return createTableReferencePayload({
+ const columnNames = resolveSidebarColumnDragNames(activeNode.value, selectedNodes);
+ if (!activeNode.value.connectionId || activeNode.value.database == null || columnNames.length === 0) return null;
+ return createColumnReferencePayload({
connectionId: activeNode.value.connectionId,
database: activeNode.value.database,
schema: activeNode.value.schema,
- tableName: activeNode.value.tableName,
- columnName,
+ columnNames,
databaseType: currentDatabaseType(),
- driverProfile: currentDriverProfile(),
+ columnNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
+ });
+ }
+ const tableTargets = resolveSidebarTableCopyTargets(activeNode.value, selectedNodes);
+ if (tableTargets.length > 1) {
+ return createMultiTableReferencePayload({
+ connectionId: activeNode.value.connectionId,
+ database: activeNode.value.database,
+ tableReferences: tableTargets.map((target: SidebarTableCopyTarget) => ({ schema: target.schema, tableName: target.label })),
+ ...tableCopyOptions,
});
}
const payload = createTableReferencePayload({
@@ -1209,15 +1246,13 @@ function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null {
databaseType: currentDatabaseType(),
driverProfile: currentDriverProfile(),
});
+ if (payload && tableCopyOptions.includeTableSchema) {
+ payload.includeTableSchema = true;
+ payload.tableNameSeparator = tableCopyOptions.tableNameSeparator;
+ }
return payload;
}
-function columnNameForDrag(node: TreeNode): string {
- const column = node.meta as Partial | undefined;
- if (typeof column?.name === "string" && column.name) return column.name;
- return node.label.replace(/\s+\([^()]*\)$/, "");
-}
-
function startTableReferenceDrag(payload: QueryEditorTableReferencePayload) {
draggingTableReferencePayload = payload;
setActiveTableReferencePayload(payload);
diff --git a/apps/desktop/src/components/sidebar/__tests__/TreeItem.tableReferenceDrag.spec.ts b/apps/desktop/src/components/sidebar/__tests__/TreeItem.tableReferenceDrag.spec.ts
index a786f4aa2d..a6421d4ca1 100644
--- a/apps/desktop/src/components/sidebar/__tests__/TreeItem.tableReferenceDrag.spec.ts
+++ b/apps/desktop/src/components/sidebar/__tests__/TreeItem.tableReferenceDrag.spec.ts
@@ -45,6 +45,8 @@ vi.mock("@/stores/settingsStore", () => ({
sidebarAllowHorizontalScroll: false,
sidebarHiddenTablePrefixes: [],
sidebarObjectInfoMode: "none",
+ sidebarCopyTableNameSeparator: "comma",
+ sidebarCopyTableNameIncludeSchema: false,
},
}),
}));
@@ -147,4 +149,56 @@ describe("TreeItem table reference dragging", () => {
expect(onDrop).not.toHaveBeenCalled();
});
+
+ it("drags multiple selected columns from the same table", async () => {
+ mockEditorDropTarget();
+ const onDrop = listenForTableReferenceDrop();
+ const columnA: TreeNode = {
+ id: "column-id",
+ label: "id (INTEGER)",
+ type: "column",
+ connectionId: "connection-1",
+ database: "main",
+ schema: "main",
+ tableName: "orders",
+ meta: { name: "id" },
+ };
+ const columnB: TreeNode = {
+ id: "column-name",
+ label: "name (TEXT)",
+ type: "column",
+ connectionId: "connection-1",
+ database: "main",
+ schema: "main",
+ tableName: "orders",
+ meta: { name: "name" },
+ };
+ connectionStore.selectedTreeNodeIds = [columnA.id, columnB.id];
+ connectionStore.selectedTreeNodeIdsSet = new Set([columnA.id, columnB.id]);
+ connectionStore.selectedTreeNodeId = columnA.id;
+ connectionStore.treeNodes = [columnA, columnB];
+
+ const container = document.createElement("div");
+ document.body.append(container);
+ const app = createApp(
+ defineComponent({
+ setup: () => () => h(TreeItem, { node: columnA, depth: 3 }),
+ }),
+ );
+ mountedApps.push(app);
+ app.use(i18n);
+ app.provide(sidebarTreeRuntimeKey, createSidebarTreeRuntime());
+ app.mount(container);
+ await nextTick();
+
+ const row = container.querySelector("[tabindex]");
+ if (!row) throw new Error("Tree item row was not rendered");
+ dragToEditor(row);
+
+ expect(onDrop).toHaveBeenCalledOnce();
+ const detail = (onDrop.mock.calls[0][0] as CustomEvent).detail;
+ expect(detail.payload.referenceType).toBe("column");
+ expect(detail.payload.columnNames).toEqual(["id", "name"]);
+ expect(detail.payload.columnNameSeparator).toBe("comma");
+ });
});
diff --git a/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts
index 80ae883960..adabd29913 100644
--- a/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts
@@ -112,7 +112,7 @@ describe("useSidebarTreeExportRuntime", () => {
const runtime = useSidebarTreeExportRuntime({
activeNode,
connectionStore: connectionStore as never,
- settingsStore: {} as never,
+ settingsStore: exportSettings() as never,
acceptedSelectionIds: () => null,
});
@@ -156,15 +156,15 @@ describe("useSidebarTreeExportRuntime", () => {
expect(source).toContain('import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue"');
expect(exportDataXlsx).toContain("await api.getColumns(");
- expect(exportDataXlsx).toContain("hasXlsxHeaderComments(columnInfos?.map((column) => column.comment))");
- expect(exportDataXlsx.indexOf("await showSidebarTreeXlsxHeaderDialog(")).toBeLessThan(exportDataXlsx.indexOf('await exportTableData(target, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter)'));
+ expect(exportDataXlsx).toContain("hasXlsxHeaderComments(columnInfos.map((column) => column.comment))");
+ expect(exportDataXlsx.indexOf("await showSidebarTreeXlsxHeaderDialog(")).toBeLessThan(exportDataXlsx.indexOf('await exportTableData(target, "xlsx"'));
});
it("falls back to field-name headers when column metadata is unavailable", () => {
const exportDataXlsx = functionBody("exportDataXlsx");
- expect(exportDataXlsx).toContain("catch {\n // Export still works with field-name headers when column metadata is unavailable.\n }");
- expect(exportDataXlsx).toContain('await exportTableData(target, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter)');
+ expect(exportDataXlsx).toContain("// Export still works with field-name headers when column metadata is unavailable.");
+ expect(exportDataXlsx).toContain('await exportTableData(target, "xlsx"');
});
it("sends the selected mode's header overrides to both XLSX export paths", () => {
@@ -246,4 +246,132 @@ describe("useSidebarTreeExportRuntime", () => {
expect.any(Function),
);
});
+
+ it("exports every selected table when multiple tables are selected", async () => {
+ const first = { id: "table-1", type: "table", label: "users", connectionId: "conn-1", database: "db", schema: "public", children: [] } as TreeNode;
+ const second = { id: "table-2", type: "table", label: "orders", connectionId: "conn-1", database: "db", schema: "public", children: [] } as TreeNode;
+ const group = { id: "tables", type: "group-tables", label: "Tables", children: [first, second] } as TreeNode;
+ const activeNode = shallowRef(first);
+ const settingsStore = exportSettings();
+ const connectionStore = {
+ ensureConnected: vi.fn(),
+ getConfig: vi.fn(() => ({ db_type: "postgres" })),
+ connectionIdentifierQuote: vi.fn(() => '"'),
+ treeNodes: [group],
+ selectedTreeNodeIds: [second.id, first.id],
+ };
+ const runtime = useSidebarTreeExportRuntime({
+ activeNode,
+ connectionStore: connectionStore as never,
+ settingsStore: settingsStore as never,
+ acceptedSelectionIds: () => null,
+ });
+
+ await runtime.exportData("csv");
+
+ expect(apiMock.startTableExport).toHaveBeenCalledTimes(2);
+ expect(apiMock.startTableExport).toHaveBeenNthCalledWith(1, expect.objectContaining({ tableName: "users", filePath: "users.csv" }), expect.any(Function));
+ expect(apiMock.startTableExport).toHaveBeenNthCalledWith(2, expect.objectContaining({ tableName: "orders", filePath: "orders.csv" }), expect.any(Function));
+ });
+
+ it("exports same-name tables from different schemas to distinct files", async () => {
+ const publicUsers = { id: "table-1", type: "table", label: "users", connectionId: "conn-1", database: "db", schema: "public", children: [] } as TreeNode;
+ const salesUsers = { id: "table-2", type: "table", label: "users", connectionId: "conn-1", database: "db", schema: "sales", children: [] } as TreeNode;
+ const group = { id: "tables", type: "group-tables", label: "Tables", children: [publicUsers, salesUsers] } as TreeNode;
+ const activeNode = shallowRef(publicUsers);
+ const connectionStore = {
+ ensureConnected: vi.fn(),
+ getConfig: vi.fn(() => ({ db_type: "postgres" })),
+ connectionIdentifierQuote: vi.fn(() => '"'),
+ treeNodes: [group],
+ selectedTreeNodeIds: [salesUsers.id, publicUsers.id],
+ };
+ const runtime = useSidebarTreeExportRuntime({
+ activeNode,
+ connectionStore: connectionStore as never,
+ settingsStore: exportSettings() as never,
+ acceptedSelectionIds: () => null,
+ });
+
+ await runtime.exportData("csv");
+
+ expect(apiMock.startTableExport).toHaveBeenCalledTimes(2);
+ expect(apiMock.startTableExport).toHaveBeenNthCalledWith(1, expect.objectContaining({ tableName: "users", filePath: "public.users.csv" }), expect.any(Function));
+ expect(apiMock.startTableExport).toHaveBeenNthCalledWith(2, expect.objectContaining({ tableName: "users", filePath: "sales.users.csv" }), expect.any(Function));
+ });
+
+ it("keeps unique table names unqualified when schemas differ without collisions", async () => {
+ const publicUsers = { id: "table-1", type: "table", label: "users", connectionId: "conn-1", database: "db", schema: "public", children: [] } as TreeNode;
+ const salesOrders = { id: "table-2", type: "table", label: "orders", connectionId: "conn-1", database: "db", schema: "sales", children: [] } as TreeNode;
+ const group = { id: "tables", type: "group-tables", label: "Tables", children: [publicUsers, salesOrders] } as TreeNode;
+ const activeNode = shallowRef(publicUsers);
+ const connectionStore = {
+ ensureConnected: vi.fn(),
+ getConfig: vi.fn(() => ({ db_type: "postgres" })),
+ connectionIdentifierQuote: vi.fn(() => '"'),
+ treeNodes: [group],
+ selectedTreeNodeIds: [salesOrders.id, publicUsers.id],
+ };
+ const runtime = useSidebarTreeExportRuntime({
+ activeNode,
+ connectionStore: connectionStore as never,
+ settingsStore: exportSettings() as never,
+ acceptedSelectionIds: () => null,
+ });
+
+ await runtime.exportData("csv");
+
+ expect(apiMock.startTableExport).toHaveBeenCalledTimes(2);
+ expect(apiMock.startTableExport).toHaveBeenNthCalledWith(1, expect.objectContaining({ filePath: "users.csv" }), expect.any(Function));
+ expect(apiMock.startTableExport).toHaveBeenNthCalledWith(2, expect.objectContaining({ filePath: "orders.csv" }), expect.any(Function));
+ });
+
+ it("exports only tables in the active execution context when the selection spans connections", async () => {
+ const localUsers = { id: "table-1", type: "table", label: "users", connectionId: "conn-1", database: "db", schema: "public", children: [] } as TreeNode;
+ const foreignOrders = { id: "table-2", type: "table", label: "orders", connectionId: "conn-2", database: "db", schema: "public", children: [] } as TreeNode;
+ const foreignDatabase = { id: "db-b", type: "database", label: "db", connectionId: "conn-2", children: [foreignOrders] } as TreeNode;
+ const localGroup = { id: "tables", type: "group-tables", label: "Tables", children: [localUsers] } as TreeNode;
+ const localDatabase = { id: "db-a", type: "database", label: "db", connectionId: "conn-1", children: [localGroup] } as TreeNode;
+ const activeNode = shallowRef(localUsers);
+ const connectionStore = {
+ ensureConnected: vi.fn(),
+ getConfig: vi.fn(() => ({ db_type: "postgres" })),
+ connectionIdentifierQuote: vi.fn(() => '"'),
+ treeNodes: [localDatabase, foreignDatabase],
+ selectedTreeNodeIds: [foreignOrders.id, localUsers.id],
+ };
+ const runtime = useSidebarTreeExportRuntime({
+ activeNode,
+ connectionStore: connectionStore as never,
+ settingsStore: exportSettings() as never,
+ acceptedSelectionIds: () => null,
+ });
+
+ await runtime.exportData("csv");
+
+ expect(apiMock.startTableExport).toHaveBeenCalledTimes(1);
+ expect(apiMock.startTableExport).toHaveBeenCalledWith(expect.objectContaining({ connectionId: "conn-1", tableName: "users", filePath: "users.csv" }), expect.any(Function));
+ });
+
+ it("keeps single-table export behavior when only one table is selected", async () => {
+ const activeNode = shallowRef({ id: "table-1", type: "table", label: "users", connectionId: "conn-1", database: "db", schema: "public", children: [] } as TreeNode);
+ const connectionStore = {
+ ensureConnected: vi.fn(),
+ getConfig: vi.fn(() => ({ db_type: "postgres" })),
+ connectionIdentifierQuote: vi.fn(() => '"'),
+ treeNodes: [],
+ selectedTreeNodeIds: [],
+ };
+ const runtime = useSidebarTreeExportRuntime({
+ activeNode,
+ connectionStore: connectionStore as never,
+ settingsStore: exportSettings() as never,
+ acceptedSelectionIds: () => null,
+ });
+
+ await runtime.exportData("csv");
+
+ expect(apiMock.startTableExport).toHaveBeenCalledOnce();
+ expect(apiMock.startTableExport).toHaveBeenCalledWith(expect.objectContaining({ tableName: "users", filePath: "users.csv" }), expect.any(Function));
+ });
});
diff --git a/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts
index 2b9144a830..73295c1d79 100644
--- a/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts
@@ -4,15 +4,22 @@ import type { TreeNode } from "@/types/database";
import { useSidebarTreeToolRuntime } from "@/composables/useSidebarTreeToolRuntime";
-function setup(node: Partial) {
+function setup(node: Partial, options: { treeNodes?: TreeNode[]; selectedTreeNodeIds?: string[]; acceptedSelectionIds?: readonly string[] | null } = {}) {
const activeNode = shallowRef({ id: "n-1", label: "node", children: [], ...node } as TreeNode);
- const connectionStore = { docsSource: null as unknown };
+ const connectionStore = {
+ docsSource: null as unknown,
+ diagramSource: null as unknown,
+ databaseExportSource: null as unknown,
+ treeNodes: options.treeNodes ?? [],
+ selectedTreeNodeIds: options.selectedTreeNodeIds ?? [],
+ };
const runtime = useSidebarTreeToolRuntime({
activeNode,
connectionStore: connectionStore as never,
queryStore: {} as never,
settingsStore: {} as never,
tableChildObjectName: () => "",
+ acceptedSelectionIds: () => options.acceptedSelectionIds ?? null,
});
return { connectionStore, runtime };
}
@@ -43,3 +50,60 @@ describe("useSidebarTreeToolRuntime openDocs", () => {
expect(connectionStore.docsSource).toBeNull();
});
});
+
+describe("useSidebarTreeToolRuntime diagram and database export", () => {
+ const publicUsers: TreeNode = { id: "t1", label: "users", type: "table", connectionId: "c1", database: "db", schema: "public" };
+ const publicOrders: TreeNode = { id: "t2", label: "orders", type: "table", connectionId: "c1", database: "db", schema: "public" };
+ const salesUsers: TreeNode = { id: "t3", label: "users", type: "table", connectionId: "c1", database: "db", schema: "sales" };
+ const publicView: TreeNode = { id: "v1", label: "active_users", type: "view", connectionId: "c1", database: "db", schema: "public" };
+ const group: TreeNode = { id: "group", label: "Tables", type: "group-tables", children: [publicUsers, publicOrders, salesUsers, publicView] };
+
+ it("opens a multi-table diagram only for tables in the active schema", () => {
+ const { connectionStore, runtime } = setup(publicUsers, {
+ treeNodes: [group],
+ selectedTreeNodeIds: [publicOrders.id, publicUsers.id, salesUsers.id],
+ });
+
+ runtime.openDiagram();
+
+ expect(connectionStore.diagramSource).toEqual({
+ connectionId: "c1",
+ database: "db",
+ schema: "public",
+ tableName: "users",
+ tableNames: ["users", "orders"],
+ });
+ });
+
+ it("prefills database export with same-schema tables only", () => {
+ const { connectionStore, runtime } = setup(publicUsers, {
+ treeNodes: [group],
+ selectedTreeNodeIds: [publicOrders.id, publicUsers.id, publicView.id],
+ });
+
+ runtime.openDatabaseExport();
+
+ expect(connectionStore.databaseExportSource).toEqual({
+ connectionId: "c1",
+ database: "db",
+ schema: "public",
+ tableNames: ["users", "orders"],
+ });
+ });
+
+ it("falls back to single-table database export when the selection spans schemas", () => {
+ const { connectionStore, runtime } = setup(publicUsers, {
+ treeNodes: [group],
+ selectedTreeNodeIds: [salesUsers.id, publicUsers.id],
+ });
+
+ runtime.openDatabaseExport();
+
+ expect(connectionStore.databaseExportSource).toEqual({
+ connectionId: "c1",
+ database: "db",
+ schema: "public",
+ tableName: "users",
+ });
+ });
+});
diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts
index 510462bc63..4668a05b79 100644
--- a/apps/desktop/src/composables/useDialogSources.ts
+++ b/apps/desktop/src/composables/useDialogSources.ts
@@ -56,6 +56,7 @@ const diagramPrefillConnectionId = ref("");
const diagramPrefillDatabase = ref("");
const diagramPrefillSchema = ref("");
const diagramFocusTableName = ref("");
+const diagramFocusTableNames = ref([]);
const docsPrefillConnectionId = ref("");
const docsPrefillDatabase = ref("");
const docsPrefillSchema = ref("");
@@ -183,6 +184,7 @@ export function useDialogSources() {
diagramPrefillDatabase.value = v.database;
diagramPrefillSchema.value = v.schema ?? "";
diagramFocusTableName.value = v.tableName ?? "";
+ diagramFocusTableNames.value = v.tableNames ?? (v.tableName ? [v.tableName] : []);
showDiagramDialog.value = true;
connectionStore.diagramSource = null;
}
@@ -511,6 +513,7 @@ export function useDialogSources() {
diagramPrefillDatabase,
diagramPrefillSchema,
diagramFocusTableName,
+ diagramFocusTableNames,
docsPrefillConnectionId,
docsPrefillDatabase,
docsPrefillSchema,
diff --git a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts
index 2d428abf0e..bdb8b62157 100644
--- a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts
+++ b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts
@@ -13,7 +13,7 @@ import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
import { joinExportedDdls } from "@/lib/export/ddlExport";
import { translateBackendError } from "@/i18n/backend-errors";
-import { sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
+import { sidebarStructureExportTargets, sidebarTableDataExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
@@ -29,6 +29,7 @@ interface SidebarTreeExportRuntimeOptions {
}
interface SidebarTableExportTarget {
+ nodeId: string;
connectionId: string;
database: string;
schema?: string;
@@ -41,6 +42,44 @@ interface SidebarTableExportTarget {
identifierQuote?: string;
batchSize: number;
rowLimit: number | null;
+ fileNameBase?: string;
+}
+
+function tableExportTargetCacheKey(target: Pick): string {
+ return target.nodeId;
+}
+
+/**
+ * Batch exports share one output directory, so same-name tables from different
+ * schemas would silently overwrite each other. Qualify colliding names with
+ * their schema and fall back to numeric suffixes for remaining collisions.
+ */
+function applyTableExportFileBaseNames(targets: readonly SidebarTableExportTarget[]): void {
+ const tableNameCounts = new Map();
+ for (const target of targets) tableNameCounts.set(target.tableName, (tableNameCounts.get(target.tableName) ?? 0) + 1);
+
+ const usedNames = new Set();
+ for (const target of targets) {
+ const base = (tableNameCounts.get(target.tableName) ?? 0) > 1 && target.schema ? `${target.schema}.${target.tableName}` : target.tableName;
+ let name = base;
+ let suffix = 2;
+ while (usedNames.has(name)) name = `${base}-${suffix++}`;
+ usedNames.add(name);
+ target.fileNameBase = name;
+ }
+}
+
+interface ExportTableDataOptions {
+ columnInfos?: ColumnInfo[];
+ headerMode?: XlsxHeaderMode;
+ autoFilter?: boolean;
+ outputDirectory?: string;
+ suppressDoneToast?: boolean;
+}
+
+function joinExportFilePath(directory: string, fileName: string): string {
+ const separator = directory.includes("\\") ? "\\" : "/";
+ return directory.endsWith(separator) ? `${directory}${fileName}` : `${directory}${separator}${fileName}`;
}
export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOptions) {
@@ -80,6 +119,10 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
return sidebarStructureExportTargets(activeNode.value, connectionStore.treeNodes, options.acceptedSelectionIds() ?? connectionStore.selectedTreeNodeIds);
}
+ function tableDataExportTargets(): Array {
+ return sidebarTableDataExportTargets(activeNode.value, connectionStore.treeNodes, options.acceptedSelectionIds() ?? connectionStore.selectedTreeNodeIds);
+ }
+
async function exportStructure() {
const targets = structureExportTargets();
if (!targets.length) return;
@@ -217,41 +260,56 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
}
}
- async function exportDataLegacy(format: "json") {
- const node = activeNode.value;
- if (!node.connectionId || !node.database) return;
- const connectionId = node.connectionId;
- const database = node.database;
+ async function pickTableExportDirectory(): Promise {
+ if (!isTauriRuntime()) return "";
+ const { open } = await import("@tauri-apps/plugin-dialog");
+ const selected = await open({ directory: true, multiple: false, title: t("contextMenu.exportDataSelectDirectory") });
+ return typeof selected === "string" ? selected : null;
+ }
+
+ async function resolveTableExportOutputPath(target: SidebarTableExportTarget, format: string, outputDirectory?: string): Promise {
+ const fileName = `${target.fileNameBase ?? target.tableName}.${format}`;
+ if (outputDirectory !== undefined) {
+ return outputDirectory ? joinExportFilePath(outputDirectory, fileName) : fileName;
+ }
+ if (isTauriRuntime()) {
+ const { save } = await import("@tauri-apps/plugin-dialog");
+ const filterName = format === "csv" ? "CSV" : format === "json" ? "JSON" : format === "xlsx" ? "Excel" : "SQL";
+ const path = await save({
+ defaultPath: fileName,
+ filters: [{ name: filterName, extensions: [format] }],
+ });
+ return path ? String(path) : null;
+ }
+ return fileName;
+ }
+
+ async function exportDataLegacyForTarget(target: SidebarTableExportTarget, outputDirectory?: string, suppressDoneToast = false) {
+ const { connectionId, database } = target;
const config = connectionStore.getConfig(connectionId);
- if (!config) return;
+ if (!config) return false;
try {
await connectionStore.ensureConnected(connectionId);
- const queryColumns = config.db_type === "neo4j" ? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((column) => column.name) : undefined;
- const effectiveDbType = effectiveDatabaseTypeForConnection(config);
+ const queryColumns = config.db_type === "neo4j" ? (await api.getColumns(connectionId, database, target.metadataSchema, target.tableName, target.catalog)).map((column) => column.name) : undefined;
const result = await fetchTableDataForExport({
- databaseType: effectiveDbType,
- identifierQuote: connectionStore.connectionIdentifierQuote?.(connectionId),
- schema: node.schema,
- tableName: node.label,
- tableType: node.tableType,
+ databaseType: target.databaseType,
+ identifierQuote: target.identifierQuote,
+ schema: target.schema,
+ tableName: target.tableName,
+ tableType: target.tableType,
columns: queryColumns,
executePage: (sql) => api.executeQuery(connectionId, database, sql),
});
- if (format === "json") {
- let outputPath = `${node.label}.json`;
- if (isTauriRuntime()) {
- const { save } = await import("@tauri-apps/plugin-dialog");
- const path = await save({ defaultPath: outputPath, filters: [{ name: "JSON", extensions: ["json"] }] });
- if (!path) return;
- outputPath = path as string;
- }
- await api.exportQueryResultJson(outputPath, result.columns, result.rows);
- toast(t("grid.exported"));
- }
+ const outputPath = await resolveTableExportOutputPath(target, "json", outputDirectory);
+ if (!outputPath) return false;
+ await api.exportQueryResultJson(outputPath, result.columns, result.rows);
+ if (!suppressDoneToast) toast(t("grid.exported"));
+ return true;
} catch (error: any) {
toast(t("grid.exportFailed", { message: translateBackendError(t, error) }), 5000);
+ return false;
}
}
@@ -280,8 +338,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
});
}
- function currentTableExportTarget(): SidebarTableExportTarget | null {
- const node = activeNode.value;
+ function tableExportTargetFromNode(node: TreeNode): SidebarTableExportTarget | null {
if (!node.connectionId || !node.database) return null;
const connectionId = node.connectionId;
const database = node.database;
@@ -290,6 +347,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
const editorSettings = settingsStore.editorSettings;
return {
+ nodeId: node.id,
connectionId,
database,
schema: node.schema || undefined,
@@ -305,23 +363,22 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
};
}
- async function exportTableData(target: SidebarTableExportTarget, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true) {
+ function currentTableExportTargets(): SidebarTableExportTarget[] {
+ const targets = tableDataExportTargets()
+ .map((node) => tableExportTargetFromNode(node))
+ .filter((target): target is SidebarTableExportTarget => target != null);
+ applyTableExportFileBaseNames(targets);
+ return targets;
+ }
+
+ async function exportTableData(target: SidebarTableExportTarget, format: "csv" | "xlsx" | "sql", exportOptions: ExportTableDataOptions = {}) {
+ const { columnInfos, headerMode = "name", autoFilter = true, outputDirectory, suppressDoneToast = false } = exportOptions;
const { connectionId, database } = target;
let task: ExportTask | null = null;
try {
- // Choose the destination before registering a background task so cancellation creates no orphan tracker entry.
- let outputPath = `${target.tableName}.${format}`;
- if (isTauriRuntime()) {
- const { save } = await import("@tauri-apps/plugin-dialog");
- const filterName = format === "csv" ? "CSV" : format === "xlsx" ? "Excel" : "SQL";
- const path = await save({
- defaultPath: outputPath,
- filters: [{ name: filterName, extensions: [format] }],
- });
- if (!path) return;
- outputPath = path as string;
- }
+ const outputPath = await resolveTableExportOutputPath(target, format, outputDirectory);
+ if (!outputPath) return false;
await connectionStore.ensureConnected(connectionId);
task = addExportTask(target.tableName, format, outputPath);
@@ -347,8 +404,8 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
currentTask.status = "Done";
currentTask.rowsExported = result.rows.length;
currentTask.totalRows = result.rows.length;
- toast(t("grid.exported"));
- return;
+ if (!suppressDoneToast) toast(t("grid.exported"));
+ return true;
}
const columnComments =
format === "xlsx" && exportColumnInfos
@@ -378,41 +435,89 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
await api.startTableExport(request, (progress) => {
updateTableExportTask(currentTask.exportId, progress);
- if (progress.status === "Done") toast(t("grid.exported"));
- else if (progress.status === "Error") toast(t("grid.exportFailed", { message: translateBackendError(t, progress.errorMessage || "") }), 5000);
+ if (progress.status === "Done") {
+ if (!suppressDoneToast) toast(t("grid.exported"));
+ } else if (progress.status === "Error") toast(t("grid.exportFailed", { message: translateBackendError(t, progress.errorMessage || "") }), 5000);
});
+ return true;
} catch (error: any) {
if (task) {
task.status = "Error";
task.errorMessage = error?.message || String(error);
}
toast(t("grid.exportFailed", { message: translateBackendError(t, error) }), 5000);
+ return false;
}
}
+ async function resolveMultiTableExportDirectory(targetCount: number): Promise {
+ if (targetCount <= 1) return undefined;
+ if (!isTauriRuntime()) return "";
+ return pickTableExportDirectory();
+ }
+
async function exportData(format: "csv" | "json" | "sql") {
- if (format === "json") await exportDataLegacy(format);
- else {
- const target = currentTableExportTarget();
- if (target) await exportTableData(target, format);
+ const targets = currentTableExportTargets();
+ if (!targets.length) return;
+
+ const outputDirectory = await resolveMultiTableExportDirectory(targets.length);
+ if (outputDirectory === null) return;
+
+ if (format === "json") {
+ let exported = 0;
+ for (const target of targets) {
+ if (await exportDataLegacyForTarget(target, outputDirectory, targets.length > 1)) exported += 1;
+ }
+ if (targets.length > 1 && exported > 0) toast(t("contextMenu.exportDataMultipleSuccess", { count: exported }));
+ return;
}
+
+ let exported = 0;
+ for (const target of targets) {
+ if (await exportTableData(target, format, { outputDirectory, suppressDoneToast: targets.length > 1 })) exported += 1;
+ }
+ if (targets.length > 1 && exported > 0) toast(t("contextMenu.exportDataMultipleSuccess", { count: exported }));
}
async function exportDataXlsx() {
- const target = currentTableExportTarget();
- if (!target) return;
+ const targets = currentTableExportTargets();
+ if (!targets.length) return;
- let columnInfos: ColumnInfo[] | undefined;
- try {
- await connectionStore.ensureConnected(target.connectionId);
- columnInfos = await api.getColumns(target.connectionId, target.database, target.metadataSchema, target.tableName, target.catalog);
- } catch {
- // Export still works with field-name headers when column metadata is unavailable.
+ const columnInfosByTarget = new Map();
+ let hasComments = false;
+ for (const target of targets) {
+ let columnInfos: ColumnInfo[] | undefined;
+ try {
+ await connectionStore.ensureConnected(target.connectionId);
+ columnInfos = await api.getColumns(target.connectionId, target.database, target.metadataSchema, target.tableName, target.catalog);
+ if (hasXlsxHeaderComments(columnInfos.map((column) => column.comment))) hasComments = true;
+ } catch {
+ // Export still works with field-name headers when column metadata is unavailable.
+ }
+ columnInfosByTarget.set(tableExportTargetCacheKey(target), columnInfos);
}
- const exportOptions = await showSidebarTreeXlsxHeaderDialog(hasXlsxHeaderComments(columnInfos?.map((column) => column.comment)));
- if (exportOptions === null) return;
- await exportTableData(target, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter);
+ const xlsxOptions = await showSidebarTreeXlsxHeaderDialog(hasComments);
+ if (xlsxOptions === null) return;
+
+ const outputDirectory = await resolveMultiTableExportDirectory(targets.length);
+ if (outputDirectory === null) return;
+
+ let exported = 0;
+ for (const target of targets) {
+ if (
+ await exportTableData(target, "xlsx", {
+ columnInfos: columnInfosByTarget.get(tableExportTargetCacheKey(target)),
+ headerMode: xlsxOptions.headerMode,
+ autoFilter: xlsxOptions.autoFilter,
+ outputDirectory,
+ suppressDoneToast: targets.length > 1,
+ })
+ ) {
+ exported += 1;
+ }
+ }
+ if (targets.length > 1 && exported > 0) toast(t("contextMenu.exportDataMultipleSuccess", { count: exported }));
}
return {
diff --git a/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts b/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts
index dfe8e6b255..9328447484 100644
--- a/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts
+++ b/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts
@@ -3,7 +3,7 @@ import type { useConnectionStore } from "@/stores/connectionStore";
import type { useQueryStore } from "@/stores/queryStore";
import type { useSettingsStore } from "@/stores/settingsStore";
import type { TreeNode } from "@/types/database";
-import { allDatabasesExportSourceForNode, databaseExportSourceForNode } from "@/lib/sidebar/sidebarExportRuntime";
+import { allDatabasesExportSourceForNode, databaseExportSourceForNode, sidebarSameSchemaStructureTargets } from "@/lib/sidebar/sidebarExportRuntime";
interface SidebarTreeToolRuntimeOptions {
activeNode: ShallowRef;
@@ -11,6 +11,7 @@ interface SidebarTreeToolRuntimeOptions {
queryStore: ReturnType;
settingsStore: ReturnType;
tableChildObjectName: (node: TreeNode) => string;
+ acceptedSelectionIds?: () => readonly string[] | null;
}
export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions) {
@@ -52,14 +53,21 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
};
}
+ function selectedSameSchemaStructureTargets() {
+ return sidebarSameSchemaStructureTargets(activeNode.value, connectionStore.treeNodes, options.acceptedSelectionIds?.() ?? connectionStore.selectedTreeNodeIds);
+ }
+
function openDiagram() {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
+ const tables = selectedSameSchemaStructureTargets().filter((target) => target.type === "table");
+ const tableNames = tables.length > 1 ? tables.map((target) => target.label) : undefined;
connectionStore.diagramSource = {
connectionId: node.connectionId,
database: node.database,
schema: node.schema,
- tableName: node.type === "table" ? node.label : undefined,
+ tableName: tableNames?.length ? tableNames[0] : node.type === "table" ? node.label : undefined,
+ tableNames,
};
}
@@ -86,7 +94,18 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
}
function openDatabaseExport() {
- connectionStore.databaseExportSource = databaseExportSourceForNode(activeNode.value);
+ const node = activeNode.value;
+ const tables = selectedSameSchemaStructureTargets().filter((target) => target.type === "table");
+ if (tables.length > 1 && node.connectionId && node.database) {
+ connectionStore.databaseExportSource = {
+ connectionId: node.connectionId,
+ database: node.database,
+ schema: node.schema,
+ tableNames: tables.map((target) => target.label),
+ };
+ return;
+ }
+ connectionStore.databaseExportSource = databaseExportSourceForNode(node);
}
function openAllDatabasesExport() {
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index a8e9a4bc08..ab7e17776c 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -2945,6 +2945,7 @@ export default {
duplicateSelectedConnections: "Duplicate Selected {count} Connections",
newQuery: "New Query",
addToAi: "Add to AI",
+ addToAiMultiple: "Add to AI ({count} tables)",
instanceInfo: "Instance Info",
newSql: "New SQL",
generateSql: "Generate SQL",
@@ -3011,6 +3012,9 @@ export default {
restoreSqliteDatabaseFailed: "Failed to restore SQLite database: {message}",
importData: "Import Data",
exportData: "Export Data",
+ exportDataMultiple: "Export Data ({count} tables)",
+ exportDataSelectDirectory: "Select export folder",
+ exportDataMultipleSuccess: "Exported {count} tables",
exportStructure: "Export Structure",
exportStructurePreviewTitle: "Structure - {name}",
exportStructurePreviewTitleMultiple: "Structure - {count} objects",
@@ -6515,6 +6519,10 @@ export default {
sidebarHiddenTablePrefixes: "Hidden table name prefixes",
sidebarHiddenTablePrefixesDescription: "One prefix per line. Only sidebar table, view, and collection labels are shortened; tooltips and actions still use the full name.",
sidebarHiddenTablePrefixesPlaceholder: "Example:\nODS_\nT8Y2_LONG_",
+ sidebarCopyTableNameSeparator: "Copy table name separator",
+ sidebarCopyTableNameSeparatorDescription: "Separator used when copying or dragging multiple sidebar table or column names into the SQL editor.",
+ sidebarCopyTableNameIncludeSchema: "Include schema in copied table names",
+ sidebarCopyTableNameIncludeSchemaDescription: "When enabled, copied or dragged table names use schema-qualified form with the active connection's identifier quoting rules.",
sidebarTablePageSize: "Sidebar page size",
sidebarTablePageSizeDescription: "Maximum number of tables/objects loaded per page in the sidebar tree. Increase if you have many tables and want fewer pages.",
sidebarTableSearchEnabled: "Enable table search inside databases",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 6dc83d9ec6..b54a19bf44 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -2873,6 +2873,7 @@ export default withEnglishFallback({
instanceInfo: "Información de la instancia",
newQuery: "Nueva consulta",
addToAi: "Agregar a la IA",
+ addToAiMultiple: "Agregar a la IA ({count} tablas)",
newSql: "Nuevo SQL",
generateSql: "Generar SQL",
newInsert: "Nueva Inserción",
@@ -2936,6 +2937,9 @@ export default withEnglishFallback({
restoreSqliteDatabaseFailed: "Error al restaurar la base de datos SQLite: {message}",
importData: "Importar datos",
exportData: "Exportar datos",
+ exportDataMultiple: "Exportar datos ({count} tablas)",
+ exportDataSelectDirectory: "Seleccionar carpeta de exportación",
+ exportDataMultipleSuccess: "Se exportaron {count} tablas",
exportStructure: "Exportar estructura",
exportStructurePreviewTitle: "Estructura - {name}",
exportStructurePreviewTitleMultiple: "Estructura - {count} objetos",
@@ -6186,6 +6190,10 @@ export default withEnglishFallback({
sidebarHiddenTablePrefixes: "Prefijos ocultos de tablas",
sidebarHiddenTablePrefixesDescription: "Un prefijo por linea. Solo acorta etiquetas de tablas, vistas y colecciones en la barra lateral; las acciones y ayudas usan el nombre completo.",
sidebarHiddenTablePrefixesPlaceholder: "Ejemplo:\nODS_\nT8Y2_LONG_",
+ sidebarCopyTableNameSeparator: "Separador para copiar nombres de tabla",
+ sidebarCopyTableNameSeparatorDescription: "Separador usado al copiar o arrastrar varios nombres de tablas o columnas de la barra lateral al editor SQL.",
+ sidebarCopyTableNameIncludeSchema: "Incluir esquema en nombres de tabla copiados",
+ sidebarCopyTableNameIncludeSchemaDescription: "Si está activado, los nombres copiados o arrastrados usan la forma esquema.tabla con las reglas de comillas del identificador de la conexión activa.",
sidebarTablePageSize: "Tamaño de página de la barra lateral",
sidebarTablePageSizeDescription: "Número máximo de tablas/objetos cargados por página en el árbol lateral. Auméntalo si tienes muchas tablas para reducir páginas.",
sidebarTableSearchEnabled: "Activar búsqueda de tablas por base",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 76ec425afc..85a104bfa5 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -2870,6 +2870,7 @@ export default withEnglishFallback({
duplicateSelectedConnections: "Duplica {count} connessioni selezionate",
newQuery: "Nuova Query",
addToAi: "Aggiungi all'IA",
+ addToAiMultiple: "Aggiungi all'IA ({count} tabelle)",
instanceInfo: "Informazioni Istanza",
newSql: "Nuovo SQL",
generateSql: "Genera SQL",
@@ -2934,6 +2935,9 @@ export default withEnglishFallback({
restoreSqliteDatabaseFailed: "Impossibile ripristinare il database SQLite: {message}",
importData: "Importa Dati",
exportData: "Esporta Dati",
+ exportDataMultiple: "Esporta dati ({count} tabelle)",
+ exportDataSelectDirectory: "Seleziona cartella di esportazione",
+ exportDataMultipleSuccess: "Esportate {count} tabelle",
exportStructure: "Esporta Struttura",
exportStructurePreviewTitle: "Struttura - {name}",
exportStructurePreviewTitleMultiple: "Struttura - {count} oggetti",
@@ -6186,6 +6190,10 @@ export default withEnglishFallback({
sidebarHiddenTablePrefixes: "Prefissi dei nomi delle tabelle nascosti",
sidebarHiddenTablePrefixesDescription: "Un prefisso per riga. Solo le etichette di tabelle, viste e collezioni della barra laterale vengono abbreviate; i suggerimenti e le azioni utilizzano ancora il nome completo.",
sidebarHiddenTablePrefixesPlaceholder: "Esempio:\nODS_\nT8Y2_LONG_",
+ sidebarCopyTableNameSeparator: "Separatore per copiare i nomi delle tabelle",
+ sidebarCopyTableNameSeparatorDescription: "Separatore usato quando si copiano o trascinano più nomi di tabelle o colonne dalla barra laterale nell'editor SQL.",
+ sidebarCopyTableNameIncludeSchema: "Includi lo schema nei nomi di tabella copiati",
+ sidebarCopyTableNameIncludeSchemaDescription: "Se attivato, i nomi copiati o trascinati usano la forma schema.tabella con le regole di quoting dell'identificatore della connessione attiva.",
sidebarTablePageSize: "Dimensione pagina barra laterale",
sidebarTablePageSizeDescription: "Numero massimo di tabelle/oggetti caricati per pagina nell'albero laterale. Aumentalo se hai molte tabelle per ridurre le pagine.",
sidebarTableSearchEnabled: "Abilita ricerca tabelle nel database",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 138b7ac5a4..edd68ec78d 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -2895,6 +2895,7 @@ export default withEnglishFallback({
duplicateSelectedConnections: "選択した{count}件の接続を複製",
newQuery: "新しいクエリ",
addToAi: "AI に追加",
+ addToAiMultiple: "AI に追加({count} テーブル)",
newSql: "新しいSQL",
generateSql: "SQLを生成",
newInsert: "新しいINSERT",
@@ -2958,6 +2959,9 @@ export default withEnglishFallback({
restoreSqliteDatabaseFailed: "SQLite データベースの復元に失敗しました: {message}",
importData: "データをインポート",
exportData: "データをエクスポート",
+ exportDataMultiple: "データをエクスポート({count} テーブル)",
+ exportDataSelectDirectory: "エクスポート先フォルダを選択",
+ exportDataMultipleSuccess: "{count} テーブルをエクスポートしました",
exportStructure: "構造をエクスポート",
exportStructurePreviewTitle: "構造 - {name}",
exportStructurePreviewTitleMultiple: "構造 - {count}オブジェクト",
@@ -6212,6 +6216,10 @@ export default withEnglishFallback({
sidebarHiddenTablePrefixes: "非表示テーブル名プレフィックス",
sidebarHiddenTablePrefixesDescription: "1行に1つのプレフィックス。サイドバーのテーブル、ビュー、コレクションラベルのみ短縮されます。ツールチップと操作は完全な名前を使用します。",
sidebarHiddenTablePrefixesPlaceholder: "Example:\nODS_\nT8Y2_LONG_",
+ sidebarCopyTableNameSeparator: "コピー時のテーブル名区切り",
+ sidebarCopyTableNameSeparatorDescription: "サイドバーで複数のテーブル名または列名をコピー、または SQL エディターにドラッグするときに使う区切り文字。",
+ sidebarCopyTableNameIncludeSchema: "コピーするテーブル名にスキーマを含める",
+ sidebarCopyTableNameIncludeSchemaDescription: "有効にすると、コピーまたはドラッグしたテーブル名は、アクティブな接続の識別子クォート規則に従った schema.table 形式になります。",
sidebarTablePageSize: "サイドバーのページサイズ",
sidebarTablePageSizeDescription: "サイドバーツリーで1ページあたりに読み込むテーブル/オブジェクトの最大数です。テーブルが多い場合は増やすとページ切り替えが減ります。",
sidebarTableSearchEnabled: "データベース内のテーブル検索を有効化",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 5ab76834f7..cad10bfe30 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -2764,6 +2764,7 @@ export default withEnglishFallback({
duplicateSelectedConnections: "선택한 연결 {count}개 복제",
newQuery: "새 쿼리",
addToAi: "AI에 추가",
+ addToAiMultiple: "AI에 추가({count}개 테이블)",
instanceInfo: "인스턴스 정보",
newSql: "새 SQL",
generateSql: "SQL 생성",
@@ -2830,6 +2831,9 @@ export default withEnglishFallback({
restoreSqliteDatabaseFailed: "SQLite 데이터베이스 복원 실패: {message}",
importData: "데이터 가져오기",
exportData: "데이터 내보내기",
+ exportDataMultiple: "데이터 내보내기 ({count}개 테이블)",
+ exportDataSelectDirectory: "보낼 폴더 선택",
+ exportDataMultipleSuccess: "{count}개 테이블을보냈습니다",
exportStructure: "구조 내보내기",
exportStructurePreviewTitle: "구조 - {name}",
exportStructurePreviewTitleMultiple: "구조 - 객체 {count}개",
@@ -5908,6 +5912,10 @@ export default withEnglishFallback({
sidebarHiddenTablePrefixes: "숨겨진 테이블 이름 접두사",
sidebarHiddenTablePrefixesDescription: "한 줄에 하나의 접두사. 사이드바의 테이블, 뷰, 컬렉션 라벨만 줄이며 툴팁과 작업은 전체 이름을 계속 사용합니다.",
sidebarHiddenTablePrefixesPlaceholder: "예:\nODS_\nT8Y2_LONG_",
+ sidebarCopyTableNameSeparator: "테이블 이름 복사 구분자",
+ sidebarCopyTableNameSeparatorDescription: "사이드바에서 여러 테이블 또는 열 이름을 복사하거나 SQL 편집기로 끌어다 놓을 때 사용하는 구분자입니다.",
+ sidebarCopyTableNameIncludeSchema: "복사한 테이블 이름에 스키마 포함",
+ sidebarCopyTableNameIncludeSchemaDescription: "활성화하면 복사하거나 끌어다 놓은 테이블 이름이 활성 연결의 식별자 따옴표 규칙을 사용하는 스키마.테이블 형식이 됩니다.",
sidebarTablePageSize: "사이드바 페이지 크기",
sidebarTablePageSizeDescription: "사이드바 트리에서 페이지당 로드되는 최대 테이블/객체 수입니다. 테이블이 많고 페이지를 줄이려면 늘리세요.",
sidebarTableSearchEnabled: "데이터베이스 내 테이블 검색 활성화",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index ebdce452ac..d226f30a00 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -2873,6 +2873,7 @@ export default withEnglishFallback({
instanceInfo: "Informações da Instância",
newQuery: "Nova Consulta",
addToAi: "Adicionar à IA",
+ addToAiMultiple: "Adicionar à IA ({count} tabelas)",
newSql: "Novo SQL",
generateSql: "Gerar SQL",
newInsert: "Novo Insert",
@@ -2936,6 +2937,9 @@ export default withEnglishFallback({
restoreSqliteDatabaseFailed: "Falha ao restaurar o banco de dados SQLite: {message}",
importData: "Importar Dados",
exportData: "Exportar Dados",
+ exportDataMultiple: "Exportar dados ({count} tabelas)",
+ exportDataSelectDirectory: "Selecionar pasta de exportação",
+ exportDataMultipleSuccess: "Exportadas {count} tabelas",
exportStructure: "Exportar Estrutura",
exportStructurePreviewTitle: "Estrutura - {name}",
exportStructurePreviewTitleMultiple: "Estrutura - {count} objetos",
@@ -6188,6 +6192,10 @@ export default withEnglishFallback({
sidebarHiddenTablePrefixes: "Prefixos de nome de tabela ocultos",
sidebarHiddenTablePrefixesDescription: "Um prefixo por linha. Apenas os rótulos de tabela, view e coleção da barra lateral são encurtados; tooltips e ações ainda usam o nome completo.",
sidebarHiddenTablePrefixesPlaceholder: "Exemplo:\nODS_\nT8Y2_LONG_",
+ sidebarCopyTableNameSeparator: "Separador para copiar nomes de tabela",
+ sidebarCopyTableNameSeparatorDescription: "Separador usado ao copiar ou arrastar vários nomes de tabelas ou colunas da barra lateral para o editor SQL.",
+ sidebarCopyTableNameIncludeSchema: "Incluir schema nos nomes de tabela copiados",
+ sidebarCopyTableNameIncludeSchemaDescription: "Quando ativado, os nomes copiados ou arrastados usam a forma schema.tabela com as regras de aspas do identificador da conexão ativa.",
sidebarTablePageSize: "Tamanho da página da barra lateral",
sidebarTablePageSizeDescription: "Número máximo de tabelas/objetos carregados por página na árvore lateral. Aumente se tiver muitas tabelas para reduzir páginas.",
sidebarTableSearchEnabled: "Ativar busca de tabelas por banco",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index eae2f80d99..562b962376 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -2868,6 +2868,7 @@ export default withEnglishFallback({
openDamengJobAdmin: "打开达梦代理作业",
newQuery: "新建查询",
addToAi: "添加到 AI",
+ addToAiMultiple: "添加到 AI({count} 张表)",
instanceInfo: "实例信息",
newInsert: "新建新增",
newSql: "新建 SQL",
@@ -2934,6 +2935,9 @@ export default withEnglishFallback({
restoreSqliteDatabaseFailed: "还原 SQLite 数据库失败:{message}",
importData: "导入数据",
exportData: "导出数据",
+ exportDataMultiple: "导出数据({count} 张表)",
+ exportDataSelectDirectory: "选择导出文件夹",
+ exportDataMultipleSuccess: "已导出 {count} 张表",
exportStructure: "导出表结构",
exportStructurePreviewTitle: "表结构 - {name}",
exportStructurePreviewTitleMultiple: "表结构 - {count} 个对象",
@@ -6498,6 +6502,10 @@ export default withEnglishFallback({
sidebarHiddenTablePrefixes: "隐藏表名前缀",
sidebarHiddenTablePrefixesDescription: "每行一个前缀,仅影响侧边栏表、视图和集合的显示名称,悬浮提示和实际操作仍使用完整名称。",
sidebarHiddenTablePrefixesPlaceholder: "例如:\nODS_\nT8Y2_LONG_",
+ sidebarCopyTableNameSeparator: "复制表名分隔符",
+ sidebarCopyTableNameSeparatorDescription: "多选侧边栏表或字段后复制名称或拖拽到 SQL 编辑器时使用的分隔符。",
+ sidebarCopyTableNameIncludeSchema: "复制表名包含 schema",
+ sidebarCopyTableNameIncludeSchemaDescription: "开启后复制或拖拽表名时使用 schema.table 限定名,并按当前连接方言自动加引号。",
sidebarTablePageSize: "侧边栏分页大小",
sidebarTablePageSizeDescription: "侧边栏树每页最多加载的表/对象数量。表多时可以调大减少翻页次数。",
sidebarTableSearchEnabled: "启用库下表搜索框",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 09381fb9ed..0748e3d59f 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -2869,6 +2869,7 @@ export default withEnglishFallback({
duplicateSelectedConnections: "複製選取的 {count} 個連線",
newQuery: "建立查詢",
addToAi: "加入 AI",
+ addToAiMultiple: "加入 AI({count} 張表)",
instanceInfo: "實例資訊",
newSql: "新增 SQL",
generateSql: "產生 SQL",
@@ -2933,6 +2934,9 @@ export default withEnglishFallback({
restoreSqliteDatabaseFailed: "還原 SQLite 資料庫失敗:{message}",
importData: "匯入資料",
exportData: "匯出資料",
+ exportDataMultiple: "匯出資料({count} 張表)",
+ exportDataSelectDirectory: "選擇匯出資料夾",
+ exportDataMultipleSuccess: "已匯出 {count} 張表",
exportStructure: "匯出資料表結構",
exportStructurePreviewTitle: "資料表結構 - {name}",
exportStructurePreviewTitleMultiple: "資料表結構 - {count} 個物件",
@@ -5504,6 +5508,10 @@ export default withEnglishFallback({
sidebarHiddenTablePrefixes: "隱藏資料表名稱字首",
sidebarHiddenTablePrefixesDescription: "每行一個字首。只縮短側邊欄中的資料表、檢視和集合標籤;工具提示和實際操作仍使用完整名稱。",
sidebarHiddenTablePrefixesPlaceholder: "範例:\nODS_\nT8Y2_LONG_",
+ sidebarCopyTableNameSeparator: "複製表名分隔符",
+ sidebarCopyTableNameSeparatorDescription: "多選側邊欄表或欄位後複製名稱或拖曳到 SQL 編輯器時使用的分隔符。",
+ sidebarCopyTableNameIncludeSchema: "複製表名包含 schema",
+ sidebarCopyTableNameIncludeSchemaDescription: "開啟後複製或拖曳表名時使用 schema.table 限定名,並依目前連線方言自動加引號。",
sidebarTablePageSize: "側邊欄分頁大小",
sidebarTablePageSizeDescription: "側邊欄樹每頁最多載入的資料表/物件數量。資料表多時可調大減少翻頁次數。",
sidebarTableSearchEnabled: "啟用庫下資料表搜尋框",
diff --git a/apps/desktop/src/lib/__tests__/ai/addToAiDatabaseContext.spec.ts b/apps/desktop/src/lib/__tests__/ai/addToAiDatabaseContext.spec.ts
index e736c63510..5c16d25b1d 100644
--- a/apps/desktop/src/lib/__tests__/ai/addToAiDatabaseContext.spec.ts
+++ b/apps/desktop/src/lib/__tests__/ai/addToAiDatabaseContext.spec.ts
@@ -12,4 +12,14 @@ describe("add to AI database context", () => {
expect(addToAiSource).toContain("else if (hasTreeNodeDatabaseContext(node))");
expect(addToAiSource).not.toContain("else if (node.database)");
});
+
+ it("adds every selected table as an AI mention", () => {
+ const addToAiStart = appSource.indexOf("async function addToAi");
+ const addToAiEnd = appSource.indexOf("function openAiPanel", addToAiStart);
+ const addToAiSource = appSource.slice(addToAiStart, addToAiEnd);
+
+ expect(addToAiSource).toContain("Array.isArray(nodesInput) ? nodesInput : [nodesInput]");
+ expect(addToAiSource).toContain('entry.type === "table"');
+ expect(addToAiSource).toContain("for (const mention of tableMentions) handle.addTableMention(mention)");
+ });
});
diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorTableDrop.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorTableDrop.spec.ts
index a8a86fbf85..d29cda2e07 100644
--- a/apps/desktop/src/lib/__tests__/editor/queryEditorTableDrop.spec.ts
+++ b/apps/desktop/src/lib/__tests__/editor/queryEditorTableDrop.spec.ts
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
-import { createColumnReferencePayload, createTableReferencePayload, parseTableReferencePayload, tableReferenceInsertText } from "@/lib/editor/queryEditorTableDrop";
+import { createColumnReferencePayload, createMultiTableReferencePayload, createTableReferencePayload, parseTableReferencePayload, tableReferenceInsertText } from "@/lib/editor/queryEditorTableDrop";
+
+const commaNewline = { columnNameSeparator: "comma-newline" as const };
describe("query editor table reference drop", () => {
it("inserts a quoted database name for database references", () => {
@@ -102,7 +104,7 @@ describe("query editor table reference drop", () => {
columnNames: ["id", "order no", "order", "created_at"],
databaseType: "mysql",
})!;
- expect(tableReferenceInsertText(mysql)).toBe("id,\n`order no`,\n`order`,\ncreated_at");
+ expect(tableReferenceInsertText(mysql, undefined, commaNewline)).toBe("id,\n`order no`,\n`order`,\ncreated_at");
const postgres = createColumnReferencePayload({
connectionId: "conn-1",
@@ -110,7 +112,29 @@ describe("query editor table reference drop", () => {
columnNames: ["id", "OrderNo", "select"],
databaseType: "postgres",
})!;
- expect(tableReferenceInsertText(postgres)).toBe('id,\n"OrderNo",\n"select"');
+ expect(tableReferenceInsertText(postgres, undefined, commaNewline)).toBe('id,\n"OrderNo",\n"select"');
+ });
+
+ it("uses columnNameSeparator from the payload for multi-column sidebar drag", () => {
+ const comma = createColumnReferencePayload({
+ connectionId: "conn-1",
+ database: "app-db",
+ columnNames: ["id", "name"],
+ databaseType: "mysql",
+ columnNameSeparator: "comma",
+ })!;
+ expect(comma.columnNameSeparator).toBe("comma");
+ expect(parseTableReferencePayload(JSON.stringify(comma))).toEqual(comma);
+ expect(tableReferenceInsertText(comma)).toBe("id,name");
+
+ const newline = createColumnReferencePayload({
+ connectionId: "conn-1",
+ database: "app-db",
+ columnNames: ["id", "name"],
+ databaseType: "mysql",
+ columnNameSeparator: "newline",
+ })!;
+ expect(tableReferenceInsertText(newline)).toBe("id\nname");
});
it("keeps smart quoting for dialects without a dedicated keyword table", () => {
@@ -121,7 +145,7 @@ describe("query editor table reference drop", () => {
columnNames: ["id", "order no"],
databaseType: "oracle",
})!;
- expect(tableReferenceInsertText(oracle)).toBe('id,\n"order no"');
+ expect(tableReferenceInsertText(oracle, undefined, commaNewline)).toBe('id,\n"order no"');
});
it("bare-quotes sqlserver columns when safe and brackets reserved words", () => {
@@ -131,7 +155,7 @@ describe("query editor table reference drop", () => {
columnNames: ["id", "order", "order no"],
databaseType: "sqlserver",
})!;
- expect(tableReferenceInsertText(payload)).toBe("id,\n[order],\n[order no]");
+ expect(tableReferenceInsertText(payload, undefined, commaNewline)).toBe("id,\n[order],\n[order no]");
});
it("falls back to the editor database type when the payload omits one", () => {
@@ -140,6 +164,21 @@ describe("query editor table reference drop", () => {
database: "app-db",
columnNames: ["order no"],
})!;
- expect(tableReferenceInsertText(payload, "mysql")).toBe("`order no`");
+ expect(tableReferenceInsertText(payload, "mysql", commaNewline)).toBe("`order no`");
+ });
+
+ it("inserts multiple table references with the configured separator", () => {
+ const payload = createMultiTableReferencePayload({
+ connectionId: "conn-1",
+ database: "app-db",
+ tableReferences: [
+ { schema: "public", tableName: "users" },
+ { schema: "public", tableName: "orders" },
+ ],
+ databaseType: "postgres",
+ })!;
+ expect(parseTableReferencePayload(JSON.stringify(payload))).toEqual(payload);
+ expect(tableReferenceInsertText(payload, undefined, { tableNameSeparator: "comma" })).toBe("users,orders");
+ expect(tableReferenceInsertText(payload, undefined, { tableNameSeparator: "comma", includeTableSchema: true })).toBe('"public"."users","public"."orders"');
});
});
diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarDdlTargets.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarDdlTargets.spec.ts
new file mode 100644
index 0000000000..1a37cb74bb
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarDdlTargets.spec.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+import type { TreeNode } from "@/types/database";
+import { resolveSidebarDdlTargets } from "@/lib/sidebar/sidebarDdlTargets";
+
+describe("sidebarDdlTargets", () => {
+ it("resolves multi-select ddl targets in the same execution context", () => {
+ const first: TreeNode = { id: "t1", label: "one", type: "table", connectionId: "c1", database: "db" };
+ const second: TreeNode = { id: "t2", label: "two", type: "view", connectionId: "c1", database: "db" };
+ const otherDb: TreeNode = { id: "t3", label: "three", type: "table", connectionId: "c1", database: "other" };
+ const group: TreeNode = { id: "group", label: "Tables", type: "group-tables", children: [first, second, otherDb] };
+
+ expect(resolveSidebarDdlTargets(first, [group], [second.id, first.id])).toEqual([first, second]);
+ expect(resolveSidebarDdlTargets(first, [group], [otherDb.id, first.id, second.id])).toEqual([first, second]);
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarExportRuntime.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarExportRuntime.spec.ts
index 7107d9ebaf..50966a5a1b 100644
--- a/apps/desktop/src/lib/__tests__/sidebar/sidebarExportRuntime.spec.ts
+++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarExportRuntime.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TreeNode } from "@/types/database";
-import { allDatabasesExportSourceForNode, databaseExportSourceForNode, sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
+import { allDatabasesExportSourceForNode, databaseExportSourceForNode, sidebarSameSchemaStructureTargets, sidebarStructureExportTargets, sidebarTableDataExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
describe("sidebar export runtime", () => {
it("prepares database and table export sources", () => {
@@ -27,4 +27,36 @@ describe("sidebar export runtime", () => {
expect(sidebarStructureExportTargets(first, [group], [third.id, first.id, second.id])).toEqual([first, second, third]);
expect(sidebarStructureExportTargets(first, [group], [second.id])).toEqual([first]);
});
+
+ it("limits multi-select diagram and database export prefills to the active schema context", () => {
+ const publicUsers: TreeNode = { id: "t1", label: "users", type: "table", connectionId: "c1", database: "db", schema: "public" };
+ const publicOrders: TreeNode = { id: "t2", label: "orders", type: "table", connectionId: "c1", database: "db", schema: "public" };
+ const salesUsers: TreeNode = { id: "t3", label: "users", type: "table", connectionId: "c1", database: "db", schema: "sales" };
+ const group: TreeNode = { id: "group", label: "Tables", type: "group-tables", children: [publicUsers, publicOrders, salesUsers] };
+
+ expect(sidebarSameSchemaStructureTargets(publicUsers, [group], [publicOrders.id, publicUsers.id, salesUsers.id])).toEqual([publicUsers, publicOrders]);
+ expect(sidebarSameSchemaStructureTargets(publicUsers, [group], [salesUsers.id, publicUsers.id])).toEqual([publicUsers]);
+ });
+
+ it("counts batch data export targets as tables only", () => {
+ const table: TreeNode = { id: "t1", label: "users", type: "table", connectionId: "c1", database: "db", schema: "public" };
+ const view: TreeNode = { id: "v1", label: "active_users", type: "view", connectionId: "c1", database: "db", schema: "public" };
+ const other: TreeNode = { id: "t2", label: "orders", type: "table", connectionId: "c1", database: "db", schema: "public" };
+ const group: TreeNode = { id: "group", label: "Objects", type: "group-tables", children: [table, view, other] };
+
+ expect(sidebarTableDataExportTargets(table, [group], [other.id, table.id, view.id])).toEqual([table, other]);
+ expect(sidebarTableDataExportTargets(view, [group], [other.id, table.id, view.id])).toEqual([view]);
+ expect(sidebarTableDataExportTargets(table, [group], [view.id])).toEqual([table]);
+ });
+
+ it("scopes batch data export targets to the active execution context", () => {
+ const localUsers: TreeNode = { id: "t1", label: "users", type: "table", connectionId: "c1", database: "db", schema: "public" };
+ const localOrders: TreeNode = { id: "t2", label: "orders", type: "table", connectionId: "c1", database: "db", schema: "public" };
+ const otherConnectionOrders: TreeNode = { id: "t3", label: "orders", type: "table", connectionId: "c2", database: "db", schema: "public" };
+ const otherDatabaseUsers: TreeNode = { id: "t4", label: "users", type: "table", connectionId: "c1", database: "other", schema: "public" };
+ const group: TreeNode = { id: "group", label: "Tables", type: "group-tables", children: [localUsers, localOrders, otherConnectionOrders, otherDatabaseUsers] };
+
+ expect(sidebarTableDataExportTargets(localUsers, [group], [localUsers.id, localOrders.id, otherConnectionOrders.id, otherDatabaseUsers.id])).toEqual([localUsers, localOrders]);
+ expect(sidebarTableDataExportTargets(localOrders, [group], [localUsers.id, localOrders.id, otherConnectionOrders.id, otherDatabaseUsers.id])).toEqual([localUsers, localOrders]);
+ });
});
diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarTableNameCopy.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarTableNameCopy.spec.ts
new file mode 100644
index 0000000000..cdc7f00c7b
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarTableNameCopy.spec.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+import type { TreeNode } from "@/types/database";
+import { columnNameForSidebarDrag, formatSidebarTableNamesForCopy, normalizeSidebarCopyTableNameSeparator, resolveSidebarColumnDragNames, resolveSidebarColumnDragTargets, resolveSidebarTableCopyTargets } from "@/lib/sidebar/sidebarTableNameCopy";
+
+describe("sidebarTableNameCopy", () => {
+ const table = (id: string, label: string, schema = "public"): TreeNode => ({
+ id,
+ label,
+ type: "table",
+ connectionId: "c1",
+ database: "db",
+ schema,
+ });
+
+ const column = (id: string, label: string, name: string): TreeNode => ({
+ id,
+ label,
+ type: "column",
+ connectionId: "c1",
+ database: "db",
+ schema: "public",
+ tableName: "users",
+ meta: { name },
+ });
+
+ it("resolves multi-table copy targets in visible order", () => {
+ const first = table("t1", "one");
+ const second = table("t2", "two");
+ const third = table("t3", "three");
+ expect(resolveSidebarTableCopyTargets(first, [third, first, second])).toEqual([third, first, second]);
+ expect(resolveSidebarTableCopyTargets(first, [second])).toEqual([first]);
+ });
+
+ it("falls back to active node when selection is mixed connection", () => {
+ const first = table("t1", "one");
+ const other = { ...table("t2", "two"), connectionId: "c2" };
+ expect(resolveSidebarTableCopyTargets(first, [first, other])).toEqual([first]);
+ });
+
+ it("formats bare and qualified table names", () => {
+ const targets = [table("t1", "users"), table("t2", "orders", "sales")];
+ expect(formatSidebarTableNamesForCopy(targets, { separator: "comma", includeSchema: false })).toBe("users,orders");
+ expect(formatSidebarTableNamesForCopy(targets, { separator: "newline", includeSchema: true, databaseType: "postgres" })).toBe('"public"."users"\n"sales"."orders"');
+ expect(formatSidebarTableNamesForCopy([table("t1", "users")], { separator: "comma", includeSchema: true, databaseType: "mysql" })).toBe("`users`");
+ expect(formatSidebarTableNamesForCopy([table("t1", "users", "default")], { separator: "comma", includeSchema: true, databaseType: "hive", schema: "default" })).toBe("`default`.`users`");
+ });
+
+ it("resolves multi-column drag targets for the same table", () => {
+ const first = column("c1", "id (bigint)", "id");
+ const second = column("c2", "name (text)", "name");
+ expect(resolveSidebarColumnDragTargets(first, [second, first])).toEqual([second, first]);
+ expect(resolveSidebarColumnDragNames(first, [second, first])).toEqual(["name", "id"]);
+ });
+
+ it("falls back to active column when selection spans tables", () => {
+ const first = column("c1", "id (bigint)", "id");
+ const other = { ...column("c2", "name (text)", "name"), tableName: "orders" };
+ expect(resolveSidebarColumnDragTargets(first, [first, other])).toEqual([first]);
+ });
+
+ it("extracts column drag names from labels", () => {
+ expect(columnNameForSidebarDrag(column("c1", "created_at (timestamp)", "created_at"))).toBe("created_at");
+ });
+
+ it("normalizes separator settings", () => {
+ expect(normalizeSidebarCopyTableNameSeparator("comma-newline")).toBe("comma-newline");
+ expect(normalizeSidebarCopyTableNameSeparator("bogus")).toBe("comma");
+ });
+});
diff --git a/apps/desktop/src/lib/editor/queryEditorTableDrop.ts b/apps/desktop/src/lib/editor/queryEditorTableDrop.ts
index 9d1faff5a5..4297dda429 100644
--- a/apps/desktop/src/lib/editor/queryEditorTableDrop.ts
+++ b/apps/desktop/src/lib/editor/queryEditorTableDrop.ts
@@ -1,4 +1,5 @@
import type { DatabaseType } from "@/types/database";
+import { COLUMN_NAME_COPY_SEPARATOR_VALUES, loadColumnNameCopySeparator, type ColumnNameCopySeparator, isColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import { requiresMysqlIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier.ts";
@@ -37,18 +38,27 @@ export const DBX_TABLE_REFERENCE_DROP_EVENT = "dbx-table-reference-drop";
export const DBX_TABLE_REFERENCE_HOVER_EVENT = "dbx-table-reference-hover";
export const DBX_TABLE_REFERENCE_DRAG_END_EVENT = "dbx-table-reference-drag-end";
+export interface QueryEditorTableReferenceEntry {
+ schema?: string;
+ tableName: string;
+}
+
export interface QueryEditorTableReferencePayload {
kind: "dbx-table-reference";
connectionId: string;
database: string;
schema?: string;
tableName?: string;
+ tableReferences?: QueryEditorTableReferenceEntry[];
columnName?: string;
/** 多个结果列一起拖入时按选择顺序排列;单列沿用 columnName。 */
columnNames?: string[];
referenceType?: "database" | "table" | "column";
databaseType?: DatabaseType;
driverProfile?: string;
+ columnNameSeparator?: ColumnNameCopySeparator;
+ tableNameSeparator?: ColumnNameCopySeparator;
+ includeTableSchema?: boolean;
}
export interface QueryEditorTableReferenceDropDetail {
@@ -62,16 +72,38 @@ export interface QueryEditorTableReferenceHoverDetail {
clientY: number;
}
+export interface TableReferenceInsertOptions {
+ tableNameSeparator?: ColumnNameCopySeparator;
+ includeTableSchema?: boolean;
+ columnNameSeparator?: ColumnNameCopySeparator;
+}
+
function normalizeColumnNames(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((name) => (typeof name === "string" ? name.trim() : "")).filter((name) => name.length > 0);
}
+function normalizeTableReferences(value: unknown): QueryEditorTableReferenceEntry[] {
+ if (!Array.isArray(value)) return [];
+ const entries: QueryEditorTableReferenceEntry[] = [];
+ for (const item of value) {
+ if (!item || typeof item !== "object") continue;
+ const tableName = typeof (item as QueryEditorTableReferenceEntry).tableName === "string" ? (item as QueryEditorTableReferenceEntry).tableName.trim() : "";
+ if (!tableName) continue;
+ const schema = typeof (item as QueryEditorTableReferenceEntry).schema === "string" ? (item as QueryEditorTableReferenceEntry).schema : undefined;
+ entries.push(schema ? { schema, tableName } : { tableName });
+ }
+ return entries;
+}
+
/** schema/databaseType/driverProfile 为可选元数据,payload 各构造路径共用同一拷贝规则。 */
function applyOptionalReferenceMeta(payload: QueryEditorTableReferencePayload, source: Partial) {
if (typeof source.schema === "string" && source.schema) payload.schema = source.schema;
if (source.databaseType) payload.databaseType = source.databaseType;
if (typeof source.driverProfile === "string" && source.driverProfile) payload.driverProfile = source.driverProfile;
+ if (isColumnNameCopySeparator(source.columnNameSeparator)) payload.columnNameSeparator = source.columnNameSeparator;
+ if (isColumnNameCopySeparator(source.tableNameSeparator)) payload.tableNameSeparator = source.tableNameSeparator;
+ if (source.includeTableSchema === true) payload.includeTableSchema = true;
}
let activeTableReferencePayload: QueryEditorTableReferencePayload | null = null;
@@ -107,7 +139,40 @@ export function createTableReferencePayload(options: {
return payload;
}
-export function createColumnReferencePayload(options: { connectionId?: string; database?: string; schema?: string; columnNames?: readonly (string | undefined | null)[]; databaseType?: DatabaseType }): QueryEditorTableReferencePayload | null {
+export function createMultiTableReferencePayload(options: {
+ connectionId?: string;
+ database?: string;
+ tableReferences?: readonly QueryEditorTableReferenceEntry[];
+ databaseType?: DatabaseType;
+ driverProfile?: string;
+ tableNameSeparator?: ColumnNameCopySeparator;
+ includeTableSchema?: boolean;
+}): QueryEditorTableReferencePayload | null {
+ if (!options.connectionId || options.database == null) return null;
+ const tableReferences = normalizeTableReferences(options.tableReferences);
+ if (tableReferences.length === 0) return null;
+ const payload: QueryEditorTableReferencePayload = {
+ kind: "dbx-table-reference",
+ connectionId: options.connectionId,
+ database: options.database,
+ referenceType: "table",
+ };
+ if (tableReferences.length === 1) {
+ payload.tableName = tableReferences[0]!.tableName;
+ if (tableReferences[0]!.schema) payload.schema = tableReferences[0]!.schema;
+ } else {
+ payload.tableReferences = tableReferences;
+ }
+ applyOptionalReferenceMeta(payload, {
+ databaseType: options.databaseType,
+ driverProfile: options.driverProfile,
+ tableNameSeparator: options.tableNameSeparator,
+ includeTableSchema: options.includeTableSchema,
+ });
+ return payload;
+}
+
+export function createColumnReferencePayload(options: { connectionId?: string; database?: string; schema?: string; columnNames?: readonly (string | undefined | null)[]; databaseType?: DatabaseType; columnNameSeparator?: ColumnNameCopySeparator }): QueryEditorTableReferencePayload | null {
const columnNames = normalizeColumnNames(options.columnNames);
if (!options.connectionId || options.database == null || columnNames.length === 0) return null;
const payload: QueryEditorTableReferencePayload = {
@@ -143,10 +208,8 @@ export function parseTableReferencePayload(value: string | undefined | null): Qu
if (typeof parsed.driverProfile === "string" && parsed.driverProfile) payload.driverProfile = parsed.driverProfile;
return payload;
}
- if (typeof parsed.tableName !== "string" || !parsed.tableName) {
- // 无来源表的纯列引用(如从查询结果列头拖入),tableName 非必需。
- const columnNames = normalizeColumnNames(parsed.columnNames);
- if (columnNames.length === 0) return null;
+ const columnNames = normalizeColumnNames(parsed.columnNames);
+ if (columnNames.length > 0 && (parsed.referenceType === "column" || !parsed.tableName)) {
const payload: QueryEditorTableReferencePayload = {
kind: "dbx-table-reference",
connectionId: parsed.connectionId,
@@ -157,18 +220,34 @@ export function parseTableReferencePayload(value: string | undefined | null): Qu
applyOptionalReferenceMeta(payload, parsed);
return payload;
}
+ const tableReferences = normalizeTableReferences(parsed.tableReferences);
+ if (tableReferences.length > 1) {
+ const payload: QueryEditorTableReferencePayload = {
+ kind: "dbx-table-reference",
+ connectionId: parsed.connectionId,
+ database: parsed.database,
+ referenceType: "table",
+ tableReferences,
+ };
+ applyOptionalReferenceMeta(payload, parsed);
+ return payload;
+ }
+ if (typeof parsed.tableName !== "string" || !parsed.tableName) {
+ return null;
+ }
const columnName = typeof parsed.columnName === "string" && parsed.columnName ? parsed.columnName : undefined;
- const referenceType = parsed.referenceType === "column" || columnName ? "column" : "table";
- if (referenceType === "column" && !columnName) return null;
+ const referenceType = parsed.referenceType === "column" || columnName || columnNames.length > 0 ? "column" : "table";
const payload: QueryEditorTableReferencePayload = {
kind: "dbx-table-reference",
connectionId: parsed.connectionId,
database: parsed.database,
tableName: parsed.tableName,
};
- if (referenceType === "column" && columnName) {
- payload.columnName = columnName;
+ if (referenceType === "column") {
payload.referenceType = "column";
+ if (columnNames.length > 0) payload.columnNames = columnNames;
+ else if (columnName) payload.columnName = columnName;
+ else return null;
}
applyOptionalReferenceMeta(payload, parsed);
return payload;
@@ -211,14 +290,40 @@ export function createTableReferenceDragEndEvent(): Event {
return new Event(DBX_TABLE_REFERENCE_DRAG_END_EVENT);
}
-export function tableReferenceInsertText(payload: QueryEditorTableReferencePayload, fallbackDatabaseType?: DatabaseType): string {
+function separatorValue(payload: QueryEditorTableReferencePayload, options: TableReferenceInsertOptions | undefined, kind: "table" | "column"): string {
+ const fromPayload = kind === "table" ? payload.tableNameSeparator : payload.columnNameSeparator;
+ const fromOptions = kind === "table" ? options?.tableNameSeparator : options?.columnNameSeparator;
+ const fallback = kind === "column" ? loadColumnNameCopySeparator() : "comma";
+ const separator = fromOptions ?? fromPayload ?? fallback;
+ return COLUMN_NAME_COPY_SEPARATOR_VALUES[separator] ?? (kind === "column" ? "\t" : ",");
+}
+
+export function tableReferenceInsertText(payload: QueryEditorTableReferencePayload, fallbackDatabaseType?: DatabaseType, options?: TableReferenceInsertOptions): string {
const databaseType = payload.databaseType ?? fallbackDatabaseType;
if (payload.referenceType === "database") {
return quoteTableIdentifier(databaseType, payload.database);
}
const columnNames = payload.columnNames?.length ? payload.columnNames : payload.columnName ? [payload.columnName] : [];
if (payload.referenceType === "column" && columnNames.length > 0) {
- return columnNames.map((name) => quoteColumnReferenceName(databaseType, name)).join(",\n");
+ return columnNames.map((name) => quoteColumnReferenceName(databaseType, name)).join(separatorValue(payload, options, "column"));
+ }
+ const includeSchema = payload.includeTableSchema ?? options?.includeTableSchema ?? false;
+ const tableSeparator = separatorValue(payload, options, "table");
+ const tableReferences = payload.tableReferences?.length ? payload.tableReferences : payload.tableName ? [{ schema: payload.schema, tableName: payload.tableName }] : [];
+ if (tableReferences.length > 1 || (tableReferences.length === 1 && includeSchema)) {
+ return tableReferences
+ .map((entry) =>
+ includeSchema
+ ? qualifiedTableName({
+ databaseType,
+ driverProfile: payload.driverProfile,
+ schema: entry.schema,
+ tableName: entry.tableName,
+ database: payload.database,
+ })
+ : entry.tableName,
+ )
+ .join(tableSeparator);
}
const tableName = payload.tableName || payload.database;
return qualifiedTableName({
diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts
index afe997c485..0842ab9b39 100644
--- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts
+++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts
@@ -80,6 +80,8 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"sidebarIndent",
"sidebarFontSize",
"sidebarHiddenTablePrefixes",
+ "sidebarCopyTableNameSeparator",
+ "sidebarCopyTableNameIncludeSchema",
"redisKeyTemplates",
"exportBatchSize",
"exportRowLimitEnabled",
diff --git a/apps/desktop/src/lib/sidebar/sidebarDdlTargets.ts b/apps/desktop/src/lib/sidebar/sidebarDdlTargets.ts
new file mode 100644
index 0000000000..6fc2cabcd8
--- /dev/null
+++ b/apps/desktop/src/lib/sidebar/sidebarDdlTargets.ts
@@ -0,0 +1,11 @@
+import type { TreeNode } from "@/types/database";
+import { sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate";
+import { sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
+
+export type SidebarDdlTarget = TreeNode & { connectionId: string; database: string };
+
+export function resolveSidebarDdlTargets(activeNode: TreeNode, treeNodes: readonly TreeNode[], selectedNodeIds: readonly string[]): SidebarDdlTarget[] {
+ const targets = sidebarStructureExportTargets(activeNode, treeNodes, selectedNodeIds);
+ if (!targets.length || !activeNode.connectionId || !activeNode.database) return [];
+ return sidebarDdlTargetsForExecutionContext(activeNode as SidebarDdlTarget, targets);
+}
diff --git a/apps/desktop/src/lib/sidebar/sidebarExportRuntime.ts b/apps/desktop/src/lib/sidebar/sidebarExportRuntime.ts
index 253c27a762..e1b94bfd6f 100644
--- a/apps/desktop/src/lib/sidebar/sidebarExportRuntime.ts
+++ b/apps/desktop/src/lib/sidebar/sidebarExportRuntime.ts
@@ -1,4 +1,5 @@
import type { TreeNode } from "@/types/database";
+import { sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate";
export interface SidebarDatabaseExportSource {
connectionId: string;
@@ -25,15 +26,26 @@ export function allDatabasesExportSourceForNode(node: TreeNode): SidebarDatabase
return { connectionId: node.connectionId, database: "", allDatabases: true };
}
-export function sidebarStructureExportTargets(activeNode: TreeNode, treeNodes: readonly TreeNode[], selectedNodeIds: readonly string[]): Array {
- const canExport = (node: TreeNode): node is TreeNode & { connectionId: string; database: string } => (node.type === "table" || node.type === "view" || node.type === "materialized_view") && !!node.connectionId && !!node.database;
- if (!canExport(activeNode)) return [];
+export type SidebarStructureExportTarget = TreeNode & { connectionId: string; database: string };
+
+function canStructureExport(node: TreeNode): node is SidebarStructureExportTarget {
+ return (node.type === "table" || node.type === "view" || node.type === "materialized_view") && !!node.connectionId && !!node.database;
+}
+
+/** connection + database + catalog + schema identity for diagram and database-export prefills. */
+export function sidebarStructureTargetSchemaContext(node: TreeNode): string {
+ if (!node.connectionId || !node.database) return "";
+ return `${node.connectionId}\0${node.database}\0${node.catalog ?? ""}\0${node.schema ?? ""}`;
+}
+
+export function sidebarStructureExportTargets(activeNode: TreeNode, treeNodes: readonly TreeNode[], selectedNodeIds: readonly string[]): SidebarStructureExportTarget[] {
+ if (!canStructureExport(activeNode)) return [];
const selected = new Set(selectedNodeIds);
- const targets: Array = [];
+ const targets: SidebarStructureExportTarget[] = [];
const visit = (nodes: readonly TreeNode[]) => {
for (const node of nodes) {
- if (selected.has(node.id) && canExport(node)) targets.push(node);
+ if (selected.has(node.id) && canStructureExport(node)) targets.push(node);
if (node.children) visit(node.children);
if (node.hiddenChildren) visit(node.hiddenChildren);
}
@@ -41,3 +53,23 @@ export function sidebarStructureExportTargets(activeNode: TreeNode, treeNodes: r
visit(treeNodes);
return targets.length > 1 && targets.some((node) => node.id === activeNode.id) ? targets : [activeNode];
}
+
+/** Keeps multi-select only when every target shares the active node's schema context. */
+export function sidebarSameSchemaStructureTargets(activeNode: TreeNode, treeNodes: readonly TreeNode[], selectedNodeIds: readonly string[]): SidebarStructureExportTarget[] {
+ if (!canStructureExport(activeNode)) return [];
+ const targets = sidebarStructureExportTargets(activeNode, treeNodes, selectedNodeIds);
+ if (targets.length <= 1) return targets;
+ const activeContext = sidebarStructureTargetSchemaContext(activeNode);
+ const sameContext = targets.filter((target) => sidebarStructureTargetSchemaContext(target) === activeContext);
+ return sameContext.length > 1 && sameContext.some((target) => target.id === activeNode.id) ? sameContext : [activeNode];
+}
+
+/** Batch data export includes tables only and stays inside the active execution context; single-target fallback still allows one view/materialized view. */
+export function sidebarTableDataExportTargets(activeNode: TreeNode, treeNodes: readonly TreeNode[], selectedNodeIds: readonly string[]): SidebarStructureExportTarget[] {
+ const targets = canStructureExport(activeNode) ? sidebarDdlTargetsForExecutionContext(activeNode, sidebarStructureExportTargets(activeNode, treeNodes, selectedNodeIds)) : [];
+ const tables = targets.filter((node) => node.type === "table");
+ if (tables.length > 1 && tables.some((node) => node.id === activeNode.id)) return tables;
+ if (activeNode.type === "table" && canStructureExport(activeNode)) return [activeNode];
+ if (canStructureExport(activeNode) && targets.some((node) => node.id === activeNode.id)) return [activeNode];
+ return tables;
+}
diff --git a/apps/desktop/src/lib/sidebar/sidebarTableNameCopy.ts b/apps/desktop/src/lib/sidebar/sidebarTableNameCopy.ts
new file mode 100644
index 0000000000..9a7398d8bb
--- /dev/null
+++ b/apps/desktop/src/lib/sidebar/sidebarTableNameCopy.ts
@@ -0,0 +1,90 @@
+import type { ColumnInfo, DatabaseType, TreeNode } from "@/types/database";
+import { COLUMN_NAME_COPY_SEPARATOR_VALUES, type ColumnNameCopySeparator, isColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
+import { copyNameForTreeNode } from "@/lib/sidebar/treeNodeClick";
+import { qualifiedTableName } from "@/lib/table/tableSelectSql";
+
+export type SidebarTableCopyTarget = TreeNode & { connectionId: string; database: string };
+
+const TABLE_COPY_NODE_TYPES = new Set(["table", "view", "materialized_view"]);
+
+function isTableCopyTarget(node: TreeNode): node is SidebarTableCopyTarget {
+ return TABLE_COPY_NODE_TYPES.has(node.type) && !!node.connectionId && !!node.database;
+}
+
+export function columnNameForSidebarDrag(node: TreeNode): string {
+ if (node.type !== "column") return copyNameForTreeNode(node);
+ const column = node.meta as Partial | undefined;
+ if (typeof column?.name === "string" && column.name) return column.name;
+ return node.label.replace(/\s+\([^()]*\)$/, "");
+}
+
+function sameColumnTableContext(first: TreeNode, node: TreeNode): boolean {
+ return node.type === "column" && !!node.connectionId && node.database != null && node.connectionId === first.connectionId && node.database === first.database && (node.schema ?? "") === (first.schema ?? "") && !!node.tableName && node.tableName === first.tableName;
+}
+
+export function resolveSidebarTableCopyTargets(activeNode: TreeNode, selectedNodes: readonly TreeNode[]): SidebarTableCopyTarget[] {
+ if (!isTableCopyTarget(activeNode)) return [];
+ if (selectedNodes.length <= 1 || !selectedNodes.some((node) => node.id === activeNode.id)) return [activeNode];
+ const targets = selectedNodes.filter(isTableCopyTarget);
+ if (targets.length <= 1) return [activeNode];
+ const first = targets[0]!;
+ if (!targets.every((node) => node.connectionId === first.connectionId && node.database === first.database && (node.catalog ?? "") === (first.catalog ?? ""))) {
+ return [activeNode];
+ }
+ return targets;
+}
+
+export function resolveSidebarColumnDragTargets(activeNode: TreeNode, selectedNodes: readonly TreeNode[]): TreeNode[] {
+ if (activeNode.type !== "column" || !activeNode.tableName) return [activeNode];
+ if (selectedNodes.length <= 1 || !selectedNodes.some((node) => node.id === activeNode.id)) return [activeNode];
+ const targets = selectedNodes.filter((node) => node.type === "column" && sameColumnTableContext(activeNode, node));
+ return targets.length > 1 ? targets : [activeNode];
+}
+
+export function resolveSidebarColumnDragNames(activeNode: TreeNode, selectedNodes: readonly TreeNode[]): string[] {
+ return resolveSidebarColumnDragTargets(activeNode, selectedNodes)
+ .map(columnNameForSidebarDrag)
+ .filter((name) => name.length > 0);
+}
+
+export interface FormatSidebarTableNamesOptions {
+ separator: ColumnNameCopySeparator;
+ includeSchema: boolean;
+ databaseType?: DatabaseType;
+ driverProfile?: string;
+ identifierQuote?: string;
+}
+
+export function formatSidebarTableNamesForCopy(targets: readonly SidebarTableCopyTarget[], options: FormatSidebarTableNamesOptions): string {
+ const separator = COLUMN_NAME_COPY_SEPARATOR_VALUES[options.separator] ?? ",";
+ const parts = targets.map((target) => {
+ if (!options.includeSchema) return copyNameForTreeNode(target);
+ return qualifiedTableName({
+ databaseType: options.databaseType,
+ driverProfile: options.driverProfile,
+ identifierQuote: options.identifierQuote,
+ schema: target.schema,
+ tableName: target.label,
+ database: target.database,
+ catalog: target.catalog,
+ });
+ });
+ return parts.join(separator);
+}
+
+export function normalizeSidebarCopyTableNameSeparator(value: unknown): ColumnNameCopySeparator {
+ return isColumnNameCopySeparator(value) ? value : "comma";
+}
+
+export function sidebarTableCopyNodes(activeNode: TreeNode, selectedNodes: readonly TreeNode[]): TreeNode[] {
+ return selectedNodes.length > 1 && selectedNodes.some((node) => node.id === activeNode.id) ? [...selectedNodes] : [activeNode];
+}
+
+export function formatSidebarTableCopyText(activeNode: TreeNode, selectedNodes: readonly TreeNode[], options: FormatSidebarTableNamesOptions): string {
+ const tableTargets = resolveSidebarTableCopyTargets(activeNode, selectedNodes);
+ const separator = COLUMN_NAME_COPY_SEPARATOR_VALUES[options.separator] ?? ",";
+ if (tableTargets.length > 0 && (tableTargets.length > 1 || options.includeSchema)) {
+ return formatSidebarTableNamesForCopy(tableTargets, options);
+ }
+ return sidebarTableCopyNodes(activeNode, selectedNodes).map(copyNameForTreeNode).join(separator);
+}
diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeRuntime.ts b/apps/desktop/src/lib/sidebar/sidebarTreeRuntime.ts
index 227952ea57..408cd54bea 100644
--- a/apps/desktop/src/lib/sidebar/sidebarTreeRuntime.ts
+++ b/apps/desktop/src/lib/sidebar/sidebarTreeRuntime.ts
@@ -20,6 +20,7 @@ export interface SidebarTreeRuntimeHost {
handleRowKeydown(node: TreeNode, event: KeyboardEvent): void;
openPrimaryVisibleFilter(node: TreeNode): void;
openDataInNewTab(node: TreeNode): void;
+ openDdlForSelection(node: TreeNode, selectedNodeIds: readonly string[]): Promise;
requestPaste(node: TreeNode): boolean;
toggleNode(node: TreeNode): void;
}
diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts
index 4bccacb22e..d76677e3ef 100644
--- a/apps/desktop/src/stores/connectionStore.ts
+++ b/apps/desktop/src/stores/connectionStore.ts
@@ -518,6 +518,7 @@ export const useConnectionStore = defineStore("connection", () => {
database: string;
schema?: string;
tableName?: string;
+ tableNames?: string[];
} | null>(null);
const docsSource = ref<{
connectionId: string;
diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts
index ea36d5619a..a2af24a529 100644
--- a/apps/desktop/src/stores/settingsStore.ts
+++ b/apps/desktop/src/stores/settingsStore.ts
@@ -15,8 +15,10 @@ import { normalizeConnectTimeoutSecs, normalizeQueryTimeoutSecs } from "@/lib/co
import { needsTabNavigationHistoryShortcutMigration, normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort";
-import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
+import { type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
+import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
+import { normalizeSidebarCopyTableNameSeparator } from "@/lib/sidebar/sidebarTableNameCopy";
import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
@@ -670,6 +672,8 @@ export interface EditorSettings {
formatSqlOnSqlFileSave: boolean;
updateNotificationsEnabled: boolean;
sidebarHiddenTablePrefixes: string[];
+ sidebarCopyTableNameSeparator: ColumnNameCopySeparator;
+ sidebarCopyTableNameIncludeSchema: boolean;
sidebarObjectInfoMode: SidebarObjectInfoMode;
sidebarShowConnectionNotes: boolean;
sidebarAllowHorizontalScroll: boolean;
@@ -888,6 +892,8 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
formatSqlOnSqlFileSave: false,
updateNotificationsEnabled: true,
sidebarHiddenTablePrefixes: [],
+ sidebarCopyTableNameSeparator: "comma",
+ sidebarCopyTableNameIncludeSchema: false,
sidebarObjectInfoMode: "comment-inline",
sidebarShowConnectionNotes: false,
sidebarAllowHorizontalScroll: false,
@@ -1322,6 +1328,8 @@ export function normalizeEditorSettings(settings: Partial, exist
formatSqlOnSqlFileSave: settings.formatSqlOnSqlFileSave === true,
updateNotificationsEnabled: settings.updateNotificationsEnabled ?? DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(settings.sidebarHiddenTablePrefixes),
+ sidebarCopyTableNameSeparator: normalizeSidebarCopyTableNameSeparator(settings.sidebarCopyTableNameSeparator),
+ sidebarCopyTableNameIncludeSchema: settings.sidebarCopyTableNameIncludeSchema === true,
sidebarObjectInfoMode: normalizeSidebarObjectInfoMode(
settings.sidebarObjectInfoMode,
(
@@ -1925,6 +1933,8 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.formatSqlOnSqlFileSave !== undefined) editorSettings.value.formatSqlOnSqlFileSave = partial.formatSqlOnSqlFileSave === true;
if (partial.updateNotificationsEnabled !== undefined) editorSettings.value.updateNotificationsEnabled = partial.updateNotificationsEnabled;
if (partial.sidebarHiddenTablePrefixes !== undefined) editorSettings.value.sidebarHiddenTablePrefixes = normalizeSidebarHiddenTablePrefixes(partial.sidebarHiddenTablePrefixes);
+ if (partial.sidebarCopyTableNameSeparator !== undefined) editorSettings.value.sidebarCopyTableNameSeparator = normalizeSidebarCopyTableNameSeparator(partial.sidebarCopyTableNameSeparator);
+ if (partial.sidebarCopyTableNameIncludeSchema !== undefined) editorSettings.value.sidebarCopyTableNameIncludeSchema = partial.sidebarCopyTableNameIncludeSchema === true;
if (partial.sidebarObjectInfoMode !== undefined) editorSettings.value.sidebarObjectInfoMode = normalizeSidebarObjectInfoMode(partial.sidebarObjectInfoMode);
if (partial.sidebarShowConnectionNotes !== undefined) editorSettings.value.sidebarShowConnectionNotes = partial.sidebarShowConnectionNotes === true;
if (partial.sidebarAllowHorizontalScroll !== undefined) editorSettings.value.sidebarAllowHorizontalScroll = partial.sidebarAllowHorizontalScroll;
diff --git a/packages/app-tests/sidebarContextMenuHost.test.ts b/packages/app-tests/sidebarContextMenuHost.test.ts
index 0b503d63ff..2f1cbe02c3 100644
--- a/packages/app-tests/sidebarContextMenuHost.test.ts
+++ b/packages/app-tests/sidebarContextMenuHost.test.ts
@@ -88,7 +88,7 @@ test("table copy menu uses the shared single and multi-selection clipboard path"
assert.match(copySelectedNamesBody, /const selectedNodes = selectedTreeNodesInVisibleOrder\(\)/);
assert.match(copySelectedNamesBody, /selectedNodes\.length > 1 && selectedNodes\.some\(\(node\) => node\.id === activeNode\.value\.id\) \? selectedNodes : \[activeNode\.value\]/);
assert.match(copySelectedNamesBody, /updateTreeClipboardForNodes\(nodes\)/);
- assert.match(copySelectedNamesBody, /copyToClipboard\(nodes\.map\(copyNameForTreeNode\)\.join\("\\n"\)\)/);
+ assert.match(copySelectedNamesBody, /formatSelectedTableNamesForClipboard/);
});
test("MySQL object name menus expose leaf and display-path copy choices", () => {
@@ -100,7 +100,7 @@ test("MySQL object name menus expose leaf and display-path copy choices", () =>
const databaseMenuBody = functionBody(runtimeHost, "buildDatabaseSidebarMenu");
const objectMenuBody = functionBody(runtimeHost, "buildObjectSidebarMenu");
- assert.match(copyNameBody, /copyNameForTreeNode\(node\)/);
+ assert.match(copyNameBody, /formatSelectedTableNamesForClipboard/);
assert.match(copyDisplayPathBody, /copyDisplayPathForTreeNode\(node, connectionName\)/);
assert.match(copyNameMenuItemBody, /currentDatabaseType\(\) === "mysql"/);
assert.match(copyNameMenuItemBody, /children: \[/);
@@ -114,6 +114,27 @@ test("MySQL object name menus expose leaf and display-path copy choices", () =>
assert.match(objectMenuBody, /node\.type === "sequence"[\s\S]*action: copyName/);
});
+test("multi-select view ddl opens a combined ddl tab instead of export structure", () => {
+ const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
+ const openDdlBody = functionBody(runtimeHost, "openDdl");
+
+ assert.doesNotMatch(openDdlBody, /exportStructure\(\)/);
+ assert.match(openDdlBody, /openSidebarMultiTableDdlTab\(targets\)/);
+ assert.match(runtimeHost, /openDdlForSelection/);
+});
+
+test("multi-select add-to-ai only mentions tables in the active execution context", () => {
+ const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
+ // The function's return type annotation contains braces, so extract the body
+ // from the signature line to the next column-0 closing brace.
+ const selectedAiTableTargetsBody = /function selectedAiTableTargets\([^)]*\)[^\n]*\{[\s\S]*?\n\}/.exec(runtimeHost)?.[0] ?? "";
+
+ assert.notEqual(selectedAiTableTargetsBody, "");
+ assert.match(selectedAiTableTargetsBody, /resolveSidebarDdlTargets\(/);
+ assert.match(selectedAiTableTargetsBody, /target\.type === "table"/);
+ assert.doesNotMatch(selectedAiTableTargetsBody, /sidebarStructureExportTargets\(/);
+});
+
test("successful tree table paste consumes only the clipboard used to start it", () => {
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable");