Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions lib/core/widgets/virtual_selectable_text_view.dart
Original file line number Diff line number Diff line change
@@ -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,
);
},
);
}
}
16 changes: 7 additions & 9 deletions lib/features/main_screen/results_tab.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
),
);
}
Expand Down
22 changes: 9 additions & 13 deletions lib/features/mysql/mysql_routine_view.dart
Original file line number Diff line number Diff line change
@@ -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`).
Expand Down Expand Up @@ -165,23 +166,18 @@ class _MysqlRoutineViewState extends material.State<MysqlRoutineView> {
)
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,
),
),
),
Expand Down
8 changes: 4 additions & 4 deletions lib/features/mysql/mysql_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,10 @@ class _MysqlSqlWorkspaceState extends material.State<MysqlSqlWorkspace> {
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) {
Expand Down
19 changes: 19 additions & 0 deletions lib/features/postgresql/postgres_result_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// Serializable row batch for [convertPostgresResultRowsToStrings] in a worker isolate.
class PostgresResultConvertJob {
const PostgresResultConvertJob({
required this.rowValues,
});

final List<List<Object?>> rowValues;
}

/// Converts PostgreSQL result cell values to display strings off the UI thread.
List<List<String>> convertPostgresResultRowsToStrings(PostgresResultConvertJob job) {
return job.rowValues
.map(
(row) => row
.map((value) => value == null ? 'NULL' : value.toString())
.toList(),
)
.toList();
}
6 changes: 4 additions & 2 deletions lib/features/postgresql/postgres_routine_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -243,8 +244,9 @@ class _PostgresRoutineViewState extends material.State<PostgresRoutineView> {
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,
Expand Down
16 changes: 9 additions & 7 deletions lib/features/postgresql/postgres_sql_workspace.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -348,15 +350,20 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
);
}

final outRows = <List<String>>[];
final rawRows = <List<Object?>>[];
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;
Expand Down Expand Up @@ -410,11 +417,6 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
}
}

static String _cellText(Object? v) {
if (v == null) return 'NULL';
return v.toString();
}

Future<void> _openSqlFile() async {
try {
final file = await openFile(
Expand Down
19 changes: 19 additions & 0 deletions lib/features/sqlite/sqlite_result_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// Serializable row batch for [convertSqliteResultRowsToStrings] in a worker isolate.
class SqliteResultConvertJob {
const SqliteResultConvertJob({
required this.rowValues,
});

final List<List<Object?>> rowValues;
}

/// Converts SQLite result cell values to display strings off the UI thread.
List<List<String>> convertSqliteResultRowsToStrings(SqliteResultConvertJob job) {
return job.rowValues
.map(
(row) => row
.map((value) => value == null ? 'NULL' : value.toString())
.toList(),
)
.toList();
}
15 changes: 9 additions & 6 deletions lib/features/sqlite/sqlite_sql_workspace.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -167,14 +169,15 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
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;
Expand Down
44 changes: 44 additions & 0 deletions test/core/widgets/virtual_selectable_text_view_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
66 changes: 66 additions & 0 deletions test/features/sql_workspaces/result_conversion_perf_test.dart
Original file line number Diff line number Diff line change
@@ -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');
});
});
}
Loading