diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index f0a7ac239..67dc51a96 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -17,7 +17,7 @@ import { buildSelectedTablesPayload } from "@/lib/export/databaseExportSelection import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useToast } from "@/composables/useToast"; import { Input } from "@/components/ui/input"; -import { Download, Square, CheckSquare, Search, X } from "@lucide/vue"; +import { Download, Square, CheckSquare, Search, X, Loader2 } from "@lucide/vue"; import { useExportTracker } from "@/composables/useExportTracker"; const { t } = useI18n(); @@ -230,11 +230,6 @@ async function startExport() { await startAllDatabasesExport(); return; } - isExporting.value = true; - exportDone.value = false; - exportError.value = null; - exportCancelled.value = false; - exportProgress.value = null; exportId.value = generateDatabaseExportId(); @@ -248,13 +243,9 @@ async function startExport() { defaultPath: `${safeName}.sql`, filters: [{ name: "SQL", extensions: ["sql"] }], }); - if (!path) { - isExporting.value = false; - return; - } + if (!path) return; filePath = path; } catch (e: any) { - isExporting.value = false; toast(e?.message || String(e), 5000); return; } @@ -263,6 +254,24 @@ async function startExport() { filePath = `__web_export_${exportId.value}.sql`; } + // Switch to the progress view only after the save dialog closes, and seed a + // preparing state so the dialog is never a blank panel while metadata loads. + isExporting.value = true; + exportDone.value = false; + exportError.value = null; + exportCancelled.value = false; + exportProgress.value = { + exportId: exportId.value, + currentObject: "", + objectIndex: 0, + totalObjects: 0, + rowsExported: 0, + totalRows: null, + status: "Running", + error: null, + preparing: true, + }; + const request: api.DatabaseExportRequest = { exportId: exportId.value, connectionId: connectionId.value, @@ -339,19 +348,40 @@ async function startAllDatabasesExport() { exportDone.value = false; exportError.value = null; exportCancelled.value = false; - exportProgress.value = null; batchDatabaseIndex.value = 0; batchRowsExported.value = 0; const dbs = [...selectedDatabases.value]; const batchId = generateDatabaseExportId(); exportId.value = batchId; + exportProgress.value = { + exportId: batchId, + currentObject: "", + objectIndex: 0, + totalObjects: 0, + rowsExported: 0, + totalRows: null, + status: "Running", + error: null, + preparing: true, + }; addDatabaseExportTask(batchId, t("databaseExport.allDatabasesTask", { count: dbs.length }), directoryPath); let exportPlan: AllDatabaseExportPlanItem[] = []; try { exportPlan = await buildExportPlanForDatabases(dbs); batchDatabaseTotal.value = exportPlan.length; + exportProgress.value = { + exportId: batchId, + currentObject: "", + objectIndex: 0, + totalObjects: exportPlan.length, + rowsExported: 0, + totalRows: null, + status: "Running", + error: null, + preparing: true, + }; for (let index = 0; index < exportPlan.length; index += 1) { if (exportCancelled.value) break; @@ -490,14 +520,40 @@ const progressPercent = computed(() => { if (exportAllDatabases.value && batchDatabaseTotal.value > 0) { if (exportDone.value) return 100; const current = exportProgress.value; - const currentDatabaseProgress = current && current.totalObjects > 0 ? current.objectIndex / current.totalObjects : 0; + const currentDatabaseProgress = current && !current.preparing && current.totalObjects > 0 ? current.objectIndex / current.totalObjects : 0; return Math.round(Math.min(1, (Math.max(0, batchDatabaseIndex.value - 1) + currentDatabaseProgress) / batchDatabaseTotal.value) * 100); } const p = exportProgress.value; - if (!p || p.totalObjects === 0) return 0; + if (!p || p.preparing || p.totalObjects === 0) return 0; return Math.round((p.objectIndex / p.totalObjects) * 100); }); +/** True while schema metadata is still loading — before objects are written. */ +const isPreparingExport = computed(() => { + if (!isExporting.value) return false; + if (exportDone.value || exportError.value || exportCancelled.value) return false; + const p = exportProgress.value; + if (!p) return true; + return !!p.preparing || p.totalObjects <= 0; +}); + +const progressStatusText = computed(() => { + const p = exportProgress.value; + if (isPreparingExport.value) { + // Keep it as presence feedback only — no second progress counter that later resets. + if (p?.currentObject) { + return t("databaseExport.preparingObject", { object: p.currentObject }); + } + return t("databaseExport.preparing"); + } + if (!p) return t("databaseExport.exporting"); + return t("databaseExport.currentTable", { + table: p.currentObject, + current: p.objectIndex, + total: p.totalObjects, + }); +}); + const skipConnectionWatch = ref(false); watch(connectionId, (id) => { @@ -732,22 +788,18 @@ watch(
{{ t("databaseExport.currentDatabase", { current: batchDatabaseIndex, total: batchDatabaseTotal }) }}
-
-
- {{ - t("databaseExport.currentTable", { - table: exportProgress.currentObject, - current: exportProgress.objectIndex, - total: exportProgress.totalObjects, - }) - }} +
+
+ + {{ progressStatusText }}
-
+
+
-
+
{{ exportAllDatabases ? t("databaseExport.allRowsExported", { count: exportProgress.rowsExported.toLocaleString() }) : t("databaseExport.rowsExported", { current: exportProgress.objectIndex, total: exportProgress.totalObjects, count: exportProgress.rowsExported.toLocaleString() }) }}
@@ -793,3 +845,22 @@ watch( + + diff --git a/apps/desktop/src/composables/useExportTracker.ts b/apps/desktop/src/composables/useExportTracker.ts index 4fa09698f..fb4ba0f6c 100644 --- a/apps/desktop/src/composables/useExportTracker.ts +++ b/apps/desktop/src/composables/useExportTracker.ts @@ -381,7 +381,10 @@ export function useExportTracker() { function updateDatabaseExportTask(exportId: string, progress: api.ExportProgress) { const task = taskMap.get(exportId); if (!task) return; - task.tableName = progress.currentObject || task.tableName; + // Keep the database label during metadata prefetch; only follow object names while writing. + if (!progress.preparing && progress.currentObject) { + task.tableName = progress.currentObject; + } task.rowsExported = progress.rowsExported; task.totalRows = progress.totalRows; task.status = normalizeExportStatus(progress.status); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index f90c72802..ca969391c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -4986,6 +4986,8 @@ export default { export: "Export", exportAllDatabases: "Export All Databases", exporting: "Exporting...", + preparing: "Preparing export (reading tables and metadata)...", + preparingObject: "Preparing: {object}", selectExportDirectory: "Select export directory", currentDatabase: "Database {current}/{total}", currentTable: "Current: {table} ({current}/{total})", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d4b63ab3e..2a1077a13 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -4753,6 +4753,8 @@ export default withEnglishFallback({ export: "Exportar", exportAllDatabases: "Exportar todas las bases de datos", exporting: "Exportando...", + preparing: "Preparando exportación (leyendo tablas y metadatos)...", + preparingObject: "Preparando: {object}", selectExportDirectory: "Seleccionar directorio de exportación", currentDatabase: "Base de datos {current}/{total}", currentTable: "Actual: {table} ({current}/{total})", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 896e59a45..574045ee9 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -4752,6 +4752,8 @@ export default withEnglishFallback({ export: "Esporta", exportAllDatabases: "Esporta tutti i database", exporting: "Esportazione in corso...", + preparing: "Preparazione esportazione (lettura tabelle e metadati)...", + preparingObject: "Preparazione: {object}", selectExportDirectory: "Seleziona directory di esportazione", currentDatabase: "Database {current}/{total}", currentTable: "Corrente: {table} ({current}/{total})", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 06000ed92..a730ed8e5 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -4753,6 +4753,8 @@ export default withEnglishFallback({ export: "エクスポート", exportAllDatabases: "すべてのデータベースをエクスポート", exporting: "エクスポート中...", + preparing: "エクスポートを準備中(テーブルとメタデータを読み取り中)...", + preparingObject: "準備中: {object}", selectExportDirectory: "エクスポート先ディレクトリを選択", currentDatabase: "データベース {current}/{total}", currentTable: "現在: {table} ({current}/{total})", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1e8981088..9a27824bc 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -4755,6 +4755,8 @@ export default withEnglishFallback({ export: "Exportar", exportAllDatabases: "Exportar todos os bancos de dados", exporting: "Exportando...", + preparing: "Preparando exportação (lendo tabelas e metadados)...", + preparingObject: "Preparando: {object}", selectExportDirectory: "Selecionar diretório de exportação", currentDatabase: "Banco de dados {current}/{total}", currentTable: "Atual: {table} ({current}/{total})", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 0e60625be..1ef31f1dd 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -4985,6 +4985,8 @@ export default withEnglishFallback({ export: "导出", exportAllDatabases: "导出全部数据库", exporting: "正在导出...", + preparing: "正在准备导出(读取表与元数据)...", + preparingObject: "正在准备: {object}", selectExportDirectory: "选择导出目录", currentDatabase: "数据库 {current}/{total}", currentTable: "当前: {table} ({current}/{total})", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9b8883eb5..523676181 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4422,6 +4422,8 @@ export default withEnglishFallback({ export: "匯出", exportAllDatabases: "匯出全部資料庫", exporting: "正在匯出……", + preparing: "正在準備匯出(讀取資料表與中繼資料)...", + preparingObject: "正在準備: {object}", selectExportDirectory: "選擇匯出目錄", currentDatabase: "資料庫 {current}/{total}", currentTable: "目前: {table} ({current}/{total})", diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 4da46fa26..f0d25ed3d 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -2851,6 +2851,8 @@ export interface ExportProgress { totalRows: number | null; status: "Running" | "Done" | "Error" | "Cancelled"; error: string | null; + /** True while listing schema / prefetching metadata before objects are written. */ + preparing?: boolean; } // --- Table Export --- diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 4858e5cb3..dadd48fee 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -165,6 +165,9 @@ pub struct ExportProgress { pub total_rows: Option, pub status: ExportStatus, pub error: Option, + /// True while listing schema / prefetching table metadata — before objects are written. + #[serde(default)] + pub preparing: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1294,11 +1297,36 @@ fn write_database_export_rows( } } +fn emit_database_export_running( + on_progress: &(impl Fn(ExportProgress) + Sync), + export_id: &str, + current_object: impl Into, + object_index: usize, + total_objects: usize, + rows_exported: u64, + preparing: bool, +) { + on_progress(ExportProgress { + export_id: export_id.to_string(), + current_object: current_object.into(), + object_index, + total_objects, + rows_exported, + total_rows: None, + status: ExportStatus::Running, + error: None, + preparing, + }); +} + pub async fn export_database_sql_core( state: &crate::connection::AppState, request: &DatabaseExportRequest, on_progress: impl Fn(ExportProgress) + Sync, ) -> Result<(), String> { + // Emit immediately so the UI is never blank while we list schema metadata. + emit_database_export_running(&on_progress, &request.export_id, "", 0, 0, 0, true); + // 1. Get database type let db_type = state .configs @@ -1493,53 +1521,15 @@ pub async fn export_database_sql_core( let mut object_index: usize = 0; let mut total_rows_exported = 0_u64; + // total_objects is known later for the write phase; preparing updates stay + // presence-only so the UI does not show a counter that later resets. + emit_database_export_running(&on_progress, &request.export_id, "", 0, 0, 0, true); - // Export tables let batch_size = if request.batch_size == 0 { 1000 } else { request.batch_size }; - for extension in &postgres_extensions { - if is_export_cancelled(&request.export_id).await { - return Err("Export cancelled".to_string()); - } - on_progress(ExportProgress { - export_id: request.export_id.clone(), - current_object: extension.name.clone(), - object_index, - total_objects, - rows_exported: total_rows_exported, - total_rows: None, - status: ExportStatus::Running, - error: None, - }); - writeln!(file, "{}\n", generate_postgres_extension_ddl(extension)) - .map_err(|e| format!("Failed to write file: {e}"))?; - object_index += 1; - } - - for sequence in postgres_sequences.iter().filter(|sequence| sequence.owner_table.is_none()) { - if is_export_cancelled(&request.export_id).await { - return Err("Export cancelled".to_string()); - } - - on_progress(ExportProgress { - export_id: request.export_id.clone(), - current_object: sequence.name.clone(), - object_index, - total_objects, - rows_exported: total_rows_exported, - total_rows: None, - status: ExportStatus::Running, - error: None, - }); - - writeln!(file, "{};\n", generate_postgres_sequence_create_ddl(sequence, &request.schema)) - .map_err(|e| format!("Failed to write file: {e}"))?; - object_index += 1; - } - // 预取各表的 DDL 与列元数据:逐表串行往返在多表数据库上是整库导出耗时的 // 主要来源(每表 1-2 次网络往返 × 表数)。有界并发预取后,下方写出循环仍按 - // 原顺序消费,文件内容与逐表查询完全一致。 + // 原顺序消费,文件内容与逐表查询完全一致。放在写出循环之前,避免准备态与导出态来回跳。 struct PrefetchedTableMetadata { ddl: Option>, columns: Option, String>>, @@ -1611,6 +1601,18 @@ pub async fn export_database_sql_core( .buffer_unordered(EXPORT_METADATA_PREFETCH_CONCURRENCY); while let Some((index, metadata)) = prefetch_stream.next().await { prefetched_table_metadata[index] = Some(metadata); + if let Some(table_info) = tables.get(index) { + // Presence-only updates: no prepare counter that later resets to 0/N. + emit_database_export_running( + &on_progress, + &request.export_id, + table_info.name.clone(), + 0, + 0, + total_rows_exported, + true, + ); + } // 取消后不再调度新的预取任务(已在途的任务随 stream 释放而中止), // 写出循环入口的取消检查负责最终收尾 if is_export_cancelled_now(&request.export_id) { @@ -1619,6 +1621,44 @@ pub async fn export_database_sql_core( } } + for extension in &postgres_extensions { + if is_export_cancelled(&request.export_id).await { + return Err("Export cancelled".to_string()); + } + emit_database_export_running( + &on_progress, + &request.export_id, + extension.name.clone(), + object_index, + total_objects, + total_rows_exported, + false, + ); + writeln!(file, "{}\n", generate_postgres_extension_ddl(extension)) + .map_err(|e| format!("Failed to write file: {e}"))?; + object_index += 1; + } + + for sequence in postgres_sequences.iter().filter(|sequence| sequence.owner_table.is_none()) { + if is_export_cancelled(&request.export_id).await { + return Err("Export cancelled".to_string()); + } + + emit_database_export_running( + &on_progress, + &request.export_id, + sequence.name.clone(), + object_index, + total_objects, + total_rows_exported, + false, + ); + + writeln!(file, "{};\n", generate_postgres_sequence_create_ddl(sequence, &request.schema)) + .map_err(|e| format!("Failed to write file: {e}"))?; + object_index += 1; + } + for (table_index, table_info) in tables.iter().enumerate().filter(|_| exports_database_tables(request)) { // Check cancellation if is_export_cancelled(&request.export_id).await { @@ -1631,6 +1671,7 @@ pub async fn export_database_sql_core( total_rows: None, status: ExportStatus::Cancelled, error: None, + preparing: false, }); return Ok(()); } @@ -1647,6 +1688,7 @@ pub async fn export_database_sql_core( total_rows: None, status: ExportStatus::Running, error: None, + preparing: false, }); // Export structure @@ -1668,6 +1710,7 @@ pub async fn export_database_sql_core( total_rows: None, status: ExportStatus::Running, error: None, + preparing: false, }); writeln!(file, "{};\n", generate_postgres_sequence_create_ddl(sequence, &request.schema)) @@ -1779,6 +1822,7 @@ pub async fn export_database_sql_core( total_rows: None, status: ExportStatus::Running, error: None, + preparing: false, }); Ok(()) }, @@ -1816,6 +1860,7 @@ pub async fn export_database_sql_core( total_rows, status: ExportStatus::Cancelled, error: None, + preparing: false, }); return Ok(()); } @@ -1864,6 +1909,7 @@ pub async fn export_database_sql_core( total_rows, status: ExportStatus::Running, error: None, + preparing: false, }); if row_count < batch_size { break; @@ -1907,6 +1953,7 @@ pub async fn export_database_sql_core( total_rows: None, status: ExportStatus::Running, error: None, + preparing: false, }); match crate::schema::get_object_source_core( @@ -1958,6 +2005,7 @@ pub async fn export_database_sql_core( total_rows: None, status: ExportStatus::Running, error: None, + preparing: false, }); match crate::schema::get_object_source_core( @@ -2013,6 +2061,7 @@ pub async fn export_database_sql_core( total_rows: None, status: ExportStatus::Running, error: None, + preparing: false, }); match crate::schema::get_object_source_core( @@ -2067,6 +2116,7 @@ pub async fn export_database_sql_core( total_rows: None, status: ExportStatus::Done, error: None, + preparing: false, }); Ok(()) diff --git a/crates/dbx-web/src/routes/database_export.rs b/crates/dbx-web/src/routes/database_export.rs index c33cbc252..523f6c50f 100644 --- a/crates/dbx-web/src/routes/database_export.rs +++ b/crates/dbx-web/src/routes/database_export.rs @@ -94,6 +94,7 @@ pub async fn start_database_export( total_rows: None, status: ExportStatus::Error, error: Some(e.clone()), + preparing: false, }; if let Ok(json) = serde_json::to_string(&progress) { let _ = tx.send(json); diff --git a/src-tauri/src/commands/database_export.rs b/src-tauri/src/commands/database_export.rs index 048c4ddbc..8149e3079 100644 --- a/src-tauri/src/commands/database_export.rs +++ b/src-tauri/src/commands/database_export.rs @@ -49,6 +49,7 @@ pub async fn export_database_sql( total_rows: None, status: ExportStatus::Error, error: Some(e), + preparing: false, }, ); }