diff --git a/apps/desktop/src/components/export/ExportProgressDialog.vue b/apps/desktop/src/components/export/ExportProgressDialog.vue new file mode 100644 index 0000000000..4d835a5392 --- /dev/null +++ b/apps/desktop/src/components/export/ExportProgressDialog.vue @@ -0,0 +1,118 @@ + + + diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 6040c426aa..d1e472f96b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -181,6 +181,7 @@ import { import { useToast } from "@/composables/useToast"; import { useDataGridExport } from "@/composables/useDataGridExport"; +import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue"; import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; import { useDataGridSelection } from "@/composables/useDataGridSelection"; import { useDataGridEditor } from "@/composables/useDataGridEditor"; @@ -3432,6 +3433,18 @@ function setRowStatusFilter(value: string) { rowStatusFilter.value = value as RowStatusFilter; } +// --- Export progress dialog state --- +const exportProgressDialog = ref(false); +const exportProgressState = ref({ + title: "", + tableName: "", + format: "csv" as string, + rowsExported: 0, + totalRows: null as number | null, + status: "", + errorMessage: null as string | null, +}); + // --- Export composable --- const { copyText, @@ -3473,6 +3486,8 @@ const { selectedRowIds, hasRowSelection, fullExportResult: props.fullExportResult, + exportProgressDialog, + exportProgressState, }); const pageSizeMenuItems = computed(() => @@ -7692,6 +7707,7 @@ const gridContextMenuItems = computed(() => { @confirm="confirmDeleteRow" /> + diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 0df5bdd9a0..1200f1f112 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -42,6 +42,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue"; +import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue"; import * as api from "@/lib/api"; import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database"; import { isSchemaAware } from "@/lib/databaseCapabilities"; @@ -69,10 +70,10 @@ import { import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql"; import { buildViewDdl } from "@/lib/viewDdl"; import { isTauriRuntime } from "@/lib/tauriRuntime"; +import { generateDatabaseExportId } from "@/lib/databaseExport"; import { copyToClipboard } from "@/lib/clipboard"; import { formatSqlInsert } from "@/lib/exportFormats"; import { fetchTableDataForExport } from "@/lib/tableDataExport"; -import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -155,6 +156,16 @@ const showBatchDropConfirm = ref(false); const batchDropPreviewSql = ref(""); let loadId = 0; +// Export progress state +const showExportProgress = ref(false); +const exportProgressId = ref(""); +const exportProgressTableName = ref(""); +const exportProgressFormat = ref(""); +const exportProgressRows = ref(0); +const exportProgressTotal = ref(null); +const exportProgressStatus = ref(""); +const exportProgressError = ref(null); + const needsSchema = computed(() => isSchemaAware(props.connection.db_type)); const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE").length); const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW").length); @@ -772,7 +783,7 @@ async function exportStructure(row: ObjectBrowserRow) { } } -async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") { +async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") { try { const schema = row.schema || selectedSchema.value; const queryColumns = @@ -781,54 +792,14 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") (column) => column.name, ) : undefined; - - if (format === "csv" && isTauriRuntime()) { - const { save } = await import("@tauri-apps/plugin-dialog"); - const outputPath = await save({ - defaultPath: `${row.name}.csv`, - filters: [{ name: "CSV", extensions: ["csv"] }], - }); - if (!outputPath) return; - await api.exportTableDataCsv({ - filePath: outputPath as string, - connectionId: props.connection.id, - database: props.database, - schema, - tableName: row.name, - columns: queryColumns, - timeoutSecs: queryTimeoutSecsForConnection(props.connection), - }); - toast(t("grid.exported")); - return; - } - const result = await fetchTableDataForExport({ databaseType: props.connection.db_type, schema, tableName: row.name, columns: queryColumns, - executePage: (sql) => - api.executeQuery(props.connection.id, props.database, sql, undefined, undefined, { - timeoutSecs: queryTimeoutSecsForConnection(props.connection), - }), + executePage: (sql) => api.executeQuery(props.connection.id, props.database, sql), }); - if (format === "csv") { - let outputPath = `${row.name}.csv`; - if (isTauriRuntime()) { - const { save } = await import("@tauri-apps/plugin-dialog"); - const path = await save({ - defaultPath: outputPath, - filters: [{ name: "CSV", extensions: ["csv"] }], - }); - if (!path) return; - outputPath = path as string; - } - await api.exportQueryResultCsv(outputPath, result.columns, result.rows); - toast(t("grid.exported")); - return; - } - if (format === "json") { let outputPath = `${row.name}.json`; if (isTauriRuntime()) { @@ -859,40 +830,94 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") } } -async function exportDataXlsx(row: ObjectBrowserRow) { - try { - const schema = row.schema || selectedSchema.value; - const queryColumns = - props.connection.db_type === "neo4j" - ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map( - (column) => column.name, - ) - : undefined; - const result = await fetchTableDataForExport({ - databaseType: props.connection.db_type, - schema, - tableName: row.name, - columns: queryColumns, - executePage: (sql) => - api.executeQuery(props.connection.id, props.database, sql, undefined, undefined, { - timeoutSecs: queryTimeoutSecsForConnection(props.connection), - }), - }); +async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") { + if (format === "csv") { + await exportTableData(row, "csv"); + return; + } + await exportDataLegacy(row, format); +} - let outputPath = `${row.name}.xlsx`; - if (isTauriRuntime()) { +async function exportDataXlsx(row: ObjectBrowserRow) { + await exportTableData(row, "xlsx"); +} + +async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") { + const schema = row.schema || selectedSchema.value; + + // Save dialog first + let filePath = ""; + const defaultName = `${row.name}.${format}`; + + if (isTauriRuntime()) { + try { const { save } = await import("@tauri-apps/plugin-dialog"); + const filter = format === "csv" ? { name: "CSV", extensions: ["csv"] } : { name: "Excel", extensions: ["xlsx"] }; const path = await save({ - defaultPath: outputPath, - filters: [{ name: "Excel", extensions: ["xlsx"] }], + defaultPath: defaultName, + filters: [filter], }); if (!path) return; - outputPath = path as string; + filePath = path as string; + } catch (e: any) { + toast(e?.message || String(e), 5000); + return; } - await api.exportQueryResultXlsx(outputPath, row.name, result.columns, result.rows); + } else { + const webExportId = generateDatabaseExportId(); + filePath = `__web_export_${webExportId}.${format}`; + } + + // Set up progress state and open dialog + const exportId = generateDatabaseExportId(); + exportProgressId.value = exportId; + exportProgressTableName.value = row.name; + exportProgressFormat.value = format; + exportProgressRows.value = 0; + exportProgressTotal.value = null; + exportProgressStatus.value = "Running"; + exportProgressError.value = null; + showExportProgress.value = true; + + // Get columns for neo4j only + const queryColumns = + props.connection.db_type === "neo4j" + ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map( + (column) => column.name, + ) + : undefined; + + const request: api.TableExportRequest = { + exportId, + connectionId: props.connection.id, + database: props.database, + schema, + tableName: row.name, + filePath, + format, + columns: queryColumns, + }; + + try { + await api.startTableExport(request, (progress) => { + exportProgressRows.value = progress.rowsExported; + exportProgressTotal.value = progress.totalRows; + exportProgressStatus.value = progress.status; + exportProgressError.value = progress.errorMessage || null; + }); toast(t("grid.exported")); } catch (e: any) { - toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + if (exportProgressStatus.value !== "Cancelled") { + exportProgressStatus.value = "Error"; + exportProgressError.value = e?.message || String(e); + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } + } +} + +async function cancelExport() { + if (exportProgressId.value) { + await api.cancelTableExport(exportProgressId.value); } } @@ -1683,6 +1708,18 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { + +