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"
@@ -128,6 +128,7 @@ export interface UseDataGridExportOptions {
filePath: string | null;
}>;
exportCancelHandler?: Ref<(() => Promise<void>) | null>;
exportCanMinimize?: Ref<boolean>;
}
interface CopyInsertData {
@@ -140,6 +141,7 @@ interface CopyInsertData {
export function useDataGridExport(options: UseDataGridExportOptions) {
const { t } = useI18n();
const { toast } = useToast();
const tracker = useExportTracker();
const exportGuard: ActionActivationGuard = {};
const { addTask, updateTableExportTask, registerTaskCancelHandler, unregisterTaskCancelHandler, removeTask } = useExportTracker();
@@ -185,6 +187,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
exportProgressDialog,
exportProgressState,
exportCancelHandler,
exportCanMinimize,
} = options;
const selectedCellMatrix = selectedCellMatrixOption;
const allColumns = allColumnsOption ?? columns;
@@ -906,11 +909,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
};
}
if (exportProgressDialog) exportProgressDialog.value = true;
if (exportCanMinimize) exportCanMinimize.value = true;
const exportId = uuid();
const task = tracker.addTask(meta.tableName, format, outputPath);
const exportId = task.exportId;
if (exportCancelHandler) {
exportCancelHandler.value = () => api.cancelTableExport(exportId);
}
tracker.registerTaskCancelHandler(exportId, () => api.cancelTableExport(exportId));
const editorSettings = useSettingsStore().editorSettings;
const rowLimit = editorSettings.exportRowLimitEnabled ? editorSettings.exportRowLimit : null;
@@ -946,6 +952,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
errorMessage: progress.errorMessage || null,
};
}
tracker.updateTableExportTask(exportId, progress);
},
);
if (progress.status === "Done") {
@@ -953,6 +960,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
} finally {
if (exportCancelHandler) exportCancelHandler.value = null;
tracker.unregisterTaskCancelHandler(exportId);
if (exportCanMinimize) exportCanMinimize.value = false;
}
return true;
}
@@ -997,9 +1006,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
};
}
if (exportProgressDialog) exportProgressDialog.value = true;
if (exportCanMinimize) exportCanMinimize.value = true;
tracker.addTask("Query Result", format, outputPath, exportId);
if (exportCancelHandler) {
exportCancelHandler.value = () => api.cancelQueryResultExport(exportId, request.executionId);
}
tracker.registerTaskCancelHandler(exportId, () => api.cancelQueryResultExport(exportId, request.executionId));
try {
const terminalProgress = await api.startQueryResultExport(request, (progress) => {
@@ -1014,12 +1026,15 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
errorMessage: progress.errorMessage || null,
};
}
tracker.updateTableExportTask(exportId, progress);
});
if (terminalProgress.status === "Done") {
toast(t("grid.exported"));
}
} finally {
if (exportCancelHandler) exportCancelHandler.value = null;
tracker.unregisterTaskCancelHandler(exportId);
if (exportCanMinimize) exportCanMinimize.value = false;
}
return true;
}
@@ -229,10 +229,10 @@ export function useExportTracker() {
.replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, "$1-$2-$3-$4-$5");
}
function addTask(tableName: string, format: string, filePath: string): ExportTask {
const exportId = generateUUID();
function addTask(tableName: string, format: string, filePath: string, exportId?: string): ExportTask {
const id = exportId ?? generateUUID();
const task = reactive<ExportTask>({
exportId,
exportId: id,
kind: "table-export",
tableName,
format,
@@ -242,7 +242,7 @@ export function useExportTracker() {
status: "Running",
errorMessage: null,
});
taskMap.set(exportId, task);
taskMap.set(id, task);
return task;
}
+3
View File
@@ -819,6 +819,8 @@ export default {
contextMenu: {
executeSelection: "Execute selection",
executeCurrent: "Execute SQL",
export: "Export",
exportQueryResultTo: "Export current query result to {format}",
copySelection: "Copy selection",
sendToAi: "Send to AI",
uppercaseSelection: "Convert to uppercase",
@@ -1399,6 +1401,7 @@ export default {
rowsExported: "{count} rows exported",
rowsShort: "rows",
cancel: "Cancel",
minimize: "Minimize to background",
openFolder: "Open containing folder",
openFolderFailed: "Failed to open folder: {message}",
close: "Close",
+3
View File
@@ -799,6 +799,8 @@ export default withEnglishFallback({
contextMenu: {
executeSelection: "Ejecutar seleccion",
executeCurrent: "Ejecutar SQL",
export: "Exportar",
exportQueryResultTo: "Exportar el conjunto de resultados de la consulta actual a {format}",
copySelection: "Copiar seleccion",
sendToAi: "Enviar a IA",
uppercaseSelection: "Convertir a mayusculas",
@@ -1342,6 +1344,7 @@ export default withEnglishFallback({
rowsExported: "{count} filas exportadas",
rowsShort: "filas",
cancel: "Cancelar",
minimize: "Minimizar a segundo plano",
openFolder: "Abrir carpeta contenedora",
openFolderFailed: "No se pudo abrir la carpeta: {message}",
close: "Cerrar",
+3
View File
@@ -797,6 +797,8 @@ export default withEnglishFallback({
contextMenu: {
executeSelection: "Esegui selezione",
executeCurrent: "Esegui SQL",
export: "Esporta",
exportQueryResultTo: "Esporta il set di risultati della query corrente in {format}",
copySelection: "Copia selezione",
sendToAi: "Invia ad AI",
uppercaseSelection: "Converti in maiuscolo",
@@ -1340,6 +1342,7 @@ export default withEnglishFallback({
rowsExported: "{count} righe esportate",
rowsShort: "righe",
cancel: "Annulla",
minimize: "Minimizza in background",
openFolder: "Apri cartella contenente",
openFolderFailed: "Impossibile aprire la cartella: {message}",
close: "Chiudi",
+3
View File
@@ -796,6 +796,8 @@ export default withEnglishFallback({
contextMenu: {
executeSelection: "選択範囲を実行",
executeCurrent: "SQLを実行",
export: "エクスポート",
exportQueryResultTo: "現在のクエリ結果セットを {format} にエクスポート",
copySelection: "選択範囲をコピー",
sendToAi: "AIに送信",
uppercaseSelection: "大文字に変換",
@@ -1341,6 +1343,7 @@ export default withEnglishFallback({
rowsExported: "{count}行をエクスポートしました",
rowsShort: "行",
cancel: "キャンセル",
minimize: "バックグラウンドに最小化",
openFolder: "保存先フォルダーを開く",
openFolderFailed: "フォルダーを開けませんでした:{message}",
close: "閉じる",
+3
View File
@@ -798,6 +798,8 @@ export default withEnglishFallback({
contextMenu: {
executeSelection: "Executar seleção",
executeCurrent: "Executar SQL",
export: "Exportar",
exportQueryResultTo: "Exportar conjunto de resultados da consulta atual para {format}",
copySelection: "Copiar seleção",
sendToAi: "Enviar para IA",
uppercaseSelection: "Converter para maiúsculas",
@@ -1342,6 +1344,7 @@ export default withEnglishFallback({
rowsExported: "{count} linhas exportadas",
rowsShort: "linhas",
cancel: "Cancelar",
minimize: "Minimizar para segundo plano",
openFolder: "Abrir pasta do arquivo",
openFolderFailed: "Falha ao abrir a pasta: {message}",
close: "Fechar",
+3
View File
@@ -820,6 +820,8 @@ export default withEnglishFallback({
contextMenu: {
executeSelection: "执行选中 SQL",
executeCurrent: "执行 SQL",
export: "导出",
exportQueryResultTo: "导出当前查询结果集到 {format}",
copySelection: "复制选中内容",
sendToAi: "发送到 AI",
uppercaseSelection: "转为大写",
@@ -1400,6 +1402,7 @@ export default withEnglishFallback({
rowsExported: "已导出 {count} 行",
rowsShort: "行",
cancel: "取消",
minimize: "最小化到后台",
openFolder: "打开所在文件夹",
openFolderFailed: "打开文件夹失败:{message}",
close: "关闭",
+3
View File
@@ -797,6 +797,8 @@ export default withEnglishFallback({
contextMenu: {
executeSelection: "執行選取 SQL",
executeCurrent: "執行 SQL",
export: "匯出",
exportQueryResultTo: "匯出目前查詢結果集到 {format}",
copySelection: "複製選取內容",
sendToAi: "傳送至 AI",
uppercaseSelection: "轉為大寫",
@@ -1341,6 +1343,7 @@ export default withEnglishFallback({
rowsExported: "已匯出 {count} 列",
rowsShort: "列",
cancel: "取消",
minimize: "最小化到背景",
openFolder: "開啟所在資料夾",
openFolderFailed: "開啟資料夾失敗:{message}",
close: "關閉",
+55 -1
View File
@@ -58,6 +58,7 @@ import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useExportTracker } from "@/composables/useExportTracker";
import { recordQueryCancellationLatency, resourceLifecycleDiagnostics } from "@/lib/diagnostics/resourceLifecycleDiagnostics";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { formatError } from "@/lib/backend/errorUtils";
@@ -4770,7 +4771,7 @@ export const useQueryStore = defineStore("query", () => {
const setupSql = batchStatements[resultStatementIndex!]?.sql === tab.result.sourceStatement ? batchStatements.slice(0, resultStatementIndex).map((statement) => statement.sql) : undefined;
const rowLimit = settings.exportRowLimitEnabled ? settings.exportRowLimit : null;
const totalRows = typeof tab.resultTotalRowCount === "number" ? (rowLimit === null ? tab.resultTotalRowCount : Math.min(tab.resultTotalRowCount, rowLimit)) : null;
const clientSessionId = tabClientSessionId(tab, "export");
const clientSessionId = `${tabClientSessionId(tab, "export")}:${options.exportId}`;
return {
exportId: options.exportId,
@@ -4798,6 +4799,58 @@ export const useQueryStore = defineStore("query", () => {
};
}
async function exportQuerySqlDirect(id: string, sql: string, format: "csv" | "xlsx" | "txt", filePath: string) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab || tab.mode !== "query" || !sql.trim()) return;
const connStore = useConnectionStore();
await connStore.ensureConnected(tab.connectionId);
const conn = connStore.getConfig(tab.connectionId);
const settings = useSettingsStore().editorSettings;
const effectiveDbType = effectiveDatabaseTypeForConnection(conn);
if (!effectiveDbType) return;
const exportId = uuid();
const request: api.QueryResultExportRequest = {
exportId,
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
sql,
queryBaseSql: sql,
databaseType: effectiveDbType,
useAgentCursor: usesAgentCursorForQuery(conn?.db_type),
filePath,
format,
pageSize: settings.exportBatchSize,
rowLimit: settings.exportRowLimitEnabled ? settings.exportRowLimit : null,
totalRows: null,
timeoutSecs: queryTimeoutSecsForConnection(conn),
keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled,
clientSessionId: `${tabClientSessionId(tab, "export")}:${exportId}`,
executionId: uuid(),
numericColumnRightAlign: settings.numericColumnRightAlign,
};
const tracker = useExportTracker();
tracker.addTask("Query Result", format, filePath, request.exportId);
tracker.registerTaskCancelHandler(request.exportId, () => api.cancelQueryResultExport(request.exportId, request.executionId));
void (async () => {
try {
await api.startQueryResultExport(request, (progress) => tracker.updateTableExportTask(request.exportId, progress));
} catch (error: any) {
const task = tracker.tasks.value.find((item) => item.exportId === request.exportId);
if (task) {
task.status = "Error";
task.errorMessage = error?.message || String(error);
}
} finally {
tracker.unregisterTaskCancelHandler(request.exportId);
}
})();
}
return {
tabs,
activeTabId,
@@ -4900,6 +4953,7 @@ export const useQueryStore = defineStore("query", () => {
importResultArchive,
fetchTabResultForExport,
buildQueryResultExportRequest,
exportQuerySqlDirect,
getResourceLifecycleDiagnostics: () => resourceLifecycleDiagnostics(tabs.value),
notifyConnectionMayBeLost,
};