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

@@ -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)");
});
});
@@ -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"');
});
});
@@ -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]);
});
});
@@ -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]);
});
});
@@ -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");
});
});
@@ -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<QueryEditorTableReferencePayload>) {
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({
@@ -80,6 +80,8 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"sidebarIndent",
"sidebarFontSize",
"sidebarHiddenTablePrefixes",
"sidebarCopyTableNameSeparator",
"sidebarCopyTableNameIncludeSchema",
"redisKeyTemplates",
"exportBatchSize",
"exportRowLimitEnabled",
@@ -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);
}
@@ -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<TreeNode & { connectionId: string; database: string }> {
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<TreeNode & { connectionId: string; database: string }> = [];
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;
}
@@ -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<TreeNode["type"]>(["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<ColumnInfo> | 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);
}
@@ -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<boolean>;
requestPaste(node: TreeNode): boolean;
toggleNode(node: TreeNode): void;
}