feat(sidebar): batch actions for multi selected nodes
This commit is contained in:
1 parent
2f71a1a8aa
commit
69a032ed63
37 files changed
+1302
-159
No files matched your search
+133
-5
@@ -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));
|
||||
});
|
||||
});
|
||||
+66
-2
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user