dbx/apps/desktop/src/components/export/DatabaseExportDialog.vue

512 lines
18 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/api";
import type { ExportProgress } from "@/lib/api";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { generateDatabaseExportId } from "@/lib/databaseExport";
import { buildSelectedTablesPayload } from "@/lib/databaseExportSelection";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { useToast } from "@/composables/useToast";
import { Download, Square, CheckSquare, X } from "lucide-vue-next";
const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const store = useConnectionStore();
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
prefillTable?: string;
}>();
// Connection / Database / Schema selectors
const connectionId = ref("");
const database = ref("");
const databases = ref<string[]>([]);
const schema = ref("");
const schemas = ref<string[]>([]);
const loadingMeta = ref(false);
const tables = ref<string[]>([]);
const selectedTables = ref<string[]>([]);
const loadingTables = ref(false);
const tableError = ref<string | null>(null);
// Options
const includeStructure = ref(true);
const includeData = ref(true);
const includeObjects = ref(true);
const dropTableIfExists = ref(false);
// Export state
const isExporting = ref(false);
const exportProgress = ref<ExportProgress | null>(null);
const exportId = ref("");
const exportDone = ref(false);
const exportError = ref<string | null>(null);
const exportCancelled = ref(false);
const pendingPrefillTable = ref("");
const sqlConnections = computed(() =>
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch"].includes(c.db_type)),
);
const canExport = computed(
() =>
connectionId.value &&
database.value &&
schema.value &&
!loadingTables.value &&
!tableError.value &&
(tables.value.length === 0 || selectedTables.value.length > 0) &&
(includeStructure.value || includeData.value || includeObjects.value) &&
!isExporting.value,
);
const selectedTableSet = computed(() => new Set(selectedTables.value));
function connectionIconType(connId: string) {
const config = store.getConfig(connId);
return config?.driver_profile || config?.db_type || "mysql";
}
async function loadDatabases(connId: string) {
if (!connId) return;
loadingMeta.value = true;
try {
await store.ensureConnected(connId);
const dbs = await api.listDatabases(connId);
const names = dbs.map((d) => d.name);
databases.value = names;
database.value = names.length === 1 ? names[0] : "";
schemas.value = [];
schema.value = "";
tables.value = [];
selectedTables.value = [];
} catch {
databases.value = [];
} finally {
loadingMeta.value = false;
}
}
async function loadSchemas(preferredSchema = "") {
if (!connectionId.value || !database.value) return;
const config = store.getConfig(connectionId.value);
if (!isSchemaAware(config?.db_type)) {
schemas.value = [];
schema.value = database.value;
return;
}
const schemaList = await api.listSchemas(connectionId.value, database.value);
const selected =
preferredSchema && schemaList.includes(preferredSchema)
? preferredSchema
: schemaList.includes("public")
? "public"
: (schemaList[0] ?? "");
schemas.value = schemaList;
schema.value = selected;
}
async function loadTables(preferredTable = "") {
if (!connectionId.value || !database.value || !schema.value) return;
loadingTables.value = true;
tableError.value = null;
tables.value = [];
selectedTables.value = [];
try {
const tableInfos = await api.listTables(connectionId.value, database.value, schema.value);
const names = tableInfos.map((table) => table.name);
tables.value = names;
selectedTables.value = preferredTable && names.includes(preferredTable) ? [preferredTable] : [...names];
} catch (e: any) {
tableError.value = e?.message || String(e);
} finally {
loadingTables.value = false;
}
}
function toggleTable(table: string) {
const selected = new Set(selectedTables.value);
if (selected.has(table)) {
selected.delete(table);
} else {
selected.add(table);
}
selectedTables.value = tables.value.filter((name) => selected.has(name));
}
function selectAllTables() {
selectedTables.value = [...tables.value];
}
function clearSelectedTables() {
selectedTables.value = [];
}
async function startExport() {
if (!canExport.value) return;
isExporting.value = true;
exportDone.value = false;
exportError.value = null;
exportCancelled.value = false;
exportProgress.value = null;
exportId.value = generateDatabaseExportId();
let filePath = "";
if (isTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const safeName = (database.value || "database").replace(/[\\/:*?"<>|]+/g, "_").trim();
const path = await save({
defaultPath: `${safeName}.sql`,
filters: [{ name: "SQL", extensions: ["sql"] }],
});
if (!path) {
isExporting.value = false;
return;
}
filePath = path;
} catch (e: any) {
isExporting.value = false;
toast(e?.message || String(e), 5000);
return;
}
} else {
// Web mode: use a temp path; the server will handle the file
filePath = `__web_export_${exportId.value}.sql`;
}
const request: api.DatabaseExportRequest = {
exportId: exportId.value,
connectionId: connectionId.value,
database: database.value,
schema: schema.value,
filePath,
selectedTables: buildSelectedTablesPayload(tables.value, selectedTables.value),
includeStructure: includeStructure.value,
includeData: includeData.value,
includeObjects: includeObjects.value,
dropTableIfExists: dropTableIfExists.value,
batchSize: 1000,
};
try {
await api.exportDatabaseSql(request, (progress) => {
exportProgress.value = { ...progress };
if (progress.status === "Done") {
exportDone.value = true;
isExporting.value = false;
toast(t("databaseExport.exportSuccess"), 3000);
} else if (progress.status === "Error") {
exportError.value = progress.error;
isExporting.value = false;
} else if (progress.status === "Cancelled") {
exportCancelled.value = true;
isExporting.value = false;
}
});
} catch (e: any) {
exportError.value = e?.message || String(e);
isExporting.value = false;
}
}
async function cancelExport() {
if (exportId.value) {
await api.cancelDatabaseExport(exportId.value);
}
}
function resetState() {
connectionId.value = "";
database.value = "";
databases.value = [];
schema.value = "";
schemas.value = [];
tables.value = [];
selectedTables.value = [];
tableError.value = null;
pendingPrefillTable.value = "";
includeStructure.value = true;
includeData.value = true;
includeObjects.value = true;
dropTableIfExists.value = false;
isExporting.value = false;
exportProgress.value = null;
exportDone.value = false;
exportError.value = null;
exportCancelled.value = false;
exportId.value = "";
}
const progressPercent = computed(() => {
const p = exportProgress.value;
if (!p || p.totalObjects === 0) return 0;
return Math.round((p.objectIndex / p.totalObjects) * 100);
});
const skipConnectionWatch = ref(false);
watch(connectionId, (id) => {
if (skipConnectionWatch.value) {
skipConnectionWatch.value = false;
return;
}
database.value = "";
databases.value = [];
schemas.value = [];
schema.value = "";
tables.value = [];
selectedTables.value = [];
tableError.value = null;
loadDatabases(id);
});
watch(database, (db) => {
schema.value = "";
schemas.value = [];
tables.value = [];
selectedTables.value = [];
tableError.value = null;
if (db) loadSchemas(props.prefillSchema).catch((e) => toast(String(e), 5000));
});
watch(schema, (value) => {
tables.value = [];
selectedTables.value = [];
tableError.value = null;
const preferredTable = pendingPrefillTable.value;
pendingPrefillTable.value = "";
if (value) loadTables(preferredTable).catch((e) => toast(String(e), 5000));
});
watch(
open,
async (val) => {
if (val) {
resetState();
pendingPrefillTable.value = props.prefillTable ?? "";
if (props.prefillConnectionId) {
skipConnectionWatch.value = true;
connectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId);
if (props.prefillDatabase) {
database.value = props.prefillDatabase;
await loadSchemas(props.prefillSchema);
}
}
}
},
{ immediate: true },
);
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[480px] max-h-[80vh] flex flex-col overflow-hidden" @interact-outside.prevent>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Download class="w-4 h-4" />
{{ t("databaseExport.title") }}
</DialogTitle>
</DialogHeader>
<div class="flex-1 min-h-0 overflow-auto space-y-4 py-2">
<!-- Connection / Database / Schema Selection -->
<div v-if="!isExporting && !exportDone && !exportError && !exportCancelled" class="space-y-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.sourceConnection") }}</Label>
<Select :model-value="connectionId" @update:model-value="(v: any) => (connectionId = String(v))">
<SelectTrigger class="h-8 text-xs">
<div class="flex items-center gap-2">
<DatabaseIcon v-if="connectionId" :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5" />
<SelectValue :placeholder="t('diff.selectConnection')" />
</div>
</SelectTrigger>
<SelectContent position="popper" align="start">
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
<div class="flex items-center gap-2">
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
{{ c.name }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="databases.length" class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.sourceDatabase") }}</Label>
<Select :model-value="database" @update:model-value="(v: any) => (database = String(v))">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diff.selectDatabase')" />
</SelectTrigger>
<SelectContent position="popper" align="start">
<SelectItem v-for="db in databases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="schemas.length" class="space-y-1.5">
<Label class="text-xs">{{ t("diff.selectSchema") }}</Label>
<Select :model-value="schema" @update:model-value="(v: any) => (schema = String(v))">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diff.selectSchema')" />
</SelectTrigger>
<SelectContent position="popper" align="start">
<SelectItem v-for="s in schemas" :key="s" :value="s">{{ s }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="schema" class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs">{{ t("databaseExport.tableSelection") }}</Label>
<div v-if="tables.length" class="text-[11px] text-muted-foreground">
{{ t("databaseExport.selectedTables", { selected: selectedTables.length, total: tables.length }) }}
</div>
</div>
<div v-if="loadingTables" class="text-xs text-muted-foreground">
{{ t("databaseExport.loadingTables") }}
</div>
<div v-else-if="tableError" class="text-xs text-destructive">
{{ t("databaseExport.tableLoadError", { error: tableError }) }}
</div>
<div v-else-if="tables.length" class="space-y-2 rounded border border-border/60 p-2">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="selectAllTables">
{{ t("databaseExport.selectAllTables") }}
</Button>
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="clearSelectedTables">
{{ t("databaseExport.clearTables") }}
</Button>
</div>
<div class="max-h-40 overflow-auto space-y-1 pr-1">
<button
v-for="table in tables"
:key="table"
type="button"
class="flex w-full min-w-0 items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted"
@click="toggleTable(table)"
>
<CheckSquare v-if="selectedTableSet.has(table)" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
<span class="truncate">{{ table }}</span>
</button>
</div>
</div>
<div v-else class="text-xs text-muted-foreground">
{{ t("databaseExport.noTables") }}
</div>
</div>
<!-- Options -->
<div class="space-y-2.5 pt-1">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t("settings.options") }}
</div>
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeStructure = !includeStructure">
<CheckSquare v-if="includeStructure" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.includeStructure") }}
</div>
<div
class="flex items-center gap-2 text-xs"
:class="includeStructure ? 'cursor-pointer' : 'cursor-not-allowed text-muted-foreground/50'"
@click="includeStructure && (dropTableIfExists = !dropTableIfExists)"
>
<CheckSquare v-if="dropTableIfExists && includeStructure" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.dropTableIfExists") }}
</div>
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeData = !includeData">
<CheckSquare v-if="includeData" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.includeData") }}
</div>
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeObjects = !includeObjects">
<CheckSquare v-if="includeObjects" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.includeObjects") }}
</div>
</div>
</div>
<!-- Progress View -->
<div v-if="isExporting || exportDone || exportError || exportCancelled" class="py-3 space-y-3">
<div v-if="exportProgress" class="space-y-2">
<div class="text-xs text-muted-foreground">
{{
t("databaseExport.currentTable", {
table: exportProgress.currentObject,
current: exportProgress.objectIndex,
total: exportProgress.totalObjects,
})
}}
</div>
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="exportError ? 'bg-destructive' : exportCancelled ? 'bg-yellow-500' : 'bg-primary'"
:style="{ width: `${progressPercent}%` }"
/>
</div>
<div class="text-xs text-muted-foreground">
{{ t("databaseExport.rowsExported", { count: exportProgress.rowsExported.toLocaleString() }) }}
</div>
</div>
<!-- Status messages -->
<div v-if="exportDone" class="text-xs text-green-600 font-medium">
{{ t("databaseExport.exportSuccess") }}
</div>
<div v-else-if="exportError" class="text-xs text-destructive font-medium">
{{ t("databaseExport.exportError", { error: exportError }) }}
</div>
<div v-else-if="exportCancelled" class="text-xs text-yellow-600 font-medium">
{{ t("databaseExport.exportCancelled") }}
</div>
</div>
</div>
<DialogFooter>
<template v-if="!isExporting && !exportDone && !exportError && !exportCancelled">
<Button variant="outline" size="sm" @click="open = false">
{{ t("transfer.cancel") }}
</Button>
<Button size="sm" :disabled="!canExport" @click="startExport">
<Download class="w-3.5 h-3.5 mr-1.5" />
{{ t("databaseExport.export") }}
</Button>
</template>
<template v-else-if="isExporting">
<Button variant="destructive" size="sm" @click="cancelExport">
<X class="w-3.5 h-3.5 mr-1.5" />
{{ t("transfer.cancel") }}
</Button>
</template>
<template v-else>
<Button size="sm" @click="open = false">
{{ t("common.close") }}
</Button>
</template>
</DialogFooter>
</DialogContent>
</Dialog>
</template>