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

@@ -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));
});
});
@@ -4,15 +4,22 @@ import type { TreeNode } from "@/types/database";
import { useSidebarTreeToolRuntime } from "@/composables/useSidebarTreeToolRuntime";
function setup(node: Partial<TreeNode>) {
function setup(node: Partial<TreeNode>, 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",
});
});
});
@@ -56,6 +56,7 @@ const diagramPrefillConnectionId = ref("");
const diagramPrefillDatabase = ref("");
const diagramPrefillSchema = ref("");
const diagramFocusTableName = ref("");
const diagramFocusTableNames = ref<string[]>([]);
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,
@@ -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<SidebarTableExportTarget, "nodeId">): 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<string, number>();
for (const target of targets) tableNameCounts.set(target.tableName, (tableNameCounts.get(target.tableName) ?? 0) + 1);
const usedNames = new Set<string>();
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<TreeNode & { connectionId: string; database: string }> {
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<string | null> {
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<string | null> {
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<string | undefined | null> {
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<string, ColumnInfo[] | undefined>();
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 {
@@ -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<TreeNode>;
@@ -11,6 +11,7 @@ interface SidebarTreeToolRuntimeOptions {
queryStore: ReturnType<typeof useQueryStore>;
settingsStore: ReturnType<typeof useSettingsStore>;
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() {