feat(sidebar): batch actions for multi selected nodes

This commit is contained in:
Mo-Moore authored and GitHub committed 2026-09-01 00:22:02 +08:00
1 parent 2f71a1a8aa
commit 69a032ed63
37 files changed
+1302 -159

No files matched your search

@@ -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<ContextMenuItem[]>([]);
const emit = defineEmits<{
"open-settings": [initialTab: string];
"add-to-ai": [node: TreeNode];
"add-to-ai": [nodes: TreeNode | TreeNode[]];
}>();
const sidebarContextMenuTarget = ref<SidebarActionTarget | null>(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"
@@ -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<string, any> | 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<TreeNode & { connectionId: string; database: string }>, buildSql: (context: NonNullable<Awaited<ReturnType<typeof loadTemplateContext>>>) => 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<TreeNode & { connectionId: string; database: string }>) {
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<boolean> {
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<TreeNode & { connectionId: string; database: string }> {
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,
});
@@ -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<ColumnInfo> | undefined;
if (typeof column?.name === "string" && column.name) return column.name;
return node.label.replace(/\s+\([^()]*\)$/, "");
}
function startTableReferenceDrag(payload: QueryEditorTableReferencePayload) {
draggingTableReferencePayload = payload;
setActiveTableReferencePayload(payload);
@@ -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<HTMLElement>("[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<QueryEditorTableReferenceDropDetail>).detail;
expect(detail.payload.referenceType).toBe("column");
expect(detail.payload.columnNames).toEqual(["id", "name"]);
expect(detail.payload.columnNameSeparator).toBe("comma");
});
});