feat(export): support safe background query exports

This commit is contained in:
zhangyaxi authored and GitHub committed 2026-07-29 03:18:06 +08:00
1 parent 2f44d36018
commit a07def6042
17 files changed
+577 -195

No files matched your search

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
import { CaseLower, CaseUpper, Code2, FileCode, Pencil, PencilRuler, Play, Copy, List, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
import { CaseLower, CaseUpper, Code2, Download, FileCode, Pencil, PencilRuler, Play, Copy, List, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import type { CompletionContext } from "@codemirror/autocomplete";
import { Transaction, StateEffect } from "@codemirror/state";
@@ -138,6 +138,7 @@ const emit = defineEmits<{
formatError: [message: string];
execute: [source: SqlExecutionOverride];
executeInNewResultTab: [source: SqlExecutionOverride];
exportQuery: [payload: { sql: string; format: "csv" | "xlsx" | "txt" }];
save: [];
clickTable: [target: SqlObjectNavigationTarget];
viewTableData: [target: SqlObjectNavigationTarget];
@@ -1000,6 +1001,12 @@ function executeInNewResultTabFromContextMenu() {
focusEditor();
}
function exportQueryFromContextMenu(format: "csv" | "xlsx" | "txt") {
const sql = executableSql.value;
if (!sql.trim()) return;
emit("exportQuery", { sql, format });
}
async function copySelectedSqlFromContextMenu() {
if (!canCopySelectedSql.value) return;
try {
@@ -1274,6 +1281,16 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
icon: Play,
shortcut: shortcuts.executeSqlInNewResultTab,
},
{
label: t("editor.contextMenu.export"),
icon: Download,
disabled: !canExecuteContextSql.value,
children: [
{ label: t("editor.contextMenu.exportQueryResultTo", { format: "CSV" }), action: () => exportQueryFromContextMenu("csv") },
{ label: t("editor.contextMenu.exportQueryResultTo", { format: "XLSX" }), action: () => exportQueryFromContextMenu("xlsx") },
{ label: t("editor.contextMenu.exportQueryResultTo", { format: "TXT" }), action: () => exportQueryFromContextMenu("txt") },
],
},
]),
...queryContextObjectActions(contextObjectTarget.value?.type).map(contextObjectMenuItem),
{ label: "", separator: true },
@@ -3,7 +3,7 @@ import { computed, ref } 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, FolderOpen, X } from "@lucide/vue";
import { Loader2, CheckCircle2, XCircle, AlertCircle, FolderOpen, Minimize2, X } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import * as api from "@/lib/backend/api";
@@ -24,10 +24,12 @@ const props = defineProps<{
errorMessage: string | null;
filePath?: string | null;
disableCancel?: boolean;
canMinimize?: boolean;
}>();
const emit = defineEmits<{
cancel: [];
minimize: [];
"update:open": [value: boolean];
}>();
@@ -123,6 +125,10 @@ async function revealExportFile() {
<DialogFooter>
<template v-if="isActive">
<Button v-if="canMinimize" variant="ghost" size="sm" @click="emit('minimize')">
<Minimize2 class="h-3.5 w-3.5 mr-1" />
{{ t("exportProgress.minimize") }}
</Button>
<Button variant="outline" size="sm" :disabled="disableCancel" @click="emit('cancel')">
<X class="h-3.5 w-3.5 mr-1" />
{{ t("exportProgress.cancel") }}
@@ -5121,6 +5121,7 @@ const exportProgressState = ref({
filePath: null as string | null,
});
const exportCancelHandler = ref<(() => Promise<void>) | null>(null);
const exportCanMinimize = ref(false);
async function cancelActiveExport() {
await exportCancelHandler.value?.();
@@ -5196,6 +5197,7 @@ const {
exportProgressDialog,
exportProgressState,
exportCancelHandler,
exportCanMinimize,
});
function copyExtractorLabel(extractor: DataGridCopyExtractorId): string {
@@ -9302,7 +9304,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
/>
<ImagePreviewDialog v-if="imagePreviewMounted" v-model:open="imagePreviewOpen" :src="imagePreviewSrc" :title="imagePreviewTitle" />
<component v-if="previewDialogOpen && previewDialogConfig" :is="previewDialogConfig.component" v-model:open="previewDialogOpen" v-bind="previewDialogConfig.props" />
<ExportProgressDialog v-if="exportProgressDialogMounted" v-model:open="exportProgressDialog" v-bind="exportProgressState" :disable-cancel="!exportCancelHandler" @cancel="cancelActiveExport" />
<ExportProgressDialog v-if="exportProgressDialogMounted" v-model:open="exportProgressDialog" v-bind="exportProgressState" :disable-cancel="!exportCancelHandler" :can-minimize="exportCanMinimize" @cancel="cancelActiveExport" @minimize="exportProgressDialog = false" />
</div>
</template>
@@ -68,6 +68,7 @@ import { useQueryStore } from "@/stores/queryStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode, type ResultRunDisplayMode } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive";
@@ -865,6 +866,20 @@ function requestQueryEditorExecuteInNewResultTab() {
return queryEditorRef.value?.requestExecuteInNewResultTab();
}
async function handleExportQuery(payload: { sql: string; format: "csv" | "xlsx" | "txt" }) {
const tab = props.activeTab;
if (!tab || tab.mode !== "query") return;
let filePath = `query-result.${payload.format}`;
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const filterName = payload.format === "csv" ? "CSV" : payload.format === "xlsx" ? "Excel" : "Text";
const picked = await save({ defaultPath: filePath, filters: [{ name: filterName, extensions: [payload.format] }] });
if (!picked) return;
filePath = picked as string;
}
await queryStore.exportQuerySqlDirect(tab.id, payload.sql, payload.format, filePath);
}
function pasteClipboardAsSqlInCondition() {
return queryEditorRef.value?.pasteClipboardAsSqlInCondition();
}
@@ -932,6 +947,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
@format-error="emit('formatError')"
@execute="emit('execute', $event)"
@execute-in-new-result-tab="emit('executeInNewResultTab', $event)"
@export-query="handleExportQuery"
@save="emit('saveSql')"
@click-table="onHandleClickTable"
@view-table-data="onHandleViewTableData"