perf(export): 优化导出ui体验和性能

* docs(getting-started): set brew upgrade to use official homebrew cask

* fix(mysql): make admin show handling dialect aware

* fix(data-compare): create missing target tables during sync

* feat(sql): suggest join conditions from foreign keys

* fix(sidebar): use filtered order for range selection

* fix(tabs): close connection tabs on disconnect

* fix(sqlserver): execute cte queries without wrapping

* fix(opengauss): open table structure editor

* perf(export): 优化导出ui体验和性能

* perf(export): 优化导出ui体验和性能

* fix(export): normalize status casing and fix web route issues

- Add missing table export web routes (POST /export/table, SSE progress, cancel)
- Fix tauri table export Promise to wait for terminal events
- Change cancel to return Ok(()) so it doesn't get overwritten as Error
- Unify ExportStatus to PascalCase across all frontend components
- Fix http.ts table export POST body wrapping
- Fix duplicate pub mod in dbx-web routes

* fix(export): return export file for browser download in web mode

Write table export output to temp dir, register a download endpoint,
and trigger browser download from the frontend when export completes.

* fix(export): avoid table export progress races

---------

Co-authored-by: Francesco Lucarelli <francesco06lucarelli@gmail.com>
Co-authored-by: t8y2 <1156263951@qq.com>
Co-authored-by: dqn <someonegdeng@qq.com>
This commit is contained in:
DengQingNian
2026-06-03 10:39:11 +08:00
committed by GitHub
co-authored by Francesco Lucarelli t8y2 dqn
parent bed72d2132
commit bea3b45fd8
22 changed files with 1282 additions and 159 deletions
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2, XCircle, AlertCircle, X } from "lucide-vue-next";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
title: string;
tableName: string;
format: string;
rowsExported: number;
totalRows: number | null;
status: string;
errorMessage: string | null;
disableCancel?: boolean;
}>();
const emit = defineEmits<{
cancel: [];
"update:open": [value: boolean];
}>();
const isActive = computed(() => props.status === "Running" || props.status === "Writing");
const isFinished = computed(() => props.status === "Done" || props.status === "Error" || props.status === "Cancelled");
const progressPercent = computed(() => {
if (!props.totalRows || props.totalRows <= 0) return 0;
return Math.min(100, Math.round((props.rowsExported / props.totalRows) * 100));
});
const rowsText = computed(() => {
if (props.totalRows) {
return t("exportProgress.rowsCount", {
exported: props.rowsExported.toLocaleString(),
total: props.totalRows.toLocaleString(),
});
}
return t("exportProgress.rowsExported", { count: props.rowsExported.toLocaleString() });
});
</script>
<template>
<Dialog
:open="open"
@update:open="
(v: boolean) => {
if (!isActive) emit('update:open', v);
}
"
>
<DialogContent class="sm:max-w-md" :class="{ 'pointer-events-none': isActive }" @interact-outside.prevent>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
</DialogHeader>
<div class="py-4 space-y-4">
<!-- Table name and format info -->
<div class="text-sm text-muted-foreground">{{ tableName }} (.{{ format }})</div>
<!-- Progress bar -->
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
v-if="status === 'Running' || status === 'Writing'"
class="h-full bg-primary rounded-full transition-all duration-300"
:class="{ 'animate-pulse': !totalRows }"
:style="{ width: totalRows ? `${progressPercent}%` : '50%' }"
/>
<div v-else-if="status === 'Done'" class="h-full bg-green-500 rounded-full" style="width: 100%" />
</div>
<!-- Status message -->
<div class="flex items-center gap-2 text-sm">
<template v-if="status === 'Running'">
<Loader2 class="h-4 w-4 animate-spin text-primary" />
<span>{{ t("exportProgress.fetching") }}</span>
</template>
<template v-else-if="status === 'Writing'">
<Loader2 class="h-4 w-4 animate-spin text-primary" />
<span>{{ t("exportProgress.writing") }}</span>
</template>
<template v-else-if="status === 'Done'">
<CheckCircle2 class="h-4 w-4 text-green-500" />
<span class="text-green-700 dark:text-green-400">{{ t("exportProgress.done") }}</span>
</template>
<template v-else-if="status === 'Error'">
<XCircle class="h-4 w-4 text-destructive" />
<span class="text-destructive">{{ errorMessage || t("exportProgress.error") }}</span>
</template>
<template v-else-if="status === 'Cancelled'">
<AlertCircle class="h-4 w-4 text-yellow-500" />
<span class="text-yellow-700 dark:text-yellow-400">{{ t("exportProgress.cancelled") }}</span>
</template>
</div>
<!-- Row count -->
<div class="text-xs text-muted-foreground tabular-nums">
{{ rowsText }}
</div>
</div>
<DialogFooter>
<template v-if="isActive">
<Button variant="outline" size="sm" :disabled="disableCancel" @click="emit('cancel')">
<X class="h-3.5 w-3.5 mr-1" />
{{ t("exportProgress.cancel") }}
</Button>
</template>
<template v-else-if="isFinished">
<Button variant="outline" size="sm" @click="emit('update:open', false)">
{{ t("exportProgress.close") }}
</Button>
</template>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -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<ContextMenuItem[]>(() => {
@confirm="confirmDeleteRow"
/>
<ImagePreviewDialog v-model:open="imagePreviewOpen" :src="imagePreviewSrc" :title="imagePreviewTitle" />
<ExportProgressDialog v-model:open="exportProgressDialog" v-bind="exportProgressState" disable-cancel />
</div>
</template>
@@ -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<number | null>(null);
const exportProgressStatus = ref<string>("");
const exportProgressError = ref<string | null>(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[] {
</DialogFooter>
</DialogContent>
</Dialog>
<ExportProgressDialog
v-model:open="showExportProgress"
:title="t('grid.exporting')"
:table-name="exportProgressTableName"
:format="exportProgressFormat"
:rows-exported="exportProgressRows"
:total-rows="exportProgressTotal"
:status="exportProgressStatus"
:error-message="exportProgressError"
@cancel="cancelExport"
/>
</template>
<style scoped>
+138 -85
View File
@@ -90,7 +90,7 @@ import {
} from "@/lib/treeNodeClick";
import { formatSqlInsert } from "@/lib/exportFormats";
import { fetchTableDataForExport } from "@/lib/tableDataExport";
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { generateDatabaseExportId } from "@/lib/databaseExport";
import {
buildCreateDatabaseSql,
buildDuckDbAttachDatabaseSql,
@@ -127,6 +127,7 @@ import {
} from "@/lib/sidebarTreeSelection";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { copyToClipboard } from "@/lib/clipboard";
import { formatShortcut } from "@/lib/shortcutRegistry";
@@ -161,6 +162,26 @@ type StructureCopyFormat = "tsv" | "markdown";
type DuplicateStructureSource = TreeNode & { connectionId: string; database: string };
const { getDatabaseOptions } = useDatabaseOptions();
const showVisibleDatabasesDialog = ref(false);
const exportProgressDialogOpen = ref(false);
const exportProgress = ref<{
title: string;
tableName: string;
format: string;
rowsExported: number;
totalRows: number | null;
status: string;
errorMessage: string | null;
}>({
title: "",
tableName: "",
format: "",
rowsExported: 0,
totalRows: null,
status: "",
errorMessage: null,
});
const exportCancelled = ref(false);
const currentExportId = ref("");
const props = defineProps<{
node: TreeNode;
@@ -752,45 +773,38 @@ async function openData() {
});
queryStore.updateSql(tabId, sql);
queryStore.setTableMeta(tabId, {
schema: node.schema,
tableName: node.label,
columns: [],
primaryKeys: [],
});
console.info("[DBX][openData:get-columns:start]", {
traceId,
database: node.database,
schema: querySchema,
table: node.label,
elapsed: elapsed(),
});
const columnsPromise = api.getColumns(node.connectionId, node.database, querySchema, node.label);
const loadTableMeta = async () => {
try {
console.info("[DBX][openData:get-columns:start]", {
traceId,
database: node.database,
schema: querySchema,
table: node.label,
elapsed: elapsed(),
});
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
console.info("[DBX][openData:get-columns:done]", {
traceId,
columnCount: columns.length,
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
elapsed: elapsed(),
});
const pks = editablePrimaryKeys(config.db_type, columns);
queryStore.setTableMeta(tabId, {
schema: node.schema,
tableName: node.label,
columns,
primaryKeys: pks,
});
} catch (error) {
console.warn("[DBX][openData:get-columns:error]", { traceId, elapsed: elapsed(), error });
}
};
console.info("[DBX][openData:execute:start]", { traceId, tabId, elapsed: elapsed() });
const dataPromise = queryStore.executeTabSql(tabId, sql);
const [columnsResult, dataResult] = await Promise.allSettled([columnsPromise, dataPromise]);
if (columnsResult.status === "fulfilled") {
const columns = columnsResult.value;
console.info("[DBX][openData:get-columns:done]", {
traceId,
columnCount: columns.length,
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
elapsed: elapsed(),
});
const pks = editablePrimaryKeys(config.db_type, columns);
queryStore.setTableMeta(tabId, {
schema: node.schema,
tableName: node.label,
columns,
primaryKeys: pks,
});
} else {
console.warn("[DBX][openData:get-columns:error]", { traceId, elapsed: elapsed(), error: columnsResult.reason });
}
if (dataResult.status === "rejected") throw dataResult.reason;
await queryStore.executeTabSql(tabId, sql);
console.info("[DBX][openData:execute:done]", { traceId, tabId, elapsed: elapsed() });
void loadTableMeta();
} catch (e: any) {
console.error("[DBX][openData:error]", { traceId, elapsed: elapsed(), error: e });
queryStore.setErrorResult(tabId, e);
@@ -2042,7 +2056,7 @@ async function saveStructurePreview() {
}
}
async function exportData(format: "csv" | "json" | "sql") {
async function exportDataLegacy(format: "csv" | "json" | "sql") {
const node = props.node;
if (!node.connectionId || !node.database) return;
const connectionId = node.connectionId;
@@ -2058,36 +2072,12 @@ async function exportData(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: `${node.label}.csv`,
filters: [{ name: "CSV", extensions: ["csv"] }],
});
if (!outputPath) return;
await api.exportTableDataCsv({
filePath: outputPath as string,
connectionId,
database,
schema: node.schema,
tableName: node.label,
columns: queryColumns,
timeoutSecs: queryTimeoutSecsForConnection(config),
});
toast(t("grid.exported"));
return;
}
const result = await fetchTableDataForExport({
databaseType: config.db_type,
schema: node.schema,
tableName: node.label,
columns: queryColumns,
executePage: (sql) =>
api.executeQuery(connectionId, database, sql, undefined, undefined, {
timeoutSecs: queryTimeoutSecsForConnection(config),
}),
executePage: (sql) => api.executeQuery(connectionId, database, sql),
});
if (format === "csv") {
@@ -2136,7 +2126,15 @@ async function exportData(format: "csv" | "json" | "sql") {
}
}
async function exportDataXlsx() {
async function exportData(format: "csv" | "json" | "sql") {
if (format !== "csv") {
await exportDataLegacy(format);
return;
}
await exportTableData("csv");
}
async function exportTableData(format: "csv" | "xlsx") {
const node = props.node;
if (!node.connectionId || !node.database) return;
const connectionId = node.connectionId;
@@ -2146,40 +2144,93 @@ async function exportDataXlsx() {
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 result = await fetchTableDataForExport({
databaseType: config.db_type,
schema: node.schema,
tableName: node.label,
columns: queryColumns,
executePage: (sql) =>
api.executeQuery(connectionId, database, sql, undefined, undefined, {
timeoutSecs: queryTimeoutSecsForConnection(config),
}),
});
let outputPath = `${node.label}.xlsx`;
// Step 1: Open save dialog FIRST
let outputPath = `${node.label}.${format}`;
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
defaultPath: outputPath,
filters: [{ name: "Excel", extensions: ["xlsx"] }],
filters: [{ name: format === "csv" ? "CSV" : "Excel", extensions: [format] }],
});
if (!path) return;
outputPath = path as string;
}
await api.exportQueryResultXlsx(outputPath, node.label, result.columns, result.rows);
toast(t("grid.exported"));
// Step 2: Prepare progress state and open dialog
const exportId = generateDatabaseExportId();
currentExportId.value = exportId;
exportCancelled.value = false;
exportProgress.value = {
title: t("exportProgress.title"),
tableName: node.label,
format,
rowsExported: 0,
totalRows: null,
status: "Running",
errorMessage: null,
};
exportProgressDialogOpen.value = true;
// Step 3: Get query columns for neo4j
const queryColumns =
config.db_type === "neo4j"
? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((c) => c.name)
: undefined;
// Step 4: Start streaming export
const request: api.TableExportRequest = {
exportId,
connectionId,
database,
schema: node.schema || undefined,
tableName: node.label,
filePath: outputPath,
format,
columns: queryColumns,
};
await api.startTableExport(request, (progress) => {
exportProgress.value = {
...exportProgress.value,
rowsExported: progress.rowsExported,
totalRows: progress.totalRows,
status: progress.status,
errorMessage: progress.errorMessage || null,
};
if (progress.status === "Done") {
toast(t("grid.exported"));
} else if (progress.status === "Error") {
toast(t("grid.exportFailed", { message: progress.errorMessage || "" }), 5000);
}
});
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
if (!exportCancelled.value) {
exportProgress.value = {
...exportProgress.value,
status: "Error",
errorMessage: e?.message || String(e),
};
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
}
}
async function cancelExport() {
exportCancelled.value = true;
if (currentExportId.value) {
try {
await api.cancelTableExport(currentExportId.value);
} catch {
/* ignore */
}
}
}
async function exportDataXlsx() {
await exportTableData("xlsx");
}
function editConnection() {
if (props.node.connectionId) {
connectionStore.startEditing(props.node.connectionId);
@@ -3658,6 +3709,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
:confirm-label="t('contextMenu.dropSchema')"
@confirm="confirmDropSchema"
/>
<ExportProgressDialog v-model:open="exportProgressDialogOpen" v-bind="exportProgress" @cancel="cancelExport" />
</template>
<style>
@@ -46,6 +46,16 @@ export interface UseDataGridExportOptions {
selectedRowIds: Ref<Set<number>> | ComputedRef<Set<number>>;
hasRowSelection: ComputedRef<boolean>;
fullExportResult?: () => Promise<QueryResult | undefined>;
exportProgressDialog?: Ref<boolean>;
exportProgressState?: Ref<{
title: string;
tableName: string;
format: string;
rowsExported: number;
totalRows: number | null;
status: string;
errorMessage: string | null;
}>;
}
interface CopyStatementCache {
@@ -93,6 +103,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
selectedRowIds,
hasRowSelection,
fullExportResult,
exportProgressDialog,
exportProgressState,
} = options;
async function copyText(text: string) {
@@ -441,8 +453,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function exportCsv(rowIds?: number[]) {
await runExclusiveExport(async () => {
try {
const needsFullExport = !rowIds?.length && !!fullExportResult;
if (needsFullExport && exportProgressDialog && exportProgressState) {
exportProgressState.value = {
title: t("exportProgress.title"),
tableName: tableMeta.value?.tableName || "",
format: "csv",
rowsExported: 0,
totalRows: null,
status: "Running",
errorMessage: null,
};
exportProgressDialog.value = true;
}
const result = await resultToExport(rowIds);
const rows = result.rows.map((row) => row.map((c) => displayCellValue(c)));
if (needsFullExport && exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
status: "Writing",
rowsExported: result.rows.length,
};
}
let outputPath = "export.csv";
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
@@ -450,12 +482,30 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
defaultPath: outputPath,
filters: [{ name: "CSV", extensions: ["csv"] }],
});
if (!path) return;
if (!path) {
if (exportProgressDialog) exportProgressDialog.value = false;
return;
}
outputPath = path as string;
}
await api.exportQueryResultCsv(outputPath, result.columns, rows);
if (needsFullExport && exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
status: "Done",
rowsExported: result.rows.length,
totalRows: result.rows.length,
};
}
toast(t("grid.exported"));
} catch (e: any) {
if (exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
status: "Error",
errorMessage: e?.message || String(e),
};
}
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
});
@@ -518,15 +568,50 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (!path) return;
outputPath = path as string;
}
const needsFullExport = !rowIds?.length && !!fullExportResult;
if (needsFullExport && exportProgressDialog && exportProgressState) {
exportProgressState.value = {
title: t("exportProgress.title"),
tableName: tableMeta.value?.tableName || "",
format: "xlsx",
rowsExported: 0,
totalRows: null,
status: "Running",
errorMessage: null,
};
exportProgressDialog.value = true;
}
const result = await resultToExport(rowIds);
if (needsFullExport && exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
status: "Writing",
rowsExported: result.rows.length,
};
}
await api.exportQueryResultXlsx(
outputPath,
tableMeta.value?.tableName || "Export",
result.columns,
result.rows,
);
if (needsFullExport && exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
status: "Done",
rowsExported: result.rows.length,
totalRows: result.rows.length,
};
}
toast(t("grid.exported"));
} catch (e: any) {
if (exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
status: "Error",
errorMessage: e?.message || String(e),
};
}
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
});
+12
View File
@@ -654,6 +654,18 @@ export default {
sortUnsupported: "This SQL does not support full-result sorting. Try again with a single SELECT query.",
truncatedHint: "Results truncated to {count} rows. Use the footer pagination or adjust rows per page.",
},
exportProgress: {
title: "Exporting Table Data",
fetching: "Fetching data from database...",
writing: "Writing to file...",
done: "Export completed!",
error: "Export failed",
cancelled: "Export cancelled.",
rowsCount: "{exported} / {total} rows",
rowsExported: "{count} rows exported",
cancel: "Cancel",
close: "Close",
},
welcome: {
title: "Database Workspace",
subtitle: "Choose a connection on the left to browse schema, or open a query tab directly.",
+12
View File
@@ -587,6 +587,18 @@ export default {
sortUnsupported: "Este SQL no admite ordenamiento del resultado completo. Intenta con una consulta SELECT simple.",
truncatedHint: "Los resultados están limitados a 10.000 filas. Usa LIMIT/OFFSET en tu consulta para paginar.",
},
exportProgress: {
title: "Exportando datos de la tabla",
fetching: "Obteniendo datos de la base de datos...",
writing: "Escribiendo en el archivo...",
done: "¡Exportación completada!",
error: "Error de exportación",
cancelled: "Exportación cancelada.",
rowsCount: "{exported} / {total} filas",
rowsExported: "{count} filas exportadas",
cancel: "Cancelar",
close: "Cerrar",
},
welcome: {
title: "Espacio de trabajo de base de datos",
subtitle:
+12
View File
@@ -644,6 +644,18 @@ export default {
sortUnsupported: "当前 SQL 不支持全量排序,请改为单条 SELECT 查询后再尝试。",
truncatedHint: "结果已截断,仅显示前 {count} 行。可通过底部分页继续加载,或调整每页行数。",
},
exportProgress: {
title: "导出表数据",
fetching: "正在从数据库获取数据...",
writing: "正在写入文件...",
done: "导出完成!",
error: "导出失败",
cancelled: "导出已取消。",
rowsCount: "已导出 {exported} / {total} 行",
rowsExported: "已导出 {count} 行",
cancel: "取消",
close: "关闭",
},
welcome: {
title: "数据库工作台",
subtitle:
+5
View File
@@ -188,6 +188,8 @@ export const exportTableDataCsv = forward("exportTableDataCsv");
export const exportQueryResultXlsx = forward("exportQueryResultXlsx");
export const exportQueryResultJson = forward("exportQueryResultJson");
export const exportQueryResultMarkdown = forward("exportQueryResultMarkdown");
export const startTableExport = forward("startTableExport");
export const cancelTableExport = forward("cancelTableExport");
// Redis
export const redisListDatabases = forward("redisListDatabases");
@@ -286,4 +288,7 @@ export type {
TableImportProgress,
DatabaseExportRequest,
ExportProgress,
TableExportProgress,
TableExportStatus,
TableExportRequest,
} from "./tauri";
+64
View File
@@ -49,6 +49,8 @@ import type {
TableImportProgress,
DatabaseExportRequest,
ExportProgress,
TableExportRequest,
TableExportProgress,
TableCsvExportOptions,
XlsxCellValue,
QueryPaginationExecutionPlanOptions,
@@ -1071,6 +1073,68 @@ export async function cancelDatabaseExport(exportId: string): Promise<void> {
await post("/api/export/database/cancel", { exportId });
}
// --- Table Export ---
export async function startTableExport(
request: TableExportRequest,
onProgress: (progress: TableExportProgress) => void,
): Promise<TableExportProgress> {
const { exportId } = request;
return new Promise((resolve, reject) => {
let started = false;
let settled = false;
const eventSource = new EventSource(`/api/export/table/progress/${exportId}`);
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
eventSource.close();
callback();
};
eventSource.onopen = () => {
if (started) return;
started = true;
post("/api/export/table", { request }).catch((error) => {
finish(() => reject(error));
});
};
eventSource.onmessage = (event) => {
const progress: TableExportProgress = JSON.parse(event.data);
onProgress(progress);
if (progress.status === "Done" || progress.status === "Error" || progress.status === "Cancelled") {
if (progress.status === "Error") {
finish(() => reject(new Error(progress.errorMessage || "Export failed")));
} else if (progress.status === "Done") {
// Trigger browser download
downloadTableExportFile(exportId, request.format);
finish(() => resolve(progress));
} else {
finish(() => resolve(progress));
}
}
};
eventSource.onerror = () => {
finish(() => reject(new Error("Export progress connection lost")));
};
});
}
function downloadTableExportFile(exportId: string, format: string): void {
const ext = format === "csv" ? "csv" : "xlsx";
const a = document.createElement("a");
a.href = `/api/export/table/download/${exportId}`;
a.download = `table_export_${exportId}.${ext}`;
a.click();
}
export async function cancelTableExport(exportId: string): Promise<void> {
return post("/api/export/table/cancel", { exportId });
}
export async function exportQueryResultCsv(
filePath: string,
columns: string[],
+71
View File
@@ -1479,6 +1479,20 @@ export interface ExportProgress {
error: string | null;
}
// --- Table Export ---
export type TableExportStatus = "Running" | "Writing" | "Done" | "Error" | "Cancelled";
export interface TableExportRequest {
exportId: string;
connectionId: string;
database: string;
schema?: string;
tableName: string;
filePath: string;
format: "csv" | "xlsx";
columns?: string[];
}
export interface TableCsvExportOptions {
filePath: string;
connectionId: string;
@@ -1490,6 +1504,63 @@ export interface TableCsvExportOptions {
timeoutSecs?: number;
}
export interface TableExportProgress {
exportId: string;
tableName: string;
rowsExported: number;
totalRows: number | null;
status: TableExportStatus;
errorMessage?: string;
}
export async function startTableExport(
request: TableExportRequest,
onProgress: (progress: TableExportProgress) => void,
): Promise<TableExportProgress> {
let unlisten: UnlistenFn | undefined;
let settled = false;
let resolveTerminal: (progress: TableExportProgress) => void = () => {};
let rejectTerminal: (error: unknown) => void = () => {};
const terminalProgress = new Promise<TableExportProgress>((resolve, reject) => {
resolveTerminal = resolve;
rejectTerminal = reject;
});
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
unlisten?.();
callback();
};
try {
unlisten = await listen<TableExportProgress>("table-export-progress", (event) => {
if (event.payload.exportId !== request.exportId) return;
onProgress(event.payload);
if (event.payload.status === "Done" || event.payload.status === "Error" || event.payload.status === "Cancelled") {
if (event.payload.status === "Error") {
finish(() => rejectTerminal(new Error(event.payload.errorMessage || "Export failed")));
} else {
finish(() => resolveTerminal(event.payload));
}
}
});
await invoke("start_table_export", { request });
return await terminalProgress;
} catch (error) {
if (!settled) {
settled = true;
unlisten?.();
}
throw error;
}
}
export async function cancelTableExport(exportId: string): Promise<void> {
return invoke("cancel_table_export", { exportId });
}
export async function exportDatabaseSql(
request: DatabaseExportRequest,
onProgress: (progress: ExportProgress) => void,
+2 -2
View File
@@ -27,11 +27,11 @@ pub struct TableCsvExportOptions {
pub timeout_secs: Option<u64>,
}
fn escape_csv(value: &str) -> String {
pub(crate) fn escape_csv(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
fn value_to_csv_text(value: &Value) -> String {
pub(crate) fn value_to_csv_text(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::Bool(v) => v.to_string(),
+3 -2
View File
@@ -45,6 +45,7 @@ pub struct ExportProgress {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ExportStatus {
Running,
Writing,
Done,
Error,
Cancelled,
@@ -337,7 +338,7 @@ pub async fn export_database_sql_core(
status: ExportStatus::Cancelled,
error: None,
});
return Err("Export cancelled".to_string());
return Ok(());
}
let table_name = &table_info.name;
@@ -432,7 +433,7 @@ pub async fn export_database_sql_core(
status: ExportStatus::Cancelled,
error: None,
});
return Err("Export cancelled".to_string());
return Ok(());
}
let sql = crate::transfer::pagination_sql(
+1
View File
@@ -36,6 +36,7 @@ pub mod sql_dialect;
pub mod sql_editability;
pub mod sql_file_import;
pub mod storage;
pub mod table_export;
pub mod table_import;
pub mod table_structure_sql;
pub mod text_export;
+422
View File
@@ -0,0 +1,422 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::Write;
use crate::connection::AppState;
use crate::csv_export::{escape_csv, format_csv, value_to_csv_text};
use crate::database_export::is_export_cancelled;
pub use crate::database_export::ExportStatus;
use crate::transfer::{count_sql, execute_on_pool, pagination_sql};
use crate::xlsx_export::{build_xlsx_workbook, XlsxWorksheetData};
const DEFAULT_BATCH_SIZE: usize = 500;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableExportRequest {
pub export_id: String,
pub connection_id: String,
pub database: String,
pub schema: Option<String>,
pub table_name: String,
pub file_path: String,
/// "csv" or "xlsx"
pub format: String,
#[serde(default)]
pub columns: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableExportProgress {
pub export_id: String,
pub table_name: String,
pub rows_exported: u64,
pub total_rows: Option<u64>,
pub status: ExportStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
}
/// Format rows as CSV text without a header row.
/// Used for streaming subsequent pagination batches.
fn format_csv_rows(rows: &[Vec<Value>]) -> String {
rows.iter()
.map(|row| row.iter().map(|cell| escape_csv(&value_to_csv_text(cell))).collect::<Vec<_>>().join(","))
.collect::<Vec<_>>()
.join("\n")
}
pub async fn export_table_data_core(
state: &AppState,
request: &TableExportRequest,
on_progress: impl Fn(TableExportProgress),
) -> Result<(), String> {
// 1. Get database type
let db_type = state
.configs
.read()
.await
.get(&request.connection_id)
.map(|c| c.db_type)
.ok_or_else(|| format!("Connection config not found: {}", request.connection_id))?;
// 2. Get pool
let pool_key = state.get_or_create_pool(&request.connection_id, Some(&request.database)).await?;
// 3. Get columns
let columns = crate::schema::get_columns_core(
state,
&request.connection_id,
&request.database,
request.schema.as_deref().unwrap_or(""),
&request.table_name,
)
.await?;
let col_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
// 4. Optionally filter to requested columns
let col_names = if let Some(requested_cols) = &request.columns {
if requested_cols.is_empty() {
col_names
} else {
let filtered: Vec<String> = requested_cols.iter().filter(|c| col_names.contains(c)).cloned().collect();
if filtered.is_empty() {
return Err("None of the requested columns exist in the table".to_string());
}
filtered
}
} else {
col_names
};
if col_names.is_empty() {
return Err("No columns found for table".to_string());
}
// 5. Get total row count for progress estimation
let count_query = count_sql(&request.table_name, request.schema.as_deref().unwrap_or(""), &db_type);
let total_rows = match execute_on_pool(state, &pool_key, &count_query).await {
Ok(result) => result.rows.first().and_then(|r| r.first()).and_then(|v| match v {
Value::Number(n) => n.as_u64(),
Value::String(s) => s.parse::<u64>().ok(),
_ => None,
}),
Err(_) => None,
};
// 6. Emit initial Running progress
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
rows_exported: 0,
total_rows,
status: ExportStatus::Running,
error_message: None,
});
// 7. Create output file
let mut file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create file: {e}"))?;
let mut rows_exported: u64 = 0;
let mut offset: u64 = 0;
let batch_size = DEFAULT_BATCH_SIZE;
match request.format.to_lowercase().as_str() {
"csv" => {
// Write UTF-8 BOM
file.write_all(b"\xEF\xBB\xBF").map_err(|e| format!("Failed to write BOM: {e}"))?;
let mut is_first_batch = true;
loop {
// Check cancellation between batches
if is_export_cancelled(&request.export_id).await {
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
rows_exported,
total_rows,
status: ExportStatus::Cancelled,
error_message: Some("Export cancelled".to_string()),
});
return Ok(());
}
let sql = pagination_sql(
&col_names,
&request.table_name,
request.schema.as_deref().unwrap_or(""),
&db_type,
offset,
batch_size,
);
let result = execute_on_pool(state, &pool_key, &sql).await?;
let row_count = result.rows.len();
if row_count == 0 {
break;
}
if is_first_batch {
// First batch: write header + rows via format_csv
let csv_content = format_csv(&col_names, &result.rows);
file.write_all(csv_content.as_bytes()).map_err(|e| format!("Failed to write CSV: {e}"))?;
is_first_batch = false;
} else {
// Subsequent batches: write rows only (prepend newline for separation)
let rows_csv = format_csv_rows(&result.rows);
if !rows_csv.is_empty() {
write!(file, "\n{rows_csv}").map_err(|e| format!("Failed to write CSV rows: {e}"))?;
}
}
rows_exported += row_count as u64;
offset += row_count as u64;
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
rows_exported,
total_rows,
status: ExportStatus::Running,
error_message: None,
});
if row_count < batch_size {
break;
}
}
}
"xlsx" => {
let mut all_rows: Vec<Vec<Value>> = Vec::new();
loop {
// Check cancellation between batches
if is_export_cancelled(&request.export_id).await {
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
rows_exported,
total_rows,
status: ExportStatus::Cancelled,
error_message: Some("Export cancelled".to_string()),
});
return Ok(());
}
let sql = pagination_sql(
&col_names,
&request.table_name,
request.schema.as_deref().unwrap_or(""),
&db_type,
offset,
batch_size,
);
let result = execute_on_pool(state, &pool_key, &sql).await?;
let row_count = result.rows.len();
if row_count == 0 {
break;
}
all_rows.extend(result.rows);
rows_exported += row_count as u64;
offset += row_count as u64;
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
rows_exported,
total_rows,
status: ExportStatus::Running,
error_message: None,
});
if row_count < batch_size {
break;
}
}
// Emit Writing progress before building XLSX
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
rows_exported,
total_rows,
status: ExportStatus::Writing,
error_message: None,
});
// Build XLSX workbook from accumulated rows
let workbook_data =
XlsxWorksheetData { sheet_name: Some(request.table_name.clone()), columns: col_names, rows: all_rows };
let xlsx_bytes = build_xlsx_workbook(&workbook_data)?;
file.write_all(&xlsx_bytes).map_err(|e| format!("Failed to write XLSX file: {e}"))?;
}
other => {
return Err(format!("Unsupported export format: {other}"));
}
}
// 8. Emit Done progress
on_progress(TableExportProgress {
export_id: request.export_id.clone(),
table_name: request.table_name.clone(),
rows_exported,
total_rows,
status: ExportStatus::Done,
error_message: None,
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database_export::{clear_export_cancelled, set_export_cancelled};
use serde_json::json;
// -----------------------------------------------------------------------
// Helper: check that two CSV strings are equivalent by splitting lines
// -----------------------------------------------------------------------
fn csv_lines_equal(actual: &str, expected: &str) -> bool {
let actual_lines: Vec<&str> = actual.lines().collect();
let expected_lines: Vec<&str> = expected.lines().collect();
actual_lines == expected_lines
}
// -----------------------------------------------------------------------
// format_csv_rows
// -----------------------------------------------------------------------
#[test]
fn formats_csv_rows_with_multiple_columns() {
let rows = vec![vec![json!(1), json!("Alice")], vec![json!(2), json!("Bob \"Builder\"")]];
let out = format_csv_rows(&rows);
assert!(csv_lines_equal(&out, "\"1\",\"Alice\"\n\"2\",\"Bob \"\"Builder\"\"\""));
}
#[test]
fn formats_csv_rows_with_null_values() {
let rows = vec![vec![json!(1), Value::Null, json!("active")], vec![json!(2), json!("some notes"), Value::Null]];
let out = format_csv_rows(&rows);
assert!(csv_lines_equal(&out, "\"1\",\"\",\"active\"\n\"2\",\"some notes\",\"\""));
}
#[test]
fn formats_csv_rows_with_boolean_and_number_values() {
let rows = vec![vec![json!(true), json!(3.14)], vec![json!(false), json!(-42)]];
let out = format_csv_rows(&rows);
assert!(csv_lines_equal(&out, "\"true\",\"3.14\"\n\"false\",\"-42\""));
}
#[test]
fn formats_csv_rows_returns_empty_string_for_empty_rows() {
let rows: Vec<Vec<Value>> = vec![];
let out = format_csv_rows(&rows);
assert_eq!(out, "");
}
#[test]
fn formats_csv_rows_single_row() {
let rows = vec![vec![json!("just"), json!("one")]];
let out = format_csv_rows(&rows);
assert_eq!(out, "\"just\",\"one\"");
}
#[test]
fn formats_csv_rows_escapes_embedded_commas_and_newlines() {
let rows = vec![vec![json!("hello,world"), json!("line1\nline2")]];
let out = format_csv_rows(&rows);
assert!(out.contains("\"hello,world\""));
assert!(out.contains("\"line1\nline2\""));
// CSV escaping wraps in quotes, so commas inside are safe
let fields: Vec<&str> = out.split(',').collect();
assert_eq!(fields.len(), 2);
}
// -----------------------------------------------------------------------
// Cancellation flow
// -----------------------------------------------------------------------
#[test]
fn cancellation_set_and_cleared_correctly() {
let export_id = "test-cancel-1";
assert!(!poll_is_cancelled(export_id));
block_on(set_export_cancelled(export_id));
assert!(poll_is_cancelled(export_id));
block_on(clear_export_cancelled(export_id));
assert!(!poll_is_cancelled(export_id));
}
#[test]
fn cancellation_is_id_scoped() {
let id_a = "cancel-scope-a";
let id_b = "cancel-scope-b";
block_on(set_export_cancelled(id_a));
assert!(poll_is_cancelled(id_a));
assert!(!poll_is_cancelled(id_b));
block_on(clear_export_cancelled(id_a));
}
// -----------------------------------------------------------------------
// XLSX workbook integration
// -----------------------------------------------------------------------
#[test]
fn builds_xlsx_workbook_with_table_export_data() {
let data = XlsxWorksheetData {
sheet_name: Some("employees".to_string()),
columns: vec!["id".to_string(), "name".to_string(), "salary".to_string()],
rows: vec![
vec![json!(1), json!("Alice"), json!(75000.50)],
vec![json!(2), json!("Bob"), json!(82000)],
vec![json!(3), Value::Null, json!(0)],
],
};
let workbook = build_xlsx_workbook(&data).expect("XLSX build should succeed");
let text = String::from_utf8_lossy(&workbook);
assert_eq!(workbook[0], 0x50, "Should be a ZIP (PK) archive");
assert_eq!(workbook[1], 0x4b);
assert!(text.contains("[Content_Types].xml"));
assert!(text.contains("xl/worksheets/sheet1.xml"));
assert!(text.contains("name=\"employees\""));
assert!(text.contains("<v>75000.5</v>"));
assert!(text.contains("Alice"));
}
// -----------------------------------------------------------------------
// CSV header + rows (format_csv) — basic integration check
// -----------------------------------------------------------------------
#[test]
fn format_csv_produces_header_and_rows() {
let out = format_csv(
&["col1".to_string(), "col2".to_string()],
&[vec![json!("a"), json!("b")], vec![json!("c"), json!("d")]],
);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3, "header + 2 data rows = 3 lines");
assert_eq!(lines[0], "\"col1\",\"col2\"");
assert_eq!(lines[1], "\"a\",\"b\"");
assert_eq!(lines[2], "\"c\",\"d\"");
}
// -----------------------------------------------------------------------
// Helpers for async cancellation in tests
// -----------------------------------------------------------------------
fn poll_is_cancelled(export_id: &str) -> bool {
block_on(is_export_cancelled(export_id))
}
fn block_on<F: std::future::Future>(future: F) -> F::Output {
tokio::runtime::Runtime::new().expect("create tokio runtime").block_on(future)
}
}
+6
View File
@@ -67,6 +67,7 @@ async fn main() {
sse_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
login_rate_limit: tokio::sync::Mutex::new(state::LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
});
// CORS
@@ -262,6 +263,11 @@ async fn main() {
.route("/export/database", post(routes::database_export::start_database_export))
.route("/export/database/progress/{exportId}", get(routes::database_export::database_export_progress))
.route("/export/database/cancel", post(routes::database_export::cancel_database_export))
// Table export
.route("/export/table", post(routes::table_export::start_table_export))
.route("/export/table/progress/{exportId}", get(routes::table_export::table_export_progress))
.route("/export/table/download/{exportId}", get(routes::table_export::table_export_download))
.route("/export/table/cancel", post(routes::table_export::cancel_table_export))
// SQL file
.route("/sql-file/preview", post(routes::sql_file::preview_sql_file))
.route("/sql-file/execute", post(routes::sql_file::execute_sql_file))
+1
View File
@@ -16,6 +16,7 @@ pub mod schema;
pub mod schema_cache;
pub mod schema_diff;
pub mod sql_file;
pub mod table_export;
pub mod table_import;
pub mod text_export;
pub mod transfer;
+152
View File
@@ -0,0 +1,152 @@
use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::response::{Response, Sse};
use axum::Json;
use dbx_core::table_export::{self, ExportStatus, TableExportProgress, TableExportRequest};
use futures::stream::Stream;
use serde::Deserialize;
use crate::error::AppError;
use crate::state::WebState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartExportRequest {
pub request: TableExportRequest,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelExportRequest {
pub export_id: String,
}
pub async fn start_table_export(
State(state): State<Arc<WebState>>,
Json(body): Json<StartExportRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let mut req = body.request;
let export_id = req.export_id.clone();
// Generate temp file path for web export output
let tmp_dir = state.data_dir.join("tmp");
std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?;
let ext = match req.format.as_str() {
"csv" => "csv",
"xlsx" => "xlsx",
_ => return Err(AppError(format!("Unsupported export format: {}", req.format))),
};
let tmp_file = tmp_dir.join(format!("table_export_{export_id}.{ext}"));
let file_path = tmp_file.to_string_lossy().to_string();
req.file_path = file_path.clone();
// Store export file mapping for download
state.export_files.write().await.insert(export_id.clone(), (file_path, req.format.clone()));
let tx = {
let mut channels = state.sse_channels.write().await;
channels.entry(export_id.clone()).or_insert_with(|| tokio::sync::broadcast::channel::<String>(256).0).clone()
};
let app = state.app.clone();
let state_clone = state.clone();
let cancelled = Arc::new(AtomicBool::new(false));
let cancelled_progress = cancelled.clone();
tokio::spawn(async move {
let result = table_export::export_table_data_core(&app, &req, |progress| {
if matches!(progress.status, ExportStatus::Cancelled) {
cancelled_progress.store(true, Ordering::SeqCst);
}
if let Ok(json) = serde_json::to_string(&progress) {
let _ = tx.send(json);
}
})
.await;
if let Err(e) = result {
let _ = tokio::fs::remove_file(&req.file_path).await;
state_clone.export_files.write().await.remove(&req.export_id);
let progress = TableExportProgress {
export_id: req.export_id.clone(),
table_name: req.table_name.clone(),
rows_exported: 0,
total_rows: None,
status: ExportStatus::Error,
error_message: Some(e),
};
if let Ok(json) = serde_json::to_string(&progress) {
let _ = tx.send(json);
}
} else if cancelled.load(Ordering::SeqCst) {
let _ = tokio::fs::remove_file(&req.file_path).await;
state_clone.export_files.write().await.remove(&req.export_id);
}
dbx_core::database_export::clear_export_cancelled(&req.export_id).await;
tokio::time::sleep(Duration::from_secs(5)).await;
state_clone.remove_sse_channel(&req.export_id).await;
});
Ok(Json(serde_json::json!({ "exportId": export_id })))
}
pub async fn table_export_progress(
State(state): State<Arc<WebState>>,
Path(export_id): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, AppError> {
let tx = {
let mut channels = state.sse_channels.write().await;
channels.entry(export_id).or_insert_with(|| tokio::sync::broadcast::channel::<String>(256).0).clone()
};
let rx = tx.subscribe();
Ok(crate::sse::sse_from_channel(rx))
}
pub async fn cancel_table_export(
State(_state): State<Arc<WebState>>,
Json(req): Json<CancelExportRequest>,
) -> Json<serde_json::Value> {
dbx_core::database_export::set_export_cancelled(&req.export_id).await;
Json(serde_json::json!({ "cancelled": true }))
}
pub async fn table_export_download(
State(state): State<Arc<WebState>>,
Path(export_id): Path<String>,
) -> Result<Response, AppError> {
let (file_path, format) = state
.export_files
.write()
.await
.remove(&export_id)
.ok_or_else(|| AppError("Export file not found".to_string()))?;
let data = tokio::fs::read(&file_path).await.map_err(|e| AppError(e.to_string()))?;
// Clean up temp file
let _ = tokio::fs::remove_file(&file_path).await;
let (content_type, file_ext) = match format.as_str() {
"csv" => ("text/csv; charset=utf-8", "csv"),
"xlsx" => ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"),
_ => return Err(AppError(format!("Unknown format: {format}"))),
};
let filename = format!("table_export_{export_id}.{file_ext}");
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""))
.body(Body::from(data))
.map_err(|e| AppError(e.to_string()))?)
}
+2
View File
@@ -18,6 +18,8 @@ pub struct WebState {
pub sse_channels: RwLock<HashMap<String, broadcast::Sender<String>>>,
pub sql_file_executions: RwLock<HashMap<String, CancellationToken>>,
pub login_rate_limit: Mutex<LoginRateLimit>,
/// Table export temp files: export_id -> (file_path, format)
pub export_files: RwLock<HashMap<String, (String, String)>>,
}
impl WebState {
+1
View File
@@ -25,6 +25,7 @@ pub mod schema_cache;
pub mod schema_diff;
pub mod sql_file;
pub mod system_fonts;
pub mod table_export;
pub mod table_import;
pub mod text_export;
pub mod transfer;
+50
View File
@@ -0,0 +1,50 @@
use std::sync::Arc;
use tauri::{AppHandle, Emitter, State};
use crate::commands::connection::AppState;
pub use dbx_core::table_export::{ExportStatus, TableExportProgress, TableExportRequest};
fn emit_progress(app: &AppHandle, progress: TableExportProgress) {
let _ = app.emit("table-export-progress", progress);
}
#[tauri::command]
pub async fn start_table_export(
app: AppHandle,
state: State<'_, Arc<AppState>>,
request: TableExportRequest,
) -> Result<(), String> {
let state = state.inner().clone();
let export_id = request.export_id.clone();
tokio::spawn(async move {
let result =
dbx_core::table_export::export_table_data_core(&state, &request, |progress| emit_progress(&app, progress))
.await;
if let Err(e) = result {
emit_progress(
&app,
TableExportProgress {
export_id: export_id.clone(),
table_name: String::new(),
rows_exported: 0,
total_rows: None,
status: ExportStatus::Error,
error_message: Some(e),
},
);
}
dbx_core::database_export::clear_export_cancelled(&export_id).await;
});
Ok(())
}
#[tauri::command]
pub async fn cancel_table_export(export_id: String) -> Result<(), String> {
dbx_core::database_export::set_export_cancelled(&export_id).await;
Ok(())
}
+2
View File
@@ -499,6 +499,8 @@ pub fn run() {
commands::transfer::cancel_transfer,
commands::database_export::export_database_sql,
commands::database_export::cancel_database_export,
commands::table_export::start_table_export,
commands::table_export::cancel_table_export,
commands::csv_export::export_query_result_csv,
commands::csv_export::export_table_data_csv,
commands::xlsx_export::export_query_result_xlsx,