From ac19202b5a41a76cf10dfb2630118c4e34dd5e89 Mon Sep 17 00:00:00 2001 From: Dhruv Shah Date: Wed, 12 Aug 2026 17:05:51 -0400 Subject: [PATCH 1/2] Fix multi-statement result export --- src/pages/Editor.tsx | 69 ++++++++++++++++++++++++++++++-- src/utils/resultExport.ts | 52 ++++++++++++++++++++++++ tests/utils/resultExport.test.ts | 57 ++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 src/utils/resultExport.ts create mode 100644 tests/utils/resultExport.test.ts diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index e448f019..b3d077f6 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { reconstructTableQuery } from "../utils/editor"; import { formatRowsForCopy, copyTextToClipboard } from "../utils/clipboard"; +import { formatResultForExport } from "../utils/resultExport"; import { serializePkKey, buildPkMap } from "../utils/dataGrid"; import { buildKeylessUpdatePlan, @@ -69,6 +70,7 @@ import { } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { listen, emit } from "@tauri-apps/api/event"; +import { writeTextFile } from "@tauri-apps/plugin-fs"; import { TableToolbar } from "../components/ui/TableToolbar"; import { DataGrid } from "../components/ui/DataGrid"; import { MultiResultPanel } from "../components/ui/MultiResultPanel"; @@ -97,6 +99,7 @@ import { removeOtherEntries, removeEntriesToRight, removeEntriesToLeft, + findActiveEntry, } from "../utils/multiResult"; import { extractQueryParams, @@ -467,6 +470,16 @@ export const Editor = ({ commandScopeId }: EditorProps) => { const isMultiDb = usesMultiDatabaseLayout(activeCapabilities, selectedDatabases); const isEditorOpen = !isTableTab && (activeTab?.isEditorOpen ?? activeTab?.type !== "table"); + const activeResultEntry = useMemo( + () => + activeTab?.results + ? findActiveEntry(activeTab.results, activeTab.activeResultId) + : undefined, + [activeTab?.activeResultId, activeTab?.results], + ); + const activeExportResult = activeResultEntry?.result ?? activeTab?.result; + const canExportActiveResult = + !!activeExportResult && activeExportResult.rows.length > 0; const handleCloseTab = useCallback( (tabId: string) => { @@ -1345,7 +1358,15 @@ export const Editor = ({ commandScopeId }: EditorProps) => { applied.add(idx); applyStatement(idx, item); }); - updateTab(targetTabId, { isLoading: false }); + const firstResultEntry = batchResults.findIndex( + (item) => (item.result?.rows.length ?? 0) > 0, + ); + updateTab(targetTabId, { + isLoading: false, + ...(firstResultEntry >= 0 + ? { activeResultId: entries[firstResultEntry].id } + : {}), + }); }, [ activeConnectionId, @@ -3272,6 +3293,49 @@ export const Editor = ({ commandScopeId }: EditorProps) => { const handleExportCommon = async (format: "csv" | "json" | "markdown") => { if (!activeTab || !activeConnectionId) return; + const extension = format === "markdown" ? "md" : format; + const multiResult = activeResultEntry?.result; + if (multiResult?.rows.length) { + try { + const filePath = await save({ + filters: [ + { + name: format === "markdown" ? "Markdown" : format.toUpperCase(), + extensions: [extension], + }, + ], + defaultPath: `result_${Date.now()}.${extension}`, + }); + + if (!filePath) return; + + setExportState({ + isOpen: true, + status: "exporting", + rowsProcessed: multiResult.rows.length, + fileName: filePath.split(/[/\\]/).pop() || filePath, + }); + setExportMenuOpen(false); + + await writeTextFile( + filePath, + formatResultForExport(multiResult, format, csvDelimiter), + ); + + setExportState((prev) => ({ + ...prev, + status: "completed", + })); + } catch (e) { + setExportState((prev) => ({ + ...prev, + status: "error", + errorMessage: String(e), + })); + } + return; + } + const effectiveSchema = activeCapabilities?.schemas === true ? activeTab.schema : undefined; const tabForQuery = { ...activeTab, schema: effectiveSchema }; @@ -3283,7 +3347,6 @@ export const Editor = ({ commandScopeId }: EditorProps) => { if (!query || !query.trim()) return; try { - const extension = format === "markdown" ? "md" : format; const filePath = await save({ filters: [ { @@ -3812,7 +3875,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => {
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 198aedfd..d9384d77 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1182,6 +1182,7 @@ "executingQuery": "Abfrage wird ausgeführt...", "exporting": "Wird exportiert...", "rowsProcessed": "Verarbeitete Zeilen", + "exportLoadedRowsWarning": "Es wurden nur {{loaded}} geladene Zeilen von insgesamt {{total}} Zeilen exportiert. Blättere durch das Ergebnis oder führe eine eingeschränktere Abfrage aus, wenn du mehr Zeilen exportieren musst.", "queryParameters": "Abfrageparameter", "formatSql": "SQL formatieren", "convertToConsole": "In Konsole umwandeln", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f0342854..4968944e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1230,6 +1230,7 @@ "executingQuery": "Executing query...", "exporting": "Exporting...", "rowsProcessed": "Rows Processed", + "exportLoadedRowsWarning": "Only {{loaded}} loaded rows were exported out of {{total}} total rows. Page through the result or run a narrower query before exporting if you need more rows.", "queryParameters": "Query Parameters", "formatSql": "Format SQL", "convertToConsole": "Convert to Console", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 9d0639f7..c24d9555 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1200,6 +1200,7 @@ "executingQuery": "Ejecutando consulta...", "exporting": "Exportando...", "rowsProcessed": "Filas Procesadas", + "exportLoadedRowsWarning": "Solo se exportaron {{loaded}} filas cargadas de {{total}} filas totales. Recorre las páginas del resultado o ejecuta una consulta más específica antes de exportar si necesitas más filas.", "queryParameters": "Parámetros de Consulta", "formatSql": "Formatear SQL", "convertToConsole": "Convertir a Consola", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e62286f1..42957551 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1220,6 +1220,7 @@ "executingQuery": "Exécution de la requête...", "exporting": "Exportation...", "rowsProcessed": "Lignes traitées", + "exportLoadedRowsWarning": "Seules {{loaded}} lignes chargées sur {{total}} lignes au total ont été exportées. Parcourez les pages du résultat ou exécutez une requête plus restrictive avant d'exporter si vous avez besoin de plus de lignes.", "queryParameters": "Paramètres de requête", "formatSql": "Formater SQL", "convertToConsole": "Convertir en console", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index c45f47f8..b93eec2e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1204,6 +1204,7 @@ "executingQuery": "Esecuzione query...", "exporting": "Esportazione in corso...", "rowsProcessed": "Righe processate", + "exportLoadedRowsWarning": "Sono state esportate solo {{loaded}} righe caricate su {{total}} righe totali. Scorri le pagine del risultato o esegui una query più mirata prima di esportare se ti servono più righe.", "queryParameters": "Parametri Query", "formatSql": "Formatta SQL", "convertToConsole": "Converti in Console", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 59b119f0..84e7f018 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1193,6 +1193,7 @@ "executingQuery": "クエリを実行中...", "exporting": "エクスポート中...", "rowsProcessed": "処理行数", + "exportLoadedRowsWarning": "合計 {{total}} 行のうち、読み込み済みの {{loaded}} 行のみをエクスポートしました。さらに多くの行が必要な場合は、結果をページ送りするか、より絞り込んだクエリを実行してからエクスポートしてください。", "queryParameters": "クエリパラメータ", "formatSql": "SQLをフォーマット", "convertToConsole": "コンソールに変換", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 4f12917d..daaf5560 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1157,6 +1157,7 @@ "executingQuery": "쿼리를 실행하는 중...", "exporting": "내보내는 중...", "rowsProcessed": "처리된 행", + "exportLoadedRowsWarning": "총 {{total}}개 행 중 로드된 {{loaded}}개 행만 내보냈습니다. 더 많은 행이 필요하면 결과 페이지를 넘기거나 더 좁은 쿼리를 실행한 뒤 내보내세요.", "queryParameters": "쿼리 매개변수", "formatSql": "SQL 포맷", "convertToConsole": "콘솔로 변환", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 3d8b5c47..90f60922 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1196,6 +1196,7 @@ "executingQuery": "Executando consulta...", "exporting": "Exportando...", "rowsProcessed": "Linhas Processadas", + "exportLoadedRowsWarning": "Apenas {{loaded}} linhas carregadas de {{total}} linhas totais foram exportadas. Navegue pelas páginas do resultado ou execute uma consulta mais restrita antes de exportar se precisar de mais linhas.", "queryParameters": "Parâmetros da Consulta", "formatSql": "Formatar SQL", "convertToConsole": "Converter para Console", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index a17204ef..8e073d26 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1178,6 +1178,7 @@ "executingQuery": "Выполнение запроса...", "exporting": "Экспорт...", "rowsProcessed": "Обработано строк", + "exportLoadedRowsWarning": "Экспортированы только {{loaded}} загруженных строк из {{total}} строк. Если нужны дополнительные строки, пролистайте результат или выполните более узкий запрос перед экспортом.", "queryParameters": "Параметры запроса", "formatSql": "Форматировать SQL", "convertToConsole": "Преобразовать в консоль", diff --git a/src/i18n/locales/tl.json b/src/i18n/locales/tl.json index 43f76781..05ebbf24 100644 --- a/src/i18n/locales/tl.json +++ b/src/i18n/locales/tl.json @@ -1213,6 +1213,7 @@ "executingQuery": "Isinasakatuparan ang query...", "exporting": "Nag-e-export...", "rowsProcessed": "Mga Row na Naproseso", + "exportLoadedRowsWarning": "{{loaded}} lang na na-load na row ang na-export mula sa kabuuang {{total}} row. Mag-page sa resulta o magpatakbo ng mas makitid na query bago mag-export kung kailangan mo ng mas maraming row.", "queryParameters": "Mga Parameter ng Query", "formatSql": "I-format ang SQL", "convertToConsole": "I-convert sa Console", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7a5f42fe..92ce62b5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1148,6 +1148,7 @@ "executingQuery": "执行查询中...", "exporting": "导出中...", "rowsProcessed": "已处理行数", + "exportLoadedRowsWarning": "仅导出了总计 {{total}} 行中的 {{loaded}} 行已加载数据。如果需要更多行,请先翻页加载结果或运行范围更窄的查询后再导出。", "queryParameters": "查询参数", "formatSql": "格式化 SQL", "convertToConsole": "转换为控制台", diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 5b69e20a..32cdaefd 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -4,7 +4,10 @@ import { useTranslation } from "react-i18next"; import { reconstructTableQuery } from "../utils/editor"; import { shouldShowStatementSuccess } from "../utils/resultPresentation"; import { formatRowsForCopy, copyTextToClipboard } from "../utils/clipboard"; -import { formatResultForExport } from "../utils/resultExport"; +import { + formatResultForExport, + getLoadedRowsExportLimit, +} from "../utils/resultExport"; import { serializePkKey, buildPkMap } from "../utils/dataGrid"; import { buildKeylessUpdatePlan, @@ -267,6 +270,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { rowsProcessed: number; fileName: string; errorMessage?: string; + warningMessage?: string; }>({ isOpen: false, status: "exporting", @@ -3308,6 +3312,14 @@ export const Editor = ({ commandScopeId }: EditorProps) => { const multiResult = activeResultEntry?.result; if (multiResult?.rows.length) { try { + const loadedRowsLimit = getLoadedRowsExportLimit(multiResult); + const warningMessage = loadedRowsLimit + ? t("editor.exportLoadedRowsWarning", { + loaded: loadedRowsLimit.loadedRows.toLocaleString(), + total: loadedRowsLimit.totalRows.toLocaleString(), + }) + : undefined; + const filePath = await save({ filters: [ { @@ -3325,6 +3337,8 @@ export const Editor = ({ commandScopeId }: EditorProps) => { status: "exporting", rowsProcessed: multiResult.rows.length, fileName: filePath.split(/[/\\]/).pop() || filePath, + errorMessage: undefined, + warningMessage, }); setExportMenuOpen(false); @@ -3375,6 +3389,8 @@ export const Editor = ({ commandScopeId }: EditorProps) => { status: "exporting", rowsProcessed: 0, fileName: filePath.split(/[/\\]/).pop() || filePath, // Show only filename + errorMessage: undefined, + warningMessage: undefined, }); setExportMenuOpen(false); @@ -4877,6 +4893,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { rowsProcessed={exportState.rowsProcessed} fileName={exportState.fileName} errorMessage={exportState.errorMessage} + warningMessage={exportState.warningMessage} onCancel={cancelExport} onClose={closeExportModal} /> diff --git a/src/utils/resultExport.ts b/src/utils/resultExport.ts index 2061bb36..e02b602b 100644 --- a/src/utils/resultExport.ts +++ b/src/utils/resultExport.ts @@ -3,6 +3,19 @@ import { rowsToMarkdown } from "./clipboard"; export type ResultExportFormat = "csv" | "json" | "markdown"; +export function getLoadedRowsExportLimit( + result: QueryResult, +): { loadedRows: number; totalRows: number } | null { + const totalRows = result.pagination?.total_rows; + + if (typeof totalRows !== "number" || !Number.isFinite(totalRows)) { + return null; + } + + const loadedRows = result.rows.length; + return loadedRows < totalRows ? { loadedRows, totalRows } : null; +} + function csvValue(value: unknown, delimiter: string): string { const text = value === null || value === undefined diff --git a/tests/components/modals/ExportProgressModal.test.tsx b/tests/components/modals/ExportProgressModal.test.tsx new file mode 100644 index 00000000..c338b509 --- /dev/null +++ b/tests/components/modals/ExportProgressModal.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ExportProgressModal } from "../../../src/components/modals/ExportProgressModal"; + +vi.mock("../../../src/components/ui/Modal", () => ({ + Modal: ({ + isOpen, + children, + }: { + isOpen: boolean; + children: React.ReactNode; + }) => (isOpen ?
{children}
: null), +})); + +describe("ExportProgressModal", () => { + const defaultProps = { + isOpen: true, + status: "completed" as const, + rowsProcessed: 100, + fileName: "result.csv", + onCancel: vi.fn(), + onClose: vi.fn(), + }; + + it("shows a warning message when the export used only loaded rows", () => { + render( + , + ); + + expect( + screen.getByText( + "Only 100 loaded rows were exported out of 250 total rows.", + ), + ).toBeInTheDocument(); + }); + + it("shows the error message instead of a loaded rows warning on failure", () => { + render( + , + ); + + expect(screen.getByText("Export failed")).toBeInTheDocument(); + expect( + screen.queryByText( + "Only 100 loaded rows were exported out of 250 total rows.", + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 1143a85d..fccd92f4 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -134,6 +134,7 @@ vi.mock("lucide-react", () => ({ RefreshCw: () => null, SquareStack: () => null, Check: () => null, + CheckCircle: () => null, Undo: () => null, Minus: () => null, Network: () => null, diff --git a/tests/utils/resultExport.test.ts b/tests/utils/resultExport.test.ts index 233829f2..9c8b3cd1 100644 --- a/tests/utils/resultExport.test.ts +++ b/tests/utils/resultExport.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { QueryResult } from "../../src/types/editor"; -import { formatResultForExport } from "../../src/utils/resultExport"; +import { + formatResultForExport, + getLoadedRowsExportLimit, +} from "../../src/utils/resultExport"; const result: QueryResult = { columns: ["id", "name"], @@ -54,4 +57,47 @@ describe("resultExport", () => { "| id | name |\n| --- | --- |\n| 1 | John |\n| 2 | null |", ); }); + + it("detects when only part of a paginated result is loaded for export", () => { + expect( + getLoadedRowsExportLimit({ + ...result, + pagination: { + total_rows: 25, + page: 1, + page_size: 2, + has_more: true, + }, + }), + ).toEqual({ loadedRows: 2, totalRows: 25 }); + }); + + it("does not warn when all paginated rows are loaded", () => { + expect( + getLoadedRowsExportLimit({ + ...result, + pagination: { + total_rows: 2, + page: 1, + page_size: 2, + has_more: false, + }, + }), + ).toBeNull(); + }); + + it("does not warn without a finite total row count", () => { + expect(getLoadedRowsExportLimit(result)).toBeNull(); + expect( + getLoadedRowsExportLimit({ + ...result, + pagination: { + total_rows: Number.POSITIVE_INFINITY, + page: 1, + page_size: 2, + has_more: false, + }, + }), + ).toBeNull(); + }); });