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 });
- +
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");