diff --git a/lib/core/widgets/virtual_selectable_text_view.dart b/lib/core/widgets/virtual_selectable_text_view.dart new file mode 100644 index 00000000..0cbca49b --- /dev/null +++ b/lib/core/widgets/virtual_selectable_text_view.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart' as material; + +/// Renders long text efficiently. If line count <= [threshold], uses a single +/// `SelectableText` inside `SingleChildScrollView` so multi-line selection across +/// the entire block works seamlessly. +/// If line count > [threshold], virtualizes lines using `ListView.builder` to +/// ensure 60 FPS scrolling and rendering without UI jank. +class VirtualSelectableTextView extends material.StatelessWidget { + const VirtualSelectableTextView({ + super.key, + required this.text, + this.style, + this.threshold = 200, + this.padding = const material.EdgeInsets.all(16), + }); + + final String text; + final material.TextStyle? style; + final int threshold; + final material.EdgeInsets padding; + + @override + material.Widget build(material.BuildContext context) { + final lines = text.split('\n'); + if (lines.length <= threshold) { + return material.SingleChildScrollView( + padding: padding, + child: material.SelectableText( + text, + style: style, + ), + ); + } + + return material.ListView.builder( + padding: padding, + itemCount: lines.length, + itemBuilder: (context, index) { + return material.SelectableText( + lines[index], + style: style, + ); + }, + ); + } +} diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index efcbc400..58ac59c4 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -1,6 +1,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -32,15 +33,12 @@ class ResultsTab extends StatelessWidget { ); } if (errorMessage != null && errorMessage!.isNotEmpty) { - return material.SingleChildScrollView( - padding: const material.EdgeInsets.all(16), - child: material.SelectableText( - errorMessage!, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: Theme.of(context).colorScheme.destructive, - ), + return VirtualSelectableTextView( + text: errorMessage!, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Theme.of(context).colorScheme.destructive, ), ); } diff --git a/lib/features/mysql/mysql_routine_view.dart b/lib/features/mysql/mysql_routine_view.dart index bd94c14a..2b977072 100644 --- a/lib/features/mysql/mysql_routine_view.dart +++ b/lib/features/mysql/mysql_routine_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Displays MySQL routine DDL (`SHOW CREATE PROCEDURE` / `SHOW CREATE FUNCTION`). @@ -165,23 +166,18 @@ class _MysqlRoutineViewState extends material.State { ) else if (_error != null) material.Expanded( - child: material.Center( - child: material.SelectableText( - _error!, - style: material.TextStyle(color: cs.destructive, fontSize: 13), - ), + child: VirtualSelectableTextView( + text: _error!, + style: material.TextStyle(color: cs.destructive, fontSize: 13), ), ) else material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(16), - child: material.SelectableText( - _ddlText ?? '', - style: const material.TextStyle( - fontFamily: 'monospace', - fontSize: 13, - ), + child: VirtualSelectableTextView( + text: _ddlText ?? '', + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 13, ), ), ), diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index a63c7d71..0a79a82b 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -207,10 +207,10 @@ class _MysqlSqlWorkspaceState extends material.State { n++; } - final outRows = await compute( - convertMysqlResultRowsToStrings, - MysqlResultConvertJob(rowValues: rawRows), - ); + final job = MysqlResultConvertJob(rowValues: rawRows); + final outRows = rawRows.length > 500 + ? await compute(convertMysqlResultRowsToStrings, job) + : convertMysqlResultRowsToStrings(job); int? affected; if (cols.isEmpty && outRows.isEmpty) { diff --git a/lib/features/postgresql/postgres_result_utils.dart b/lib/features/postgresql/postgres_result_utils.dart new file mode 100644 index 00000000..a6aaf18d --- /dev/null +++ b/lib/features/postgresql/postgres_result_utils.dart @@ -0,0 +1,19 @@ +/// Serializable row batch for [convertPostgresResultRowsToStrings] in a worker isolate. +class PostgresResultConvertJob { + const PostgresResultConvertJob({ + required this.rowValues, + }); + + final List> rowValues; +} + +/// Converts PostgreSQL result cell values to display strings off the UI thread. +List> convertPostgresResultRowsToStrings(PostgresResultConvertJob job) { + return job.rowValues + .map( + (row) => row + .map((value) => value == null ? 'NULL' : value.toString()) + .toList(), + ) + .toList(); +} diff --git a/lib/features/postgresql/postgres_routine_view.dart b/lib/features/postgresql/postgres_routine_view.dart index a52b59de..f938d6eb 100644 --- a/lib/features/postgresql/postgres_routine_view.dart +++ b/lib/features/postgresql/postgres_routine_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows [pg_get_functiondef] for each overload of a PostgreSQL function. @@ -243,8 +244,9 @@ class _PostgresRoutineViewState extends material.State { color: cs.border.withValues(alpha: 0.4), ), ), - child: material.SelectableText( - _overloads[i].definition, + child: VirtualSelectableTextView( + text: _overloads[i].definition, + padding: material.EdgeInsets.zero, style: material.TextStyle( fontFamily: 'monospace', fontSize: 12, diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 34204167..5e266483 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart' show compute; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:file_selector/file_selector.dart'; +import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:postgres/postgres.dart' as pg; @@ -348,15 +350,20 @@ class _PostgresSqlWorkspaceState extends material.State { ); } - final outRows = >[]; + final rawRows = >[]; var n = 0; final cap = _resultMaxRows; for (final row in result) { if (n >= cap) break; - outRows.add(row.map(_cellText).toList()); + rawRows.add(row.toList()); n++; } + final job = PostgresResultConvertJob(rowValues: rawRows); + final outRows = rawRows.length > 500 + ? await compute(convertPostgresResultRowsToStrings, job) + : convertPostgresResultRowsToStrings(job); + setState(() { _columns = cols; _rows = outRows; @@ -410,11 +417,6 @@ class _PostgresSqlWorkspaceState extends material.State { } } - static String _cellText(Object? v) { - if (v == null) return 'NULL'; - return v.toString(); - } - Future _openSqlFile() async { try { final file = await openFile( diff --git a/lib/features/sqlite/sqlite_result_utils.dart b/lib/features/sqlite/sqlite_result_utils.dart new file mode 100644 index 00000000..0f616c68 --- /dev/null +++ b/lib/features/sqlite/sqlite_result_utils.dart @@ -0,0 +1,19 @@ +/// Serializable row batch for [convertSqliteResultRowsToStrings] in a worker isolate. +class SqliteResultConvertJob { + const SqliteResultConvertJob({ + required this.rowValues, + }); + + final List> rowValues; +} + +/// Converts SQLite result cell values to display strings off the UI thread. +List> convertSqliteResultRowsToStrings(SqliteResultConvertJob job) { + return job.rowValues + .map( + (row) => row + .map((value) => value == null ? 'NULL' : value.toString()) + .toList(), + ) + .toList(); +} diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 0956e858..718076b7 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart' show compute; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:file_selector/file_selector.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; @@ -167,14 +169,15 @@ class _SqliteSqlWorkspaceState extends material.State { final truncated = results.length > cap; final limitCount = truncated ? cap : results.length; - final rawRows = results.take(limitCount).toList(); - final outRows = rawRows.map((row) { - return cols.map((col) { - final val = row[col]; - return val == null ? 'NULL' : val.toString(); - }).toList(); + final rawRows = results.take(limitCount).map((row) { + return cols.map((col) => row[col]).toList(); }).toList(); + final job = SqliteResultConvertJob(rowValues: rawRows); + final outRows = rawRows.length > 500 + ? await compute(convertSqliteResultRowsToStrings, job) + : convertSqliteResultRowsToStrings(job); + setState(() { _columns = cols; _rows = outRows; diff --git a/test/core/widgets/virtual_selectable_text_view_test.dart b/test/core/widgets/virtual_selectable_text_view_test.dart new file mode 100644 index 00000000..3cf5439f --- /dev/null +++ b/test/core/widgets/virtual_selectable_text_view_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; + +void main() { + group('VirtualSelectableTextView', () { + testWidgets('renders SingleChildScrollView + SelectableText below threshold', (tester) async { + const text = 'line 1\nline 2\nline 3'; + await tester.pumpWidget( + const material.MaterialApp( + home: material.Scaffold( + body: VirtualSelectableTextView( + text: text, + threshold: 10, + ), + ), + ), + ); + + expect(find.byType(material.SingleChildScrollView), findsOneWidget); + expect(find.byType(material.ListView), findsNothing); + expect(find.text(text), findsOneWidget); + }); + + testWidgets('renders ListView.builder above threshold', (tester) async { + final text = List.generate(50, (i) => 'Virtual Line $i').join('\n'); + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: VirtualSelectableTextView( + text: text, + threshold: 10, + ), + ), + ), + ); + + expect(find.byType(material.SingleChildScrollView), findsNothing); + expect(find.byType(material.ListView), findsOneWidget); + expect(find.text('Virtual Line 0'), findsOneWidget); + expect(find.text('Virtual Line 1'), findsOneWidget); + }); + }); +} diff --git a/test/features/sql_workspaces/result_conversion_perf_test.dart b/test/features/sql_workspaces/result_conversion_perf_test.dart new file mode 100644 index 00000000..902d9a8b --- /dev/null +++ b/test/features/sql_workspaces/result_conversion_perf_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/mysql/mysql_result_utils.dart'; +import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; + +void main() { + group('Result Conversion Job & Utilities', () { + test('convertMysqlResultRowsToStrings maps nulls and primitives correctly', () { + final rawRows = [ + [1, 'hello', null, 3.14, true], + [2, 'world', 'abc', null, false], + ]; + final job = MysqlResultConvertJob(rowValues: rawRows); + final out = convertMysqlResultRowsToStrings(job); + + expect(out.length, 2); + expect(out[0], ['1', 'hello', 'NULL', '3.14', 'true']); + expect(out[1], ['2', 'world', 'abc', 'NULL', 'false']); + }); + + test('convertPostgresResultRowsToStrings maps nulls and primitives correctly', () { + final rawRows = [ + [100, null, 'pg_test'], + [null, 999, 'foo'], + ]; + final job = PostgresResultConvertJob(rowValues: rawRows); + final out = convertPostgresResultRowsToStrings(job); + + expect(out.length, 2); + expect(out[0], ['100', 'NULL', 'pg_test']); + expect(out[1], ['NULL', '999', 'foo']); + }); + + test('convertSqliteResultRowsToStrings maps nulls and primitives correctly', () { + final rawRows = [ + ['sqlite', null, 42], + [null, null, null], + ]; + final job = SqliteResultConvertJob(rowValues: rawRows); + final out = convertSqliteResultRowsToStrings(job); + + expect(out.length, 2); + expect(out[0], ['sqlite', 'NULL', '42']); + expect(out[1], ['NULL', 'NULL', 'NULL']); + }); + + test('All convert jobs handle large batches efficiently', () { + final rawBatch = List.generate( + 2000, + (r) => List.generate(15, (c) => c % 3 == 0 ? null : 'row_${r}_col_$c'), + ); + + final pgJob = PostgresResultConvertJob(rowValues: rawBatch); + final pgOut = convertPostgresResultRowsToStrings(pgJob); + expect(pgOut.length, 2000); + expect(pgOut.first[0], 'NULL'); + expect(pgOut.first[1], 'row_0_col_1'); + + final sqliteJob = SqliteResultConvertJob(rowValues: rawBatch); + final sqliteOut = convertSqliteResultRowsToStrings(sqliteJob); + expect(sqliteOut.length, 2000); + expect(sqliteOut[100][0], 'NULL'); + expect(sqliteOut[100][1], 'row_100_col_1'); + }); + }); +}