From 96a194fed28e7f0d59be0116fa4ed4eefb59d4f4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 15:47:13 +0300 Subject: [PATCH] fix(csv): stream file export and build clipboard CSV off the UI isolate (#277) - Write CSV to disk via IOSink in writeResultGridCsv instead of assembling one giant String before File.writeAsString. - Add resultGridAsCsvAsync (Isolate.run) and use it for Copy as CSV so large grids do not freeze the Flutter UI isolate. - Rebuild resultGridAsCsv with StringBuffer to reduce intermediate allocations. - Cover streaming and async paths with unit tests. --- lib/core/csv/result_grid_csv.dart | 64 ++++++++++++++++++----- lib/core/csv/save_result_grid_csv.dart | 16 ++++-- lib/features/main_screen/results_tab.dart | 6 ++- test/core/csv/result_grid_csv_test.dart | 59 +++++++++++++++++++++ 4 files changed, 128 insertions(+), 17 deletions(-) diff --git a/lib/core/csv/result_grid_csv.dart b/lib/core/csv/result_grid_csv.dart index 2fa60b6a..f58e157f 100644 --- a/lib/core/csv/result_grid_csv.dart +++ b/lib/core/csv/result_grid_csv.dart @@ -1,6 +1,9 @@ /// RFC 4180–style CSV for a result grid (header + rows). library; +import 'dart:io'; +import 'dart:isolate'; + String escapeCsvField(String s) { final needsQuotes = s.contains(',') || s.contains('"') || @@ -12,18 +15,55 @@ String escapeCsvField(String s) { return s; } +/// Formats a single CSV data row, padding short rows to [columnCount]. +String formatCsvDataRow(List row, int columnCount) { + final buf = StringBuffer(); + for (var i = 0; i < columnCount; i++) { + if (i > 0) buf.write(','); + if (i < row.length) buf.write(escapeCsvField(row[i])); + } + return buf.toString(); +} + /// One line per row; pads short rows with empty cells to [columns.length]. +/// +/// Prefer [resultGridAsCsvAsync] for large grids on the UI isolate, and +/// [writeResultGridCsv] when writing to a file. String resultGridAsCsv(List columns, List> rows) { - final lines = [ - columns.map(escapeCsvField).join(','), - ...rows.map((r) { - final cells = List.generate( - columns.length, - (i) => i < r.length ? escapeCsvField(r[i]) : '', - growable: false, - ); - return cells.join(','); - }), - ]; - return lines.join('\n'); + final buf = StringBuffer(); + buf.write(columns.map(escapeCsvField).join(',')); + for (final row in rows) { + buf.write('\n'); + buf.write(formatCsvDataRow(row, columns.length)); + } + return buf.toString(); +} + +/// Builds CSV off the UI isolate so large grids do not freeze the main thread. +Future resultGridAsCsvAsync( + List columns, + List> rows, +) { + return Isolate.run(() => resultGridAsCsv(columns, rows)); +} + +/// Streams CSV to [sink] without assembling the full document in memory. +Future writeResultGridCsv( + IOSink sink, { + required List columns, + required List> rows, +}) async { + sink.write(columns.map(escapeCsvField).join(',')); + var written = 0; + for (final row in rows) { + sink.write('\n'); + sink.write(formatCsvDataRow(row, columns.length)); + written++; + // Yield periodically so a large export does not starve the event loop + // when this runs on the UI isolate. + if (written % 500 == 0) { + await Future.delayed(Duration.zero); + } + } + await sink.flush(); } diff --git a/lib/core/csv/save_result_grid_csv.dart b/lib/core/csv/save_result_grid_csv.dart index b9a2f361..2e69a6d9 100644 --- a/lib/core/csv/save_result_grid_csv.dart +++ b/lib/core/csv/save_result_grid_csv.dart @@ -16,13 +16,12 @@ enum SaveResultGridCsvOutcome { error, } -/// Opens a platform save dialog and writes [columns]/[rows] as CSV. +/// Opens a platform save dialog and streams [columns]/[rows] as CSV to disk. Future saveResultGridCsvFile({ required List columns, required List> rows, String? suggestedName, }) async { - final csv = resultGridAsCsv(columns, rows); final name = suggestedName ?? 'querya_results_${DateTime.now().toIso8601String().replaceAll(':', '-')}.csv'; final location = await getSaveLocation( @@ -35,10 +34,21 @@ Future saveResultGridCsvFile({ if (path == null || path.isEmpty) { return SaveResultGridCsvOutcome.cancelled; } + IOSink? sink; try { - await File(path).writeAsString(csv); + sink = File(path).openWrite(); + await writeResultGridCsv( + sink, + columns: columns, + rows: rows, + ); + await sink.close(); + sink = null; return SaveResultGridCsvOutcome.written; } on Object { + try { + await sink?.close(); + } catch (_) {} return SaveResultGridCsvOutcome.error; } } diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index c7f2fd47..12d41db3 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -123,8 +123,10 @@ class ResultsTab extends StatelessWidget { OutlineButton( size: ButtonSize.small, onPressed: () { - final csv = resultGridAsCsv(columns, rows); - Clipboard.setData(ClipboardData(text: csv)); + unawaited(() async { + final csv = await resultGridAsCsvAsync(columns, rows); + await Clipboard.setData(ClipboardData(text: csv)); + }()); }, leading: const material.Icon( material.Icons.copy_rounded, diff --git a/test/core/csv/result_grid_csv_test.dart b/test/core/csv/result_grid_csv_test.dart index e1be60cb..6c5d75c2 100644 --- a/test/core/csv/result_grid_csv_test.dart +++ b/test/core/csv/result_grid_csv_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/csv/result_grid_csv.dart'; @@ -29,4 +31,61 @@ void main() { ); }); }); + + group('resultGridAsCsvAsync', () { + test('matches synchronous result off the UI isolate', () async { + const columns = ['a', 'b']; + const rows = [ + ['1', 'two,comma'], + ['quote', 'say "hi"'], + ]; + final asyncCsv = await resultGridAsCsvAsync(columns, rows); + expect(asyncCsv, resultGridAsCsv(columns, rows)); + }); + }); + + group('writeResultGridCsv', () { + test('streams the same content as resultGridAsCsv', () async { + const columns = ['a', 'b']; + const rows = [ + ['1', 'two,comma'], + ['quote', 'say "hi"'], + ]; + final file = File( + '${Directory.systemTemp.path}/querya_csv_stream_${DateTime.now().microsecondsSinceEpoch}.csv', + ); + try { + final sink = file.openWrite(); + await writeResultGridCsv(sink, columns: columns, rows: rows); + await sink.close(); + expect(await file.readAsString(), resultGridAsCsv(columns, rows)); + } finally { + if (await file.exists()) await file.delete(); + } + }); + + test('streams large grids without building one giant string first', () async { + const columns = ['id', 'value']; + final rows = List.generate( + 2500, + (i) => ['$i', 'value_$i'], + growable: false, + ); + final file = File( + '${Directory.systemTemp.path}/querya_csv_large_${DateTime.now().microsecondsSinceEpoch}.csv', + ); + try { + final sink = file.openWrite(); + await writeResultGridCsv(sink, columns: columns, rows: rows); + await sink.close(); + final lines = await file.readAsLines(); + expect(lines.length, 2501); + expect(lines.first, 'id,value'); + expect(lines[1], '0,value_0'); + expect(lines.last, '2499,value_2499'); + } finally { + if (await file.exists()) await file.delete(); + } + }); + }); }