diff --git a/e2e/pages/results-page.ts b/e2e/pages/results-page.ts index 0f10481..d097d4b 100644 --- a/e2e/pages/results-page.ts +++ b/e2e/pages/results-page.ts @@ -117,7 +117,7 @@ export class ResultsPage { } // ── Export ─────────────────────────────────────────────────────────────── - /** The "Export as" options dialog (note: "Save to file" itself uses a native dialog). */ + /** The export options dialog (note: "Save to file" itself uses a native dialog). */ get exportDialog(): Locator { return this.page.locator('.modal').filter({ has: this.page.locator('#export-format') }); } @@ -130,12 +130,16 @@ export class ResultsPage { return this.page.locator('#export-rows-group'); } + get exportColumnsGroup(): Locator { + return this.page.locator('#export-cols-group'); + } + get exportSummary(): Locator { return this.page.locator('.export-results-summary'); } async openExportDialog(): Promise { - await this.activeSet.getByRole('button', { name: 'Export as' }).click(); + await this.activeSet.getByRole('button', { name: 'Export', exact: true }).click(); await expect(this.exportDialog).toBeVisible(); } diff --git a/e2e/specs/results/export.spec.ts b/e2e/specs/results/export.spec.ts index f7ae61a..0115946 100644 --- a/e2e/specs/results/export.spec.ts +++ b/e2e/specs/results/export.spec.ts @@ -1,7 +1,7 @@ import { POSTGRES } from '@support/databases'; import { expect, test } from '@support/fixtures'; -// Covers the "Export as" dialog UI only. "Save to file" (native OS dialog) and +// Covers the export dialog UI only. "Save to file" (native OS dialog) and // "Copy to clipboard" (Wails clipboard) aren't driven here. test.describe('Export', () => { test('switches format and scope and reflects them in the summary', async ({ connections, editor, results }) => { @@ -16,6 +16,11 @@ test.describe('Export', () => { const selectedRows = results.exportRowsGroup.getByRole('button', { name: /Selected/ }); await expect(selectedRows).toBeDisabled(); + // With no column hidden, "Visible" would duplicate "All", so only "All" is offered. + const colsGroup = results.exportColumnsGroup; + await expect(colsGroup.getByRole('button', { name: 'All (2)' })).toHaveClass(/active/); + await expect(colsGroup.getByRole('button', { name: /Visible/ })).toHaveCount(0); + await results.setExportFormat('json'); await expect(results.exportSummary).toContainText('JSON'); diff --git a/e2e/specs/table-view/columns.spec.ts b/e2e/specs/table-view/columns.spec.ts index 83f0f59..bff93d2 100644 --- a/e2e/specs/table-view/columns.spec.ts +++ b/e2e/specs/table-view/columns.spec.ts @@ -18,7 +18,7 @@ test.describe('Table view - column visibility', () => { await expect(tableView.columnsButton).toContainText('(1/2)'); // The export dialog defaults to the visible-column scope. - await tableView.pane.getByRole('button', { name: 'Export as' }).click(); + await tableView.pane.getByRole('button', { name: 'Export', exact: true }).click(); const colsGroup = page.locator('#export-cols-group'); await expect(colsGroup.getByRole('button', { name: 'All (2)' })).toBeVisible(); await expect(colsGroup.getByRole('button', { name: 'Visible (1)' })).toHaveClass(/active/); diff --git a/frontend/bindings/xensql/internal/app/app.ts b/frontend/bindings/xensql/internal/app/app.ts index 58447b2..821090e 100644 --- a/frontend/bindings/xensql/internal/app/app.ts +++ b/frontend/bindings/xensql/internal/app/app.ts @@ -19,6 +19,14 @@ import * as storage$0 from "../storage/models.js"; // @ts-ignore: Unused imports import * as $models from "./models.js"; +/** + * AppendTextFile writes one chunk, emptying the file first when truncate is set. Each chunk opens and + * closes the file, so nothing leaks if the caller stops part-way. + */ +export function AppendTextFile(path: string, chunk: string, truncate: boolean): $CancellablePromise { + return $Call.ByID(403815492, path, chunk, truncate); +} + /** * BeginTransaction pins a dedicated connection to the tab and opens a transaction. * Subsequent ExecuteQueryStream calls for tabID will run on that connection until diff --git a/frontend/src/features/results/ResultsGrid.tsx b/frontend/src/features/results/ResultsGrid.tsx index e692fdc..ad6e946 100644 --- a/frontend/src/features/results/ResultsGrid.tsx +++ b/frontend/src/features/results/ResultsGrid.tsx @@ -212,7 +212,7 @@ function ResultsGridImpl({ selectionRef, focusRef, }); - const { copyFormat, setCopyFormat, exportBusy, copyToClipboard, exportToFile } = copyExport; + const { copyFormat, setCopyFormat, copyToClipboard } = copyExport; const copySelectionToClipboard = useCallback(() => copyToClipboard(true), [copyToClipboard]); const copyButtonToClipboard = useCallback(() => copyToClipboard(false), [copyToClipboard]); @@ -428,10 +428,8 @@ function ResultsGridImpl({ } copyFormat={copyFormat} onFormatChange={setCopyFormat} - exportBusy={exportBusy} onCopy={copyButtonToClipboard} - onExportToFile={exportToFile} - onExportAs={() => setExportOpen(true)} + onExport={() => setExportOpen(true)} /> copyToClipboard(false), [copyToClipboard]); const copySelectionToClipboard = useCallback(() => copyToClipboard(true), [copyToClipboard]); - const exportAllLoaded = useCallback(async () => { - const saved = selectionRef.current; - selectionRef.current = { rows: new Set(), cols: new Set() }; - try { - await exportToFile(); - } finally { - selectionRef.current = saved; - } - }, [exportToFile, selectionRef]); - const { selectionRowsCount, selectionColsCount, selectedSortedRows, selectedColPositions } = useGridSelectionView({ cellRange, selectedRows, @@ -467,10 +457,8 @@ export const TableViewGrid = memo(function TableViewGrid({ } copyFormat={copyFormat} onFormatChange={setCopyFormat} - exportBusy={exportBusy} onCopy={copyButtonToClipboard} - onExportToFile={exportAllLoaded} - onExportAs={() => setExportOpen(true)} + onExport={() => setExportOpen(true)} /> 0; const hasCols = selectedColumns.length > 0; - const defaultRowScope: 'all' | 'selected' = hasCols && !hasRows ? 'all' : hasRows ? 'selected' : 'all'; - const defaultColScope: 'visible' | 'all' | 'selected' = - hasRows && !hasCols ? 'visible' : hasCols ? 'selected' : 'visible'; + // Visible only differs from All while a column is hidden; the visible list is a filter of all. + const hasHiddenColumns = visibleColumns.length < allColumns.length; + const defaultRowScope: RowScope = hasRows ? 'selected' : 'all'; + const defaultColScope: ColScope = hasCols ? 'selected' : hasHiddenColumns ? 'visible' : 'all'; - const [rowScope, setRowScope] = useState<'all' | 'selected'>(defaultRowScope); - const [colScope, setColScope] = useState<'visible' | 'all' | 'selected'>(defaultColScope); + const [rowScope, setRowScope] = useState(defaultRowScope); + const [colScope, setColScope] = useState(defaultColScope); const [busy, setBusy] = useState(false); - - const canExportSelectedRows = selectedRowIndices.length > 0; - const canExportSelectedCols = selectedColumns.length > 0; + // Non-null only while a write runs, so it also marks what can be stopped; copy is busy but not. + const [progress, setProgress] = useState(null); + const abortRef = useRef(null); const exportOptions = useMemo(() => { const rowIndices = - rowScope === 'selected' && canExportSelectedRows + rowScope === 'selected' && hasRows ? [...selectedRowIndices].sort((a, b) => sortedRowIndices.indexOf(a) - sortedRowIndices.indexOf(b)) : [...sortedRowIndices]; let columns: string[]; - if (colScope === 'selected' && canExportSelectedCols) { + if (colScope === 'selected' && hasCols) { columns = visibleColumns.filter((c) => selectedColumns.includes(c)); } else if (colScope === 'all') { columns = [...allColumns]; @@ -63,8 +68,8 @@ export function ExportResultsDialog({ }, [ rowScope, colScope, - canExportSelectedRows, - canExportSelectedCols, + hasRows, + hasCols, selectedRowIndices, selectedColumns, sortedRowIndices, @@ -72,13 +77,10 @@ export function ExportResultsDialog({ allColumns, ]); - const runExport = () => buildExport(result, format, exportOptions); - const copy = async () => { setBusy(true); try { - const text = runExport(); - await api.copyToClipboard(text); + await api.copyToClipboard(buildExport(result, format, exportOptions)); appToast.success(t('toast.exportCopied')); onClose(); } catch (e) { @@ -89,26 +91,47 @@ export function ExportResultsDialog({ }; const saveFile = async () => { + const meta = EXPORT_FORMATS.find((f) => f.id === format); + if (!meta) return; setBusy(true); try { - const text = runExport(); - const meta = EXPORT_FORMATS.find((f) => f.id === format); - if (!meta) return; const path = await api.pickExportSavePath(meta.ext).catch(() => ''); if (!path) return; - await api.saveTextFile(path, text); const fileName = path.split(/[/\\]/).pop() ?? path; + const controller = new AbortController(); + abortRef.current = controller; + setProgress(0); + const { rows, cancelled } = await writeChunkedFile(path, buildExportChunks(result, format, exportOptions), { + onProgress: setProgress, + signal: controller.signal, + }); + if (cancelled) { + // The file exists but is incomplete; never let it pass for a finished export. + appToast.error(t('toast.exportStopped', { fileName, count: rows })); + return; + } appToast.success(t('toast.savedFile', { fileName })); onClose(); } catch (e) { toastError(e, t('errors.exportFailed')); } finally { setBusy(false); + setProgress(null); + abortRef.current = null; + } + }; + + const dismiss = () => { + if (progress !== null) { + abortRef.current?.abort(); + return; } + onClose(); }; return ( - + // dismiss, not onClose: Escape and the backdrop would unmount mid-write, orphaning the export. +
@@ -134,8 +157,8 @@ export function ExportResultsDialog({ type="button" className={`btn btn-sm ${rowScope === 'selected' ? 'active' : ''}`} onClick={() => setRowScope('selected')} - disabled={!canExportSelectedRows} - data-tooltip={canExportSelectedRows ? undefined : t('tooltip.exportSelectRows')} + disabled={!hasRows} + data-tooltip={hasRows ? undefined : t('tooltip.exportSelectRows')} > {t('export.rowsSelected', { count: selectedRowIndices.length })} @@ -151,35 +174,46 @@ export function ExportResultsDialog({ > {t('export.colsAll', { count: allColumns.length })} - + {hasHiddenColumns && ( + + )}
-

- {t('export.summary', { - rows: exportOptions.rowIndices.length, - cols: exportOptions.columns.length, - format: exportFormatLabel(t, format), - })} -

+ {progress === null ? ( +

+ {t('export.summary', { + rows: exportOptions.rowIndices.length, + cols: exportOptions.columns.length, + format: exportFormatLabel(t, format), + })} +

+ ) : ( +
+

+ {t('export.progress', { done: progress, total: exportOptions.rowIndices.length })} +

+ +
+ )}
- - - - {extraRight}
diff --git a/frontend/src/shared/hooks/useGridCopyExport.ts b/frontend/src/shared/hooks/useGridCopyExport.ts index 0046cc0..3725e38 100644 --- a/frontend/src/shared/hooks/useGridCopyExport.ts +++ b/frontend/src/shared/hooks/useGridCopyExport.ts @@ -1,10 +1,9 @@ import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { api } from '@/shared/lib/api'; -import { appToast, toastError } from '@/shared/lib/appToast'; +import { appToast } from '@/shared/lib/appToast'; import { buildExport, - EXPORT_FORMATS, type ExportFormat, type ExportOptions, formatCellCopyValue, @@ -45,8 +44,8 @@ export interface CopyExportInputs { onCopied?: (copied: CopiedCells) => void; } -// Materialize the copied selection as a 2D value grid, mirroring pickSubset's column/row resolution -// so it lines up with the exported text. +// Materialize the copied selection as a 2D value grid, resolving columns and rows the way the +// exporter's subsetView does so it lines up with the exported text. function selectionCells(result: QueryResult, opts: ExportOptions): (string | null)[][] { const colIndices = opts.columns.map((c) => result.columns.indexOf(c)); return opts.rowIndices.map((ri) => { @@ -61,17 +60,13 @@ function selectionCells(result: QueryResult, opts: ExportOptions): (string | nul export interface GridCopyExport { copyFormat: ExportFormat; setCopyFormat: (format: ExportFormat) => void; - /** Disables action buttons while a Wails save dialog is open. */ - exportBusy: boolean; /** `allowSingleCell` enables the focused-cell shortcut when nothing else is selected. */ copyToClipboard: (allowSingleCell: boolean) => Promise; - exportToFile: () => Promise; } export function useGridCopyExport(inputs: CopyExportInputs): GridCopyExport { const { t } = useTranslation(); const [copyFormat, setCopyFormatState] = useState(() => readStoredExportFormat()); - const [exportBusy, setExportBusy] = useState(false); // Ref keeps callbacks stable across re-renders; without it the Ctrl+C listener re-attaches on every render. const inputsRef = useRef(inputs); @@ -125,38 +120,9 @@ export function useGridCopyExport(inputs: CopyExportInputs): GridCopyExport { [copyFormat, t], ); - const exportToFile = useCallback(async () => { - const { result, displayColumns, sortedRowIndices, sortedRowCount, selectionRef } = inputsRef.current; - if (!result) return; - const { rows, cols } = selectionRef.current; - const opts = resolveCopySelection({ - selectedRows: rows, - selectedColumns: cols, - displayColumns, - sortedRowIndices: sortedRowIndices ?? identityIndices(sortedRowCount), - }); - const text = buildExport(result, copyFormat, opts); - const meta = EXPORT_FORMATS.find((f) => f.id === copyFormat); - if (!meta) return; - - setExportBusy(true); - try { - const path = await api.pickExportSavePath(meta.ext).catch(() => ''); - if (!path) return; - await api.saveTextFile(path, text); - appToast.success(t('toast.savedFile', { fileName: path.split(/[/\\]/).pop() ?? path })); - } catch (e) { - toastError(e, t('errors.exportFailed')); - } finally { - setExportBusy(false); - } - }, [copyFormat, t]); - return { copyFormat, setCopyFormat, - exportBusy, copyToClipboard, - exportToFile, }; } diff --git a/frontend/src/shared/lib/api.ts b/frontend/src/shared/lib/api.ts index 617bd94..e1bccd5 100644 --- a/frontend/src/shared/lib/api.ts +++ b/frontend/src/shared/lib/api.ts @@ -1,4 +1,5 @@ import { + AppendTextFile, BeginTransaction, CancelQuery, CheckForUpdates, @@ -124,6 +125,8 @@ export const api = { copyToClipboard: (text: string): Promise => writeClipboardText(text), pickExportSavePath: (ext: string): Promise => PickExportSavePath(ext), saveTextFile: (path: string, content: string): Promise => SaveTextFile(path, content), + appendTextFile: (path: string, chunk: string, truncate: boolean): Promise => + AppendTextFile(path, chunk, truncate), getAppInfo: (): Promise => cast(GetAppInfo()), getPathDefaults: (): Promise => cast(GetPathDefaults()), // Fire-and-forget: the native update window drives the rest of the flow. diff --git a/frontend/src/shared/lib/exportFile.test.ts b/frontend/src/shared/lib/exportFile.test.ts new file mode 100644 index 0000000..faf4b6c --- /dev/null +++ b/frontend/src/shared/lib/exportFile.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { api } from '@/shared/lib/api'; +import { writeChunkedFile } from '@/shared/lib/exportFile'; +import type { ExportChunk } from '@/shared/lib/exportResult'; + +// Stands in for the file on disk, replaying truncate/append the way the Go side would. +function fakeFile() { + const calls: { chunk: string; truncate: boolean }[] = []; + let contents = 'stale contents'; + vi.spyOn(api, 'appendTextFile').mockImplementation(async (_path, chunk, truncate) => { + calls.push({ chunk, truncate }); + contents = truncate ? chunk : contents + chunk; + }); + return { + calls, + get contents() { + return contents; + }, + }; +} + +const chunk = (text: string, rows: number): ExportChunk => ({ text, rows }); +// Large enough to force a flush on its own (WRITE_CHARS is 256 KB). +const bigChunk = (char: string, rows: number) => chunk(char.repeat(256 << 10), rows); + +describe('writeChunkedFile', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('writes a small export as a single truncating call', async () => { + const file = fakeFile(); + const res = await writeChunkedFile('/tmp/out.csv', [chunk('a,b', 0), chunk('\n1,2', 1)]); + expect(file.calls).toEqual([{ chunk: 'a,b\n1,2', truncate: true }]); + expect(res).toEqual({ rows: 1, cancelled: false }); + }); + + it('truncates once and appends after, so chunks concatenate in order', async () => { + const file = fakeFile(); + const res = await writeChunkedFile('/tmp/out.csv', [bigChunk('a', 10), bigChunk('b', 10), chunk('tail', 5)]); + expect(file.calls.length).toBeGreaterThan(1); + expect(file.calls[0].truncate).toBe(true); + expect(file.calls.slice(1).every((c) => !c.truncate)).toBe(true); + expect(file.contents).toBe(`${'a'.repeat(256 << 10)}${'b'.repeat(256 << 10)}tail`); + expect(res).toEqual({ rows: 25, cancelled: false }); + }); + + // Otherwise an empty export would leave whatever the file held before. + it('still truncates when there is nothing to write', async () => { + const file = fakeFile(); + const res = await writeChunkedFile('/tmp/out.csv', []); + expect(file.calls).toEqual([{ chunk: '', truncate: true }]); + expect(file.contents).toBe(''); + expect(res).toEqual({ rows: 0, cancelled: false }); + }); + + it('propagates a write failure instead of reporting success', async () => { + vi.spyOn(api, 'appendTextFile').mockRejectedValue(new Error('disk full')); + await expect(writeChunkedFile('/tmp/out.csv', [chunk('x', 1)])).rejects.toThrow('disk full'); + }); + + describe('progress', () => { + it('reports the cumulative rows on disk, once per write', async () => { + fakeFile(); + const seen: number[] = []; + await writeChunkedFile('/tmp/out.csv', [bigChunk('a', 10), bigChunk('b', 20), chunk('tail', 5)], { + onProgress: (rows) => seen.push(rows), + }); + expect(seen).toEqual([10, 30, 35]); + }); + + it('reports once for an export small enough to be a single write', async () => { + fakeFile(); + const seen: number[] = []; + await writeChunkedFile('/tmp/out.csv', [chunk('a', 3)], { onProgress: (rows) => seen.push(rows) }); + expect(seen).toEqual([3]); + }); + }); + + describe('cancellation', () => { + it('stops writing and reports the rows that made it', async () => { + const file = fakeFile(); + const controller = new AbortController(); + // Abort once the first write lands. + const res = await writeChunkedFile('/tmp/out.csv', [bigChunk('a', 10), bigChunk('b', 20), chunk('tail', 5)], { + onProgress: () => controller.abort(), + signal: controller.signal, + }); + expect(res).toEqual({ rows: 10, cancelled: true }); + expect(file.calls).toHaveLength(1); + expect(file.contents).toBe('a'.repeat(256 << 10)); + }); + + it('reports cancelled rather than throwing, so callers can message it', async () => { + fakeFile(); + const controller = new AbortController(); + controller.abort(); + const res = await writeChunkedFile('/tmp/out.csv', [chunk('x', 1)], { signal: controller.signal }); + expect(res).toEqual({ rows: 0, cancelled: true }); + }); + + // A generator is lazy: aborting must stop pulling from it, not just stop writing. + it('stops pulling chunks from the generator', async () => { + fakeFile(); + const controller = new AbortController(); + let pulled = 0; + function* chunks(): Generator { + for (let i = 0; i < 100; i++) { + pulled++; + yield bigChunk('a', 1); + } + } + await writeChunkedFile('/tmp/out.csv', chunks(), { + onProgress: () => controller.abort(), + signal: controller.signal, + }); + expect(pulled).toBe(2); + }); + }); +}); diff --git a/frontend/src/shared/lib/exportFile.ts b/frontend/src/shared/lib/exportFile.ts new file mode 100644 index 0000000..f843f12 --- /dev/null +++ b/frontend/src/shared/lib/exportFile.ts @@ -0,0 +1,50 @@ +import { api } from '@/shared/lib/api'; +import type { ExportChunk } from '@/shared/lib/exportResult'; + +// Also the progress granularity: progress advances once per write. +const WRITE_CHARS = 256 << 10; + +export interface ChunkedWriteOptions { + /** Cumulative rows written, between bridge calls so the UI can paint. */ + onProgress?: (rows: number) => void; + signal?: AbortSignal; +} + +export interface ChunkedWriteResult { + rows: number; + /** The file holds `rows` rows and is missing the format's closing text. */ + cancelled: boolean; +} + +/** The first write truncates, so an empty export replaces the target. Cancelling is a result, not a + * throw; a write failure still rejects. */ +export async function writeChunkedFile( + path: string, + chunks: Iterable, + opts: ChunkedWriteOptions = {}, +): Promise { + const { onProgress, signal } = opts; + let pending = ''; + let pendingRows = 0; + let written = 0; + let started = false; + + const flush = async () => { + await api.appendTextFile(path, pending, !started); + started = true; + written += pendingRows; + pending = ''; + pendingRows = 0; + onProgress?.(written); + }; + + if (signal?.aborted) return { rows: 0, cancelled: true }; + for (const chunk of chunks) { + if (signal?.aborted) return { rows: written, cancelled: true }; + pending += chunk.text; + pendingRows += chunk.rows; + if (pending.length >= WRITE_CHARS) await flush(); + } + if (pending !== '' || !started) await flush(); + return { rows: written, cancelled: false }; +} diff --git a/frontend/src/shared/lib/exportResult.test.ts b/frontend/src/shared/lib/exportResult.test.ts index 472efdb..9252872 100644 --- a/frontend/src/shared/lib/exportResult.test.ts +++ b/frontend/src/shared/lib/exportResult.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import { buildExport, + buildExportChunks, + type ExportChunk, + type ExportFormat, exportResultToText, formatCellCopyValue, resolveCopySelection, @@ -383,3 +386,69 @@ describe('formatCellCopyValue', () => { expect(formatCellCopyValue('hi')).toBe('hi'); }); }); + +describe('buildExportChunks', () => { + const formats: ExportFormat[] = ['text', 'csv', 'json', 'markdown', 'sql']; + const joinText = (chunks: ExportChunk[]) => chunks.map((c) => c.text).join(''); + const totalRows = (chunks: ExportChunk[]) => chunks.reduce((n, c) => n + c.rows, 0); + + // Chunking saves memory; it must not change output. A boundary must not drop or duplicate a separator. + it.each(formats)('joined chunks equal buildExport for %s at every boundary', (format) => { + const result = sample(); + const opts = { columns: result.columns, rowIndices: [0, 1, 2] }; + const want = buildExport(result, format, opts); + for (const rowsPerChunk of [1, 2, 3, 4, 100]) { + const chunks = [...buildExportChunks(result, format, opts, rowsPerChunk)]; + expect(joinText(chunks), `${format} @ ${rowsPerChunk}`).toBe(want); + } + }); + + // Progress is driven off these counts, so they must add up at any boundary - including one + // landing exactly on the last row. + it.each(formats)('chunk row counts sum to the exported rows for %s', (format) => { + const result = sample(); + const opts = { columns: result.columns, rowIndices: [0, 1, 2] }; + for (const rowsPerChunk of [1, 2, 3, 4, 100]) { + const chunks = [...buildExportChunks(result, format, opts, rowsPerChunk)]; + expect(totalRows(chunks), `${format} @ ${rowsPerChunk}`).toBe(3); + } + }); + + it.each(formats)('chunks an empty selection the same as buildExport for %s', (format) => { + const result = sample(); + const opts = { columns: result.columns, rowIndices: [] }; + const chunks = [...buildExportChunks(result, format, opts, 1)]; + expect(joinText(chunks)).toBe(buildExport(result, format, opts)); + expect(totalRows(chunks)).toBe(0); + }); + + it('really does split into several chunks', () => { + const rows = Array.from({ length: 10 }, (_, i) => [i, `n${i}`, null]); + const result = { ...sample(), rows, rowCount: rows.length }; + const opts = { columns: result.columns, rowIndices: rows.map((_, i) => i) }; + const chunks = [...buildExportChunks(result, 'csv', opts, 3)]; + expect(chunks.length).toBeGreaterThan(1); + expect(joinText(chunks)).toBe(buildExport(result, 'csv', opts)); + expect(totalRows(chunks)).toBe(10); + }); + + it('honors the column and row subset', () => { + const chunks = [...buildExportChunks(sample(), 'csv', { columns: ['name'], rowIndices: [2] }, 1)]; + expect(joinText(chunks)).toBe('name\neve'); + }); +}); + +// A duplicated column name is legal SQL; the all-columns path resolves positionally. +describe('exportResultToText - duplicate column names', () => { + it('keeps each duplicate column value', () => { + const r: QueryResult = { + columns: ['a', 'a'], + columnTypes: ['int', 'int'], + rows: [[1, 2]], + rowCount: 1, + affectedRows: 0, + durationMs: 0, + }; + expect(exportResultToText(r, 'csv')).toBe('a,a\n1,2'); + }); +}); diff --git a/frontend/src/shared/lib/exportResult.ts b/frontend/src/shared/lib/exportResult.ts index 9f235a4..7dfafec 100644 --- a/frontend/src/shared/lib/exportResult.ts +++ b/frontend/src/shared/lib/exportResult.ts @@ -9,20 +9,41 @@ export interface ExportOptions { rowIndices: number[]; } -function pickSubset(result: QueryResult, opts: ExportOptions): QueryResult { +const EXPORT_CHUNK_ROWS = 5000; + +// Reads rows in place, so an export never duplicates the result in memory. +interface ExportView { + columns: string[]; + columnTypes: string[]; + rowIndices: Iterable; + cells: (rowIndex: number) => unknown[]; +} + +function* range(count: number): Generator { + for (let i = 0; i < count; i++) yield i; +} + +// Positional, so duplicate column names (SELECT 1 AS a, 2 AS a) keep their own values. +function allColumnsView(result: QueryResult): ExportView { + return { + columns: result.columns, + columnTypes: result.columns.map((_, i) => result.columnTypes?.[i] ?? ''), + rowIndices: range(result.rows.length), + cells: (ri) => result.rows[ri], + }; +} + +function subsetView(result: QueryResult, opts: ExportOptions): ExportView { const colIndices = opts.columns.map((c) => result.columns.indexOf(c)).filter((i) => i >= 0); - const columns = colIndices.map((i) => result.columns[i]); - const rows = opts.rowIndices.map((ri) => { - const row = result.rows[ri]; - if (!row) return colIndices.map(() => null); // guard against a stale out-of-range selection index - return colIndices.map((i) => row[i]); - }); return { - ...result, - columns, + columns: colIndices.map((i) => result.columns[i]), columnTypes: colIndices.map((i) => result.columnTypes?.[i] ?? ''), - rows, - rowCount: rows.length, + rowIndices: opts.rowIndices, + cells: (ri) => { + const row = result.rows[ri]; + if (!row) return colIndices.map(() => null); // guard against a stale out-of-range selection index + return colIndices.map((i) => row[i]); + }, }; } @@ -48,68 +69,167 @@ function sqlLiteral(v: unknown): string { return `'${String(v).replace(/'/g, "''")}'`; } -export function exportResultToText(result: QueryResult, format: ExportFormat): string { - switch (format) { - case 'json': { - // Nest JSON/JSONB columns instead of string-wrapping them (matches the row JSON viewer). - const isJsonCol = result.columns.map((_, i) => /json/i.test(result.columnTypes?.[i] ?? '')); - const rows = result.rows.map((row) => { - const m: Record = {}; - result.columns.forEach((col, i) => { - const v = row[i]; - m[col] = isJsonCol[i] && typeof v === 'string' ? safeJsonParse(v) : v; - }); - return m; +interface RowFormatter { + header: string; + open: string; + sep: string; + close: string; + empty: string; + row: (cells: unknown[]) => string; +} + +// bigint isn't JSON-serializable; emit it as a number (backend already sends out-of-range ints as strings). +function jsonBigint(_key: string, val: unknown): unknown { + return typeof val === 'bigint' ? Number(val) : val; +} + +function jsonFormatter(view: ExportView): RowFormatter { + // Nest JSON/JSONB columns instead of string-wrapping them (matches the row JSON viewer). + const isJsonCol = view.columnTypes.map((t) => /json/i.test(t)); + return { + header: '', + open: '[\n', + sep: ',\n', + close: '\n]', + empty: '[]', + row: (cells) => { + const m: Record = {}; + view.columns.forEach((col, i) => { + const v = cells[i]; + m[col] = isJsonCol[i] && typeof v === 'string' ? safeJsonParse(v) : v; }); - // bigint isn't JSON-serializable; emit it as a number (backend already sends out-of-range ints as strings). - return JSON.stringify(rows, (_k, val) => (typeof val === 'bigint' ? Number(val) : val), 2); - } - case 'csv': { - const escapeCsv = (cell: string) => { - // Mirror Go's encoding/csv: quote on delimiter/quote/newline, leading whitespace, or \. sentinel. - const needsQuote = cell === '\\.' || /[",\n\r]/.test(cell) || /^\s/u.test(cell); - if (needsQuote) return `"${cell.replace(/"/g, '""')}"`; - return cell; - }; - const lines = [result.columns.map(escapeCsv).join(',')]; - for (const row of result.rows) { - lines.push(row.map((v) => (v == null ? '' : escapeCsv(defuseCsvFormula(String(v))))).join(',')); - } - return lines.join('\n'); - } - case 'markdown': { - // Escape headers too, not just cells - a column named `a|b` would break the alignment. - const mdCell = (s: string) => s.replace(/\|/g, '\\|').replace(/\r\n?|\n/g, ' '); - const sep = result.columns.map(() => '---'); - const lines = [`| ${result.columns.map(mdCell).join(' | ')} |`, `| ${sep.join(' | ')} |`]; - for (const row of result.rows) { - const cells = row.map((v) => (v == null ? '' : mdCell(String(v)))); - lines.push(`| ${cells.join(' | ')} |`); - } - return lines.join('\n'); - } - case 'sql': { - const quoteIdent = (id: string) => `"${id.replace(/"/g, '""')}"`; - const table = quoteIdent(result.tableName || 'results'); - const quotedCols = result.columns.map(quoteIdent); - const lines: string[] = []; - for (const row of result.rows) { - const vals = row.map(sqlLiteral); - lines.push(`INSERT INTO ${table} (${quotedCols.join(', ')}) VALUES (${vals.join(', ')});`); - } - return lines.join('\n'); - } - case 'text': { - return result.rows.map((row) => row.map((v) => (v == null ? '' : String(v))).join('\t')).join('\n'); - } + // One level deeper, so it reads as an element of the array JSON.stringify(allRows, …, 2) builds. + return ` ${JSON.stringify(m, jsonBigint, 2).replace(/\n/g, '\n ')}`; + }, + }; +} + +function csvFormatter(view: ExportView): RowFormatter { + const escapeCsv = (cell: string) => { + // Mirror Go's encoding/csv: quote on delimiter/quote/newline, leading whitespace, or \. sentinel. + const needsQuote = cell === '\\.' || /[",\n\r]/.test(cell) || /^\s/u.test(cell); + if (needsQuote) return `"${cell.replace(/"/g, '""')}"`; + return cell; + }; + return { + header: view.columns.map(escapeCsv).join(','), + open: '\n', + sep: '\n', + close: '', + empty: '', + row: (cells) => cells.map((v) => (v == null ? '' : escapeCsv(defuseCsvFormula(String(v))))).join(','), + }; +} + +function markdownFormatter(view: ExportView): RowFormatter { + // Escape headers too, not just cells - a column named `a|b` would break the alignment. + const mdCell = (s: string) => s.replace(/\|/g, '\\|').replace(/\r\n?|\n/g, ' '); + const sep = view.columns.map(() => '---'); + return { + header: `| ${view.columns.map(mdCell).join(' | ')} |\n| ${sep.join(' | ')} |`, + open: '\n', + sep: '\n', + close: '', + empty: '', + row: (cells) => `| ${cells.map((v) => (v == null ? '' : mdCell(String(v)))).join(' | ')} |`, + }; +} + +function sqlFormatter(view: ExportView, tableName: string | undefined): RowFormatter { + const quoteIdent = (id: string) => `"${id.replace(/"/g, '""')}"`; + const table = quoteIdent(tableName || 'results'); + const quotedCols = view.columns.map(quoteIdent).join(', '); + return { + header: '', + open: '', + sep: '\n', + close: '', + empty: '', + row: (cells) => `INSERT INTO ${table} (${quotedCols}) VALUES (${cells.map(sqlLiteral).join(', ')});`, + }; +} + +const TEXT_FORMATTER: RowFormatter = { + header: '', + open: '', + sep: '\n', + close: '', + empty: '', + row: (cells) => cells.map((v) => (v == null ? '' : String(v))).join('\t'), +}; + +function rowFormatter(view: ExportView, format: ExportFormat, tableName: string | undefined): RowFormatter | null { + switch (format) { + case 'json': + return jsonFormatter(view); + case 'csv': + return csvFormatter(view); + case 'markdown': + return markdownFormatter(view); + case 'sql': + return sqlFormatter(view, tableName); + case 'text': + return TEXT_FORMATTER; default: - return ''; + return null; + } +} + +export interface ExportChunk { + text: string; + /** Rows in this chunk, not cumulative. */ + rows: number; +} + +function* formatView( + view: ExportView, + format: ExportFormat, + tableName: string | undefined, + rowsPerChunk: number, +): Generator { + const fmt = rowFormatter(view, format, tableName); + if (!fmt) return; + + let pending = fmt.header; + let pendingRows = 0; + let written = 0; + for (const rowIndex of view.rowIndices) { + pending += written === 0 ? fmt.open : fmt.sep; + pending += fmt.row(view.cells(rowIndex)); + written++; + pendingRows++; + if (written % rowsPerChunk === 0) { + yield { text: pending, rows: pendingRows }; + pending = ''; + pendingRows = 0; + } + } + pending += written === 0 ? fmt.empty : fmt.close; + if (pending !== '') yield { text: pending, rows: pendingRows }; +} + +/** Joining every chunk's text gives exactly what buildExport returns. */ +export function buildExportChunks( + result: QueryResult, + format: ExportFormat, + opts: ExportOptions, + rowsPerChunk: number = EXPORT_CHUNK_ROWS, +): Generator { + return formatView(subsetView(result, opts), format, result.tableName, rowsPerChunk); +} + +export function exportResultToText(result: QueryResult, format: ExportFormat): string { + let out = ''; + for (const chunk of formatView(allColumnsView(result), format, result.tableName, EXPORT_CHUNK_ROWS)) { + out += chunk.text; } + return out; } export function buildExport(result: QueryResult, format: ExportFormat, opts: ExportOptions): string { - const subset = pickSubset(result, opts); - return exportResultToText(subset, format); + let out = ''; + for (const chunk of buildExportChunks(result, format, opts)) out += chunk.text; + return out; } export const EXPORT_FORMATS: { id: ExportFormat; label: string; ext: string }[] = [ diff --git a/frontend/src/styles/results.css b/frontend/src/styles/results.css index 3dfd1d7..5e65c4a 100644 --- a/frontend/src/styles/results.css +++ b/frontend/src/styles/results.css @@ -301,6 +301,34 @@ margin: var(--space-4) 0 0; } +.export-progress-bar { + /* Reset the native widget so the track/fill colours below apply. */ + appearance: none; + display: block; + width: 100%; + height: 0.25rem; + margin-top: var(--space-6); + border: none; + border-radius: var(--radius-pill); + background: var(--bg-hover); + color: var(--accent); +} + +.export-progress-bar::-webkit-progress-bar { + background: var(--bg-hover); + border-radius: var(--radius-pill); +} + +.export-progress-bar::-webkit-progress-value { + background: var(--accent); + border-radius: var(--radius-pill); +} + +.export-progress-bar::-moz-progress-bar { + background: var(--accent); + border-radius: var(--radius-pill); +} + .results-table-wrap { flex: 1; min-height: 0; diff --git a/internal/app/app_files.go b/internal/app/app_files.go index cbdb313..25fbf04 100644 --- a/internal/app/app_files.go +++ b/internal/app/app_files.go @@ -102,3 +102,24 @@ func (a *App) SaveTextFile(path, content string) error { } return os.WriteFile(path, []byte(content), 0o600) } + +// AppendTextFile writes one chunk, emptying the file first when truncate is set. Each chunk opens and +// closes the file, so nothing leaks if the caller stops part-way. +func (a *App) AppendTextFile(path, chunk string, truncate bool) error { + if path == "" { + return fmt.Errorf("path is empty") + } + flags := os.O_WRONLY | os.O_CREATE | os.O_APPEND + if truncate { + flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC + } + file, err := os.OpenFile(path, flags, 0o600) + if err != nil { + return err + } + if _, err := file.WriteString(chunk); err != nil { + _ = file.Close() + return err + } + return file.Close() +} diff --git a/internal/app/app_files_test.go b/internal/app/app_files_test.go new file mode 100644 index 0000000..99536f3 --- /dev/null +++ b/internal/app/app_files_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAppendTextFileStreamsChunksInOrder(t *testing.T) { + a := &App{} + path := filepath.Join(t.TempDir(), "export.csv") + + if err := a.AppendTextFile(path, "a,b\n", true); err != nil { + t.Fatalf("first chunk: %v", err) + } + if err := a.AppendTextFile(path, "1,2\n", false); err != nil { + t.Fatalf("second chunk: %v", err) + } + if err := a.AppendTextFile(path, "3,4", false); err != nil { + t.Fatalf("third chunk: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if want := "a,b\n1,2\n3,4"; string(got) != want { + t.Errorf("got %q, want %q", string(got), want) + } +} + +// A second export to the same path must replace the file, not append to the previous one. +func TestAppendTextFileTruncatesExistingContents(t *testing.T) { + a := &App{} + path := filepath.Join(t.TempDir(), "export.csv") + if err := os.WriteFile(path, []byte("a much longer previous export"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + if err := a.AppendTextFile(path, "fresh", true); err != nil { + t.Fatalf("truncating write: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != "fresh" { + t.Errorf("got %q, want %q", string(got), "fresh") + } +} + +func TestAppendTextFileCreatesWithOwnerOnlyPermissions(t *testing.T) { + a := &App{} + path := filepath.Join(t.TempDir(), "export.csv") + if err := a.AppendTextFile(path, "x", true); err != nil { + t.Fatalf("write: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + // Matches SaveTextFile: an export can hold query results. + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("mode = %v, want 0600", perm) + } +} + +func TestAppendTextFileRejectsEmptyPath(t *testing.T) { + a := &App{} + if err := a.AppendTextFile("", "x", true); err == nil { + t.Error("an empty path should fail") + } +}