From 30af284c31eefd90c1e70e4b665204432c9f2a40 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:08:19 +0300 Subject: [PATCH 01/23] perf(sqlite): inject LIMIT before materializing SQL results Share injectSqlLimit via sql_limit.dart and apply it in the SQLite SQL workspace so SELECT/WITH/VALUES are bounded at the engine, not only after rawQuery loads the full set. Document row-cap behavior. Closes #415 --- docs/user-guide.md | 4 + lib/core/database/postgres_sql.dart | 55 +------------ lib/core/database/sql_limit.dart | 59 ++++++++++++++ lib/features/sqlite/sqlite_sql_workspace.dart | 15 ++-- test/core/database/sql_limit_test.dart | 80 +++++++++++++++++++ 5 files changed, 157 insertions(+), 56 deletions(-) create mode 100644 lib/core/database/sql_limit.dart create mode 100644 test/core/database/sql_limit_test.dart diff --git a/docs/user-guide.md b/docs/user-guide.md index 12997878..bba0f411 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -44,4 +44,8 @@ Preferences (except secrets) live in the same local SQLite file as connection me High-level feature depth varies by database type. PostgreSQL, MySQL, and SQLite include rich object trees and SQL workspaces (with SQLite utilizing local `.db` files); Redis and MongoDB focus on data exploration and commands suitable for day-to-day development. +### Result row caps (SQL workspaces) + +Preferences → **Max rows in results** caps how many rows the grid loads. For **PostgreSQL** and **SQLite**, ad-hoc `SELECT` / `WITH` / `VALUES` without an author `LIMIT` get a `LIMIT` injected before execution so the engine does not materialize an unbounded result. Queries that already include `LIMIT`, and non-SELECT statements (`INSERT`, `PRAGMA`, …), are left unchanged; the UI may still truncate the displayed grid as a fallback. + For troubleshooting build/run issues, see the main [README.md](../README.md). diff --git a/lib/core/database/postgres_sql.dart b/lib/core/database/postgres_sql.dart index 8f9de124..0eff57f6 100644 --- a/lib/core/database/postgres_sql.dart +++ b/lib/core/database/postgres_sql.dart @@ -1,19 +1,9 @@ // Helpers for ad-hoc SQL workspace (transactions, stripping comments). -/// Removes leading whitespace and `--` line comments (not `/* */`). -String stripLeadingWhitespaceAndLineComments(String sql) { - var s = sql.trimLeft(); - while (true) { - if (s.isEmpty) return s; - if (s.startsWith('--')) { - final nl = s.indexOf('\n'); - if (nl == -1) return ''; - s = s.substring(nl + 1).trimLeft(); - continue; - } - return s; - } -} +import 'sql_limit.dart'; + +export 'sql_limit.dart' + show injectSqlLimit, stripLeadingWhitespaceAndLineComments; /// True if the first statement looks like explicit transaction control, so we /// should not prepend `BEGIN` when autocommit is off. @@ -36,40 +26,3 @@ bool shouldSkipImplicitBegin(String sql) { return false; } - -/// Injects a `LIMIT` clause to a read-only query (SELECT, WITH, VALUES) -/// if it does not already contain a `LIMIT` clause. -String injectSqlLimit(String sql, int limit) { - final cleanSql = stripLeadingWhitespaceAndLineComments(sql); - final upper = cleanSql.toUpperCase(); - - final isSelect = upper.startsWith('SELECT') || - upper.startsWith('WITH') || - upper.startsWith('VALUES'); - - if (!isSelect) { - return sql; - } - - // Check if it already has a LIMIT clause - final hasLimit = RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql); - if (hasLimit) { - return sql; - } - - // Strip trailing whitespace and semicolons to build the body - var body = sql.trimRight(); - var suffix = ''; - - while (true) { - if (body.isEmpty) break; - if (body.endsWith(';')) { - body = body.substring(0, body.length - 1).trimRight(); - suffix = ';$suffix'; - continue; - } - break; - } - - return '$body\nLIMIT $limit$suffix'; -} diff --git a/lib/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart new file mode 100644 index 00000000..45a81a8c --- /dev/null +++ b/lib/core/database/sql_limit.dart @@ -0,0 +1,59 @@ +// Shared helpers for bounding ad-hoc SQL result sets (Postgres, SQLite, …). + +/// Removes leading whitespace and `--` line comments (not `/* */`). +String stripLeadingWhitespaceAndLineComments(String sql) { + var s = sql.trimLeft(); + while (true) { + if (s.isEmpty) return s; + if (s.startsWith('--')) { + final nl = s.indexOf('\n'); + if (nl == -1) return ''; + s = s.substring(nl + 1).trimLeft(); + continue; + } + return s; + } +} + +/// Injects a `LIMIT` clause into a read-only query (`SELECT`, `WITH`, `VALUES`) +/// when it does not already contain `LIMIT`. +/// +/// Existing `LIMIT` is left unchanged (caller may still apply a client-side +/// display cap). Non-select statements are returned as-is. +/// +/// Trailing semicolons are preserved after the injected clause. +String injectSqlLimit(String sql, int limit) { + if (limit <= 0) return sql; + + final cleanSql = stripLeadingWhitespaceAndLineComments(sql); + final upper = cleanSql.toUpperCase(); + + final isSelect = upper.startsWith('SELECT') || + upper.startsWith('WITH') || + upper.startsWith('VALUES'); + + if (!isSelect) { + return sql; + } + + // Already bounded by the author (may still exceed UI cap — see clamp issue). + final hasLimit = RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql); + if (hasLimit) { + return sql; + } + + var body = sql.trimRight(); + var suffix = ''; + + while (true) { + if (body.isEmpty) break; + if (body.endsWith(';')) { + body = body.substring(0, body.length - 1).trimRight(); + suffix = ';$suffix'; + continue; + } + break; + } + + return '$body\nLIMIT $limit$suffix'; +} diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 718076b7..64f034a1 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -7,6 +7,7 @@ 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/sql_limit.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -156,7 +157,11 @@ class _SqliteSqlWorkspaceState extends material.State { return; } - final results = await conn.execute(userSql); + // Bound SELECT/WITH/VALUES at the engine before materializing rows. + // Client-side take() remains as defense for PRAGMA/EXPLAIN and author LIMIT. + final cap = _resultMaxRows; + final sql = injectSqlLimit(userSql, cap); + final results = await conn.execute(sql); if (!mounted) return; @@ -165,9 +170,9 @@ class _SqliteSqlWorkspaceState extends material.State { cols.addAll(results.first.keys); } - final cap = _resultMaxRows; final truncated = results.length > cap; final limitCount = truncated ? cap : results.length; + final injectedLimit = sql != userSql; final rawRows = results.take(limitCount).map((row) { return cols.map((col) => row[col]).toList(); @@ -184,10 +189,10 @@ class _SqliteSqlWorkspaceState extends material.State { _affectedRows = null; if (cols.isEmpty && outRows.isEmpty) { _statusLine = 'Command completed.'; + } else if (truncated || (injectedLimit && results.length >= cap)) { + _statusLine = 'Showing first $cap row(s) (result capped).'; } else { - _statusLine = truncated - ? 'Showing first $cap row(s) (result capped).' - : '${results.length} row(s).'; + _statusLine = '${results.length} row(s).'; } _running = false; }); diff --git a/test/core/database/sql_limit_test.dart b/test/core/database/sql_limit_test.dart new file mode 100644 index 00000000..dd3468f5 --- /dev/null +++ b/test/core/database/sql_limit_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; + +void main() { + group('injectSqlLimit', () { + test('appends LIMIT to select query without limit', () { + expect( + injectSqlLimit('SELECT * FROM users', 5000), + 'SELECT * FROM users\nLIMIT 5000', + ); + }); + + test('handles trailing semicolons', () { + expect( + injectSqlLimit('SELECT * FROM users;', 5000), + 'SELECT * FROM users\nLIMIT 5000;', + ); + expect( + injectSqlLimit('SELECT * FROM users; ', 5000), + 'SELECT * FROM users\nLIMIT 5000;', + ); + expect( + injectSqlLimit('SELECT * FROM users;;', 5000), + 'SELECT * FROM users\nLIMIT 5000;;', + ); + }); + + test('does not append LIMIT if LIMIT already exists', () { + expect( + injectSqlLimit('SELECT * FROM users LIMIT 10', 5000), + 'SELECT * FROM users LIMIT 10', + ); + expect( + injectSqlLimit('SELECT * FROM users limit 10;', 5000), + 'SELECT * FROM users limit 10;', + ); + }); + + test('does not modify non-select/non-read queries', () { + expect( + injectSqlLimit('INSERT INTO users VALUES (1)', 5000), + 'INSERT INTO users VALUES (1)', + ); + expect( + injectSqlLimit('UPDATE users SET x = 1', 5000), + 'UPDATE users SET x = 1', + ); + expect( + injectSqlLimit('PRAGMA table_info(users)', 5000), + 'PRAGMA table_info(users)', + ); + }); + + test('appends LIMIT to WITH and VALUES', () { + expect( + injectSqlLimit( + 'WITH t AS (SELECT * FROM users) SELECT * FROM t;', + 5000, + ), + 'WITH t AS (SELECT * FROM users) SELECT * FROM t\nLIMIT 5000;', + ); + expect( + injectSqlLimit('VALUES (1), (2), (3)', 2), + 'VALUES (1), (2), (3)\nLIMIT 2', + ); + }); + + test('ignores non-positive limit', () { + expect(injectSqlLimit('SELECT 1', 0), 'SELECT 1'); + expect(injectSqlLimit('SELECT 1', -1), 'SELECT 1'); + }); + + test('skips leading line comments when detecting SELECT', () { + expect( + injectSqlLimit('-- comment\nSELECT * FROM t', 100), + '-- comment\nSELECT * FROM t\nLIMIT 100', + ); + }); + }); +} From ca3b549e5186ff534fb72881c2ffe42603d8489b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:14:06 +0300 Subject: [PATCH 02/23] perf(export): stream DataExportService file saves to IOSink saveToFile no longer builds a full-document String; CSV reuses writeResultGridCsv and JSON/Markdown/SQL write row-by-row with yields. Clipboard keeps isolate formatAsync (OS clipboard API). Closes #417 --- lib/shared/services/data_export_service.dart | 145 ++++++++++++++++++- test/shared/data_export_service_test.dart | 68 +++++++++ 2 files changed, 210 insertions(+), 3 deletions(-) diff --git a/lib/shared/services/data_export_service.dart b/lib/shared/services/data_export_service.dart index 7a60bb66..bef5df0f 100644 --- a/lib/shared/services/data_export_service.dart +++ b/lib/shared/services/data_export_service.dart @@ -129,6 +129,9 @@ class DataExportService { } /// Copies data in [format] to the system clipboard. + /// + /// Clipboard still materializes a full string (OS API). Prefer [saveToFile] + /// for large grids; this path stays isolate-backed via [formatAsync]. static Future copyToClipboard( DataExportFormat format, { required List columns, @@ -165,7 +168,136 @@ class DataExportService { }); } - /// Opens a native file save dialog and writes the exported text to disk. + /// Streams formatted output to [sink] without assembling one giant [String]. + static Future writeToSink( + IOSink sink, + DataExportFormat format, { + required List columns, + required List> rows, + String tableName = 'export_table', + }) async { + switch (format) { + case DataExportFormat.csv: + await writeResultGridCsv(sink, columns: columns, rows: rows); + return; + case DataExportFormat.json: + await _writeJsonObjectArray(sink, columns: columns, rows: rows); + return; + case DataExportFormat.markdown: + await _writeMarkdownTable(sink, columns: columns, rows: rows); + return; + case DataExportFormat.sqlDump: + await _writeSqlInsertDump( + sink, + tableName: tableName, + columns: columns, + rows: rows, + ); + return; + } + } + + static Future _writeJsonObjectArray( + IOSink sink, { + required List columns, + required List> rows, + }) async { + sink.write('[\n'); + var written = 0; + for (var r = 0; r < rows.length; r++) { + final row = rows[r]; + final obj = {}; + for (var i = 0; i < columns.length; i++) { + final val = i < row.length ? row[i] : null; + obj[columns[i]] = (val == 'NULL' || val == null) ? null : val; + } + sink.write(const JsonEncoder.withIndent(' ').convert(obj)); + if (r + 1 < rows.length) sink.write(','); + sink.write('\n'); + written++; + if (written % 500 == 0) { + await Future.delayed(Duration.zero); + } + } + sink.write(']\n'); + await sink.flush(); + } + + static Future _writeMarkdownTable( + IOSink sink, { + required List columns, + required List> rows, + }) async { + if (columns.isEmpty) { + await sink.flush(); + return; + } + + String escapeMd(String s) { + if (s == 'NULL') return '`NULL`'; + return s + .replaceAll('|', '\\|') + .replaceAll('\r\n', ' ') + .replaceAll('\n', ' '); + } + + sink.write('| ${columns.map(escapeMd).join(' | ')} |\n'); + sink.write('| ${columns.map((_) => '---').join(' | ')} |\n'); + var written = 0; + for (final row in rows) { + final padded = List.generate( + columns.length, + (i) => i < row.length ? escapeMd(row[i]) : '`NULL`', + growable: false, + ); + sink.write('| ${padded.join(' | ')} |\n'); + written++; + if (written % 500 == 0) { + await Future.delayed(Duration.zero); + } + } + await sink.flush(); + } + + static Future _writeSqlInsertDump( + IOSink sink, { + required String tableName, + required List columns, + required List> rows, + }) async { + if (columns.isEmpty || rows.isEmpty) { + await sink.flush(); + return; + } + final safeTable = + tableName.trim().isEmpty ? 'export_table' : tableName.trim(); + final colList = columns + .map((c) => c.contains(' ') || c.contains('-') ? '"$c"' : c) + .join(', '); + + var written = 0; + for (final row in rows) { + sink.write('INSERT INTO $safeTable ($colList) VALUES ('); + for (var i = 0; i < columns.length; i++) { + if (i > 0) sink.write(', '); + if (i >= row.length || row[i] == 'NULL') { + sink.write('NULL'); + } else { + final escaped = row[i].replaceAll("'", "''"); + sink.write("'$escaped'"); + } + } + sink.write(');\n'); + written++; + if (written % 500 == 0) { + await Future.delayed(Duration.zero); + } + } + await sink.flush(); + } + + /// Opens a native file save dialog and **streams** the export to disk + /// (no full-document [String] for the file path). static Future saveToFile( DataExportFormat format, { required List columns, @@ -208,16 +340,23 @@ class DataExportService { return SaveExportOutcome.cancelled; } + IOSink? sink; try { - final content = await formatAsync( + sink = File(path).openWrite(); + await writeToSink( + sink, format, columns: columns, rows: rows, tableName: tableName, ); - await File(path).writeAsString(content); + await sink.close(); + sink = null; return SaveExportOutcome.written; } on Object { + try { + await sink?.close(); + } catch (_) {} return SaveExportOutcome.error; } } diff --git a/test/shared/data_export_service_test.dart b/test/shared/data_export_service_test.dart index e02c4ccc..aaacc30e 100644 --- a/test/shared/data_export_service_test.dart +++ b/test/shared/data_export_service_test.dart @@ -1,4 +1,6 @@ import 'dart:convert'; +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; @@ -64,5 +66,71 @@ void main() { ); expect(md, contains('| id | user_name | note |')); }); + + test('writeToSink streams CSV/JSON/SQL matching format helpers', () async { + Future sinkToString( + Future Function(IOSink sink) write, + ) async { + final path = + '${Directory.systemTemp.path}/querya_export_${DateTime.now().microsecondsSinceEpoch}.txt'; + final out = File(path); + final sink = out.openWrite(); + try { + await write(sink); + } finally { + await sink.close(); + } + final text = await out.readAsString(); + await out.delete(); + return text; + } + + final csv = await sinkToString( + (s) => DataExportService.writeToSink( + s, + DataExportFormat.csv, + columns: columns, + rows: rows, + ), + ); + expect(csv, DataExportService.formatCsv(columns, rows)); + + final jsonStr = await sinkToString( + (s) => DataExportService.writeToSink( + s, + DataExportFormat.json, + columns: columns, + rows: rows, + ), + ); + final decoded = jsonDecode(jsonStr) as List; + expect(decoded.length, 2); + expect(decoded[0]['id'], '101'); + expect(decoded[1]['note'], isNull); + + final sql = await sinkToString( + (s) => DataExportService.writeToSink( + s, + DataExportFormat.sqlDump, + columns: columns, + rows: rows, + tableName: 'users', + ), + ); + expect( + sql, + DataExportService.formatSqlInsertDump('users', columns, rows), + ); + + final md = await sinkToString( + (s) => DataExportService.writeToSink( + s, + DataExportFormat.markdown, + columns: columns, + rows: rows, + ), + ); + expect(md, DataExportService.formatMarkdownTable(columns, rows)); + }); }); } From d27176aab705a72b3c4219d841c90a928b4caffb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:09:43 +0300 Subject: [PATCH 03/23] perf(sql): clamp oversized LIMIT / FETCH for result caps Extend injectSqlLimit to clamp LIMIT n, LIMIT ALL, and FETCH FIRST when larger than the UI row cap so Postgres (and SQLite) engines do not buffer more rows than Preferences allow. Document the policy. Closes #416 --- docs/user-guide.md | 7 ++- lib/core/database/sql_limit.dart | 57 +++++++++++++++++++---- test/core/database/postgres_sql_test.dart | 7 ++- test/core/database/sql_limit_test.dart | 37 ++++++++++++++- 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index bba0f411..4b0dfdd1 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -46,6 +46,11 @@ High-level feature depth varies by database type. PostgreSQL, MySQL, and SQLite ### Result row caps (SQL workspaces) -Preferences → **Max rows in results** caps how many rows the grid loads. For **PostgreSQL** and **SQLite**, ad-hoc `SELECT` / `WITH` / `VALUES` without an author `LIMIT` get a `LIMIT` injected before execution so the engine does not materialize an unbounded result. Queries that already include `LIMIT`, and non-SELECT statements (`INSERT`, `PRAGMA`, …), are left unchanged; the UI may still truncate the displayed grid as a fallback. +Preferences → **Max rows in results** caps how many rows the grid loads. For **PostgreSQL** and **SQLite**, ad-hoc `SELECT` / `WITH` / `VALUES` are bounded **before** execution: + +- No author `LIMIT` → a `LIMIT` equal to the preference is injected. +- Author `LIMIT` / `LIMIT ALL` / `FETCH FIRST n ROWS ONLY` larger than the preference → clamped down to the preference (OFFSET kept when present). + +Queries that already use a smaller `LIMIT`, and non-SELECT statements (`INSERT`, `PRAGMA`, …), are left unchanged. The UI may still truncate the displayed grid as a fallback. **MySQL** SQL workspace streams rows and stops at the cap client-side. For troubleshooting build/run issues, see the main [README.md](../README.md). diff --git a/lib/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart index 45a81a8c..40211e75 100644 --- a/lib/core/database/sql_limit.dart +++ b/lib/core/database/sql_limit.dart @@ -15,13 +15,25 @@ String stripLeadingWhitespaceAndLineComments(String sql) { } } -/// Injects a `LIMIT` clause into a read-only query (`SELECT`, `WITH`, `VALUES`) -/// when it does not already contain `LIMIT`. +final _limitAll = RegExp(r'\bLIMIT\s+ALL\b', caseSensitive: false); +final _limitCount = RegExp( + r'\bLIMIT\s+(\d+)(\s+OFFSET\s+\d+)?', + caseSensitive: false, +); +final _fetchFirst = RegExp( + r'\bFETCH\s+(?:FIRST|NEXT)\s+(\d+)\s+ROWS?\s+ONLY\b', + caseSensitive: false, +); + +/// Injects or clamps a `LIMIT` on read-only queries (`SELECT`, `WITH`, `VALUES`). /// -/// Existing `LIMIT` is left unchanged (caller may still apply a client-side -/// display cap). Non-select statements are returned as-is. +/// - No `LIMIT` / `FETCH … ONLY` → appends `LIMIT [limit]`. +/// - `LIMIT ALL` → replaced with `LIMIT [limit]`. +/// - `LIMIT n [OFFSET m]` where `n > limit` → clamped to [limit]. +/// - `FETCH FIRST/NEXT n ROWS ONLY` where `n > limit` → clamped. +/// - Non-select statements are returned unchanged. /// -/// Trailing semicolons are preserved after the injected clause. +/// Trailing semicolons are preserved after an injected clause. String injectSqlLimit(String sql, int limit) { if (limit <= 0) return sql; @@ -36,9 +48,38 @@ String injectSqlLimit(String sql, int limit) { return sql; } - // Already bounded by the author (may still exceed UI cap — see clamp issue). - final hasLimit = RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql); - if (hasLimit) { + if (_limitAll.hasMatch(sql)) { + return sql.replaceFirst(_limitAll, 'LIMIT $limit'); + } + + final limitMatch = _limitCount.firstMatch(sql); + if (limitMatch != null) { + final existing = int.tryParse(limitMatch.group(1)!); + if (existing == null || existing <= limit) { + return sql; + } + final offsetPart = limitMatch.group(2) ?? ''; + return sql.replaceFirst( + limitMatch.group(0)!, + 'LIMIT $limit$offsetPart', + ); + } + + final fetchMatch = _fetchFirst.firstMatch(sql); + if (fetchMatch != null) { + final existing = int.tryParse(fetchMatch.group(1)!); + if (existing == null || existing <= limit) { + return sql; + } + return sql.replaceFirst( + fetchMatch.group(0)!, + 'FETCH FIRST $limit ROWS ONLY', + ); + } + + if (RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql) || + RegExp(r'\bFETCH\b', caseSensitive: false).hasMatch(sql)) { + // Unrecognized LIMIT/FETCH shape — leave unchanged. return sql; } diff --git a/test/core/database/postgres_sql_test.dart b/test/core/database/postgres_sql_test.dart index 0ad57b89..85bf320a 100644 --- a/test/core/database/postgres_sql_test.dart +++ b/test/core/database/postgres_sql_test.dart @@ -95,13 +95,18 @@ void main() { 'SELECT * FROM users\nLIMIT 5000;;'); }); - test('does not append LIMIT if LIMIT already exists', () { + test('does not append LIMIT if LIMIT already within cap', () { expect(injectSqlLimit('SELECT * FROM users LIMIT 10', 5000), 'SELECT * FROM users LIMIT 10'); expect(injectSqlLimit('SELECT * FROM users limit 10;', 5000), 'SELECT * FROM users limit 10;'); }); + test('clamps oversized LIMIT', () { + expect(injectSqlLimit('SELECT * FROM users LIMIT 999999', 5000), + 'SELECT * FROM users LIMIT 5000'); + }); + test('does not modify non-select/non-read queries', () { expect(injectSqlLimit('INSERT INTO users VALUES (1)', 5000), 'INSERT INTO users VALUES (1)'); diff --git a/test/core/database/sql_limit_test.dart b/test/core/database/sql_limit_test.dart index dd3468f5..24d586a0 100644 --- a/test/core/database/sql_limit_test.dart +++ b/test/core/database/sql_limit_test.dart @@ -25,7 +25,7 @@ void main() { ); }); - test('does not append LIMIT if LIMIT already exists', () { + test('does not append LIMIT if LIMIT already exists and within cap', () { expect( injectSqlLimit('SELECT * FROM users LIMIT 10', 5000), 'SELECT * FROM users LIMIT 10', @@ -36,6 +36,41 @@ void main() { ); }); + test('clamps LIMIT larger than cap', () { + expect( + injectSqlLimit('SELECT * FROM users LIMIT 999999', 5000), + 'SELECT * FROM users LIMIT 5000', + ); + expect( + injectSqlLimit('SELECT * FROM users LIMIT 100000 OFFSET 20;', 1000), + 'SELECT * FROM users LIMIT 1000 OFFSET 20;', + ); + }); + + test('replaces LIMIT ALL with cap', () { + expect( + injectSqlLimit('SELECT * FROM users LIMIT ALL', 5000), + 'SELECT * FROM users LIMIT 5000', + ); + }); + + test('clamps FETCH FIRST n ROWS ONLY', () { + expect( + injectSqlLimit( + 'SELECT * FROM users FETCH FIRST 100000 ROWS ONLY', + 5000, + ), + 'SELECT * FROM users FETCH FIRST 5000 ROWS ONLY', + ); + expect( + injectSqlLimit( + 'SELECT * FROM users FETCH FIRST 10 ROWS ONLY', + 5000, + ), + 'SELECT * FROM users FETCH FIRST 10 ROWS ONLY', + ); + }); + test('does not modify non-select/non-read queries', () { expect( injectSqlLimit('INSERT INTO users VALUES (1)', 5000), From ef24a6d347e12a432e822d73af2fadaf9d81a9b7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:23:34 +0300 Subject: [PATCH 04/23] perf(marketplace): stream SHA256 and file-decode zip archives Hash archives with sha256HexOfFile (openRead stream) and decode via InputFileStream so marketplace/sideload no longer keep a full byte buffer plus ZipDecoder output. Clear entry content after write. Closes #418 --- docs/security.md | 2 + .../extensions/local_extension_installer.dart | 12 +++--- .../market/http_marketplace_repository.dart | 18 +++++---- lib/core/security/safe_zip_extractor.dart | 40 +++++++++++++++++-- .../security/safe_zip_extractor_test.dart | 13 ++++++ 5 files changed, 68 insertions(+), 17 deletions(-) diff --git a/docs/security.md b/docs/security.md index 8480bf26..0af80608 100644 --- a/docs/security.md +++ b/docs/security.md @@ -37,6 +37,8 @@ Marketplace downloads, local extension sideload (`.zip` / `.qext`), and in-app u Archives exceeding these bounds fail closed before files are written to disk. Path traversal checks remain in `archive_path_guard.dart`. +SHA-256 verification for marketplace/sideload streams the file (`sha256.bind(file.openRead())`, same helper as the updater) instead of hashing a full in-memory copy. Zip decode uses a file stream (`InputFileStream`) so the compressed payload is not held as a separate `List` alongside the decoded archive; entry contents are cleared after each write. + ## Extension driver OS sandbox Process-sandbox database drivers launch inside OS-level isolation when available: diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index 3296763f..ee273452 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; -import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/extension_support.dart'; @@ -12,6 +11,7 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; import 'package:querya_desktop/core/security/archive_path_guard.dart'; import 'package:querya_desktop/core/security/safe_zip_extractor.dart'; +import 'package:querya_desktop/core/updater/sha256_checksums.dart'; /// Installs an extension package from a local `.zip` / `.qext` archive (issue #316). /// @@ -44,15 +44,14 @@ class LocalExtensionInstaller { } onProgress?.call(0.1); - late final List bytes; try { - bytes = await SafeZipExtractor.readBoundedBytes(archiveFile); + await SafeZipExtractor.ensureCompressedSizeAllowed(archiveFile); } on SafeZipException catch (error) { throw MarketplaceException(error.message); } if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) { - final actual = sha256.convert(bytes).toString().toLowerCase(); + final actual = (await sha256HexOfFile(archiveFile)).toLowerCase(); final expected = expectedSha256.trim().toLowerCase(); if (actual != expected) { throw MarketplaceException( @@ -65,7 +64,7 @@ class LocalExtensionInstaller { onProgress?.call(0.25); late final Archive archive; try { - archive = SafeZipExtractor.decodeBytes(bytes); + archive = await SafeZipExtractor.readAndDecodeFile(archiveFile); } on SafeZipException catch (error) { throw MarketplaceException(error.message); } @@ -245,7 +244,8 @@ class LocalExtensionInstaller { if (file.isFile) { final outFile = File(targetPath); await outFile.parent.create(recursive: true); - await outFile.writeAsBytes(file.content as List); + await outFile.writeAsBytes(file.content); + file.clear(); } else { await Directory(targetPath).create(recursive: true); } diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 11578fb7..e49694fb 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; -import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; @@ -14,6 +13,7 @@ import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'package:querya_desktop/core/security/archive_path_guard.dart'; import 'package:querya_desktop/core/security/safe_zip_extractor.dart'; +import 'package:querya_desktop/core/updater/sha256_checksums.dart'; import 'marketplace_download_policy.dart'; import 'marketplace_repository.dart'; @@ -162,7 +162,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { ); try { - // Step 2: SHA-256 Integrity Verification (Critical Security Check) + // Step 2: SHA-256 Integrity Verification (stream — no full-buffer hash) final expectedSha256 = manifest.sha256Checksum?.trim().toLowerCase(); if (expectedSha256 == null || expectedSha256.isEmpty) { throw MarketplaceException( @@ -171,13 +171,13 @@ class HttpMarketplaceRepository implements MarketplaceRepository { ); } - late final List bytes; try { - bytes = await SafeZipExtractor.readBoundedBytes(archiveFile); + await SafeZipExtractor.ensureCompressedSizeAllowed(archiveFile); } on SafeZipException catch (error) { throw MarketplaceException(error.message); } - final actualSha256 = sha256.convert(bytes).toString().toLowerCase(); + + final actualSha256 = (await sha256HexOfFile(archiveFile)).toLowerCase(); if (actualSha256 != expectedSha256) { throw MarketplaceException( 'SHA256 checksum mismatch for "${manifest.id}". ' @@ -187,10 +187,10 @@ class HttpMarketplaceRepository implements MarketplaceRepository { onProgress?.call(0.85); - // Step 3: Safe Archive Extraction (path traversal + zip bomb limits) + // Step 3: Safe Archive Extraction (file-stream decode + path/zip-bomb limits) final Archive archive; try { - archive = SafeZipExtractor.decodeBytes(bytes); + archive = await SafeZipExtractor.readAndDecodeFile(archiveFile); } on SafeZipException catch (error) { throw MarketplaceException(error.message); } @@ -218,7 +218,9 @@ class HttpMarketplaceRepository implements MarketplaceRepository { if (file.isFile) { final outFile = File(targetPath); await outFile.create(recursive: true); - await outFile.writeAsBytes(file.content as List); + final bytes = file.content; + await outFile.writeAsBytes(bytes); + file.clear(); } else { await Directory(targetPath).create(recursive: true); } diff --git a/lib/core/security/safe_zip_extractor.dart b/lib/core/security/safe_zip_extractor.dart index 0ff52fad..17d05759 100644 --- a/lib/core/security/safe_zip_extractor.dart +++ b/lib/core/security/safe_zip_extractor.dart @@ -40,7 +40,8 @@ class SafeZipException implements Exception { /// Bounded zip decode used by marketplace, sideload, and updater paths. abstract final class SafeZipExtractor { - static Future> readBoundedBytes( + /// Ensures [file] is within [limits.maxCompressedBytes] before reading. + static Future ensureCompressedSizeAllowed( File file, { ZipDecodeLimits limits = ZipDecodeLimits.standard, }) async { @@ -51,6 +52,18 @@ abstract final class SafeZipExtractor { '(${limits.maxCompressedBytes} bytes).', ); } + return length; + } + + /// Reads the whole file into memory after size check. + /// + /// Prefer [readAndDecodeFile] (file-stream decode) when you only need an + /// [Archive], so compressed bytes are not held as a separate [List]. + static Future> readBoundedBytes( + File file, { + ZipDecodeLimits limits = ZipDecodeLimits.standard, + }) async { + await ensureCompressedSizeAllowed(file, limits: limits); return file.readAsBytes(); } @@ -80,12 +93,33 @@ abstract final class SafeZipExtractor { return archive; } + /// Decodes [file] via [InputFileStream] (buffered file reads) instead of + /// materializing the full compressed payload as a [List] first. static Future readAndDecodeFile( File file, { ZipDecodeLimits limits = ZipDecodeLimits.standard, }) async { - final bytes = await readBoundedBytes(file, limits: limits); - return decodeBytes(bytes, limits: limits); + final compressedBytes = + await ensureCompressedSizeAllowed(file, limits: limits); + + final input = InputFileStream(file.path); + try { + final Archive archive; + try { + archive = ZipDecoder().decodeStream(input); + } on Object catch (error) { + throw SafeZipException('Failed to decode zip archive: $error'); + } + + _validateArchive( + archive, + compressedBytes: compressedBytes, + limits: limits, + ); + return archive; + } finally { + await input.close(); + } } static void _validateArchive( diff --git a/test/core/security/safe_zip_extractor_test.dart b/test/core/security/safe_zip_extractor_test.dart index 283a4f63..6d9fc28e 100644 --- a/test/core/security/safe_zip_extractor_test.dart +++ b/test/core/security/safe_zip_extractor_test.dart @@ -132,5 +132,18 @@ void main() { await SafeZipExtractor.readAndDecodeFile(zipFile, limits: _tightLimits); expect(decoded.first.name, 'ok.txt'); }); + + test('ensureCompressedSizeAllowed rejects oversize before decode', () async { + final file = File(p.join(tempDir.path, 'big.zip')); + await file.writeAsBytes(List.filled(5000, 1)); + + expect( + () => SafeZipExtractor.ensureCompressedSizeAllowed( + file, + limits: _tightLimits, + ), + throwsA(isA()), + ); + }); }); } From bbc2777bb69c490233c9a6a9c1c50cc61b17e470 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:30:52 +0300 Subject: [PATCH 05/23] perf(extensions): bound JSON-RPC NDJSON line size and pass query limit Fail closed when a plugin stdout line exceeds 32 MiB, decode large lines off the UI isolate, always send db.query limit from Preferences, and document host payload limits for driver authors. Closes #419 --- docs/tz-block-c-rpc-bridge.md | 16 ++- .../extensions/extension_driver_session.dart | 8 +- .../rpc/json_rpc_payload_limits.dart | 114 ++++++++++++++++++ .../extensions/rpc/json_rpc_stdio_client.dart | 37 +++++- .../extensions/extension_sql_workspace.dart | 15 ++- .../rpc/json_rpc_stdio_client_test.dart | 88 ++++++++++++++ 6 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 lib/core/extensions/rpc/json_rpc_payload_limits.dart create mode 100644 test/core/extensions/rpc/json_rpc_stdio_client_test.dart diff --git a/docs/tz-block-c-rpc-bridge.md b/docs/tz-block-c-rpc-bridge.md index 1d0d6b36..609c9a2b 100644 --- a/docs/tz-block-c-rpc-bridge.md +++ b/docs/tz-block-c-rpc-bridge.md @@ -46,7 +46,15 @@ final result = await rpcClient.sendRequest('db.connect', credentialsMap); --- -## 4. Контракт Ошибок (Error Mapping) -Плагин должен возвращать ошибки согласно спецификации JSON-RPC. RPC Bridge должен уметь парсить эти ошибки и превращать их в понятные Dart-exceptions: -- Ошибка подключения (Timeout, Wrong Password) -> Показывается в UI в красном Snackbar. -- Синтаксическая ошибка SQL -> Выделяется красным в SQL редакторе. +## 5. Лимиты полезной нагрузки (NDJSON) + +Каждый ответ — **одна JSON-строка** на `stdout` (newline-delimited). Хост (`JsonRpcStdioClient`) применяет: + +| Лимит | Значение по умолчанию | Поведение | +|-------|----------------------|-----------| +| Макс. длина одной строки ответа | **32 MiB** UTF-8 | Fail closed: `JsonRpcPayloadTooLargeException`, все pending RPC завершаются ошибкой | +| Декод больших строк | **> 64 KiB** | `jsonDecode` уходит в isolate | + +Для `db.query` хост всегда передаёт `params.limit` (Preferences → Max rows in results), чтобы драйвер обрезал результат **до** сериализации. Драйверы обязаны уважать `limit`. + +Чанкованный / бинарный framing для очень больших выборок — follow-up; до него bound + `limit` обязательны. diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index ee8a3d7a..a082edde 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -14,6 +14,7 @@ import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_bridge.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; import 'package:querya_desktop/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -276,16 +277,21 @@ class ExtensionDriverSession { } /// Executes SQL through the plugin (`db.query`) and returns the raw result. + /// + /// When [limit] is omitted, Preferences **Max rows in results** is sent so + /// drivers can bound the NDJSON response before it hits the host. Future query( ConnectionRow row, String sql, { int? limit, }) async { final bridge = await ensureConnected(row); + final effectiveLimit = + limit ?? await AppSettings.instance.getSqlResultMaxRows(); final result = await bridge.sendRequest('db.query', { 'connectionId': row.id, 'sql': sql, - if (limit != null) 'limit': limit, + 'limit': effectiveLimit, }); return compute(_parseExtensionQueryResultRpc, result); } diff --git a/lib/core/extensions/rpc/json_rpc_payload_limits.dart b/lib/core/extensions/rpc/json_rpc_payload_limits.dart new file mode 100644 index 00000000..72750292 --- /dev/null +++ b/lib/core/extensions/rpc/json_rpc_payload_limits.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +/// Default max UTF-8 byte length of one JSON-RPC stdout line (NDJSON). +/// +/// Large `db.query` results are one object per line; without a bound the host +/// can OOM before Preferences row caps apply. Drivers should honor `limit`. +const int kDefaultJsonRpcMaxLineBytes = 32 * 1024 * 1024; + +/// Lines above this UTF-8 length are `jsonDecode`d off the UI isolate. +const int kJsonRpcOffIsolateDecodeThresholdBytes = 64 * 1024; + +/// Thrown when a plugin emits a newline-delimited JSON line larger than the +/// configured maximum. +class JsonRpcPayloadTooLargeException implements Exception { + JsonRpcPayloadTooLargeException({ + required this.maxLineBytes, + required this.receivedBytes, + }); + + final int maxLineBytes; + final int receivedBytes; + + @override + String toString() => + 'JsonRpcPayloadTooLargeException: JSON-RPC line is $receivedBytes bytes ' + '(max $maxLineBytes). Reduce result size or pass a smaller `limit`.'; +} + +/// Splits a byte stream into UTF-8 lines, failing closed if any line exceeds +/// [maxLineBytes] (counted before decode). +StreamTransformer, String> boundedUtf8LineSplitter({ + int maxLineBytes = kDefaultJsonRpcMaxLineBytes, +}) { + return _BoundedUtf8LineSplitter(maxLineBytes: maxLineBytes); +} + +class _BoundedUtf8LineSplitter + extends StreamTransformerBase, String> { + _BoundedUtf8LineSplitter({required this.maxLineBytes}); + + final int maxLineBytes; + + @override + Stream bind(Stream> stream) { + final controller = StreamController(sync: true); + final pending = BytesBuilder(copy: false); + late final StreamSubscription> sub; + + void fail(Object error, [StackTrace? st]) { + if (!controller.isClosed) { + controller.addError(error, st); + controller.close(); + } + sub.cancel(); + } + + void emitLine() { + var bytes = pending.takeBytes(); + if (bytes.isNotEmpty && bytes.last == 0x0d) { + bytes = Uint8List.sublistView(bytes, 0, bytes.length - 1); + } + if (bytes.isEmpty) return; + try { + controller.add(utf8.decode(bytes)); + } catch (e, st) { + fail(e, st); + } + } + + sub = stream.listen( + (chunk) { + for (var i = 0; i < chunk.length; i++) { + final b = chunk[i]; + if (b == 0x0a) { + emitLine(); + continue; + } + if (pending.length >= maxLineBytes) { + fail( + JsonRpcPayloadTooLargeException( + maxLineBytes: maxLineBytes, + receivedBytes: pending.length + 1, + ), + ); + return; + } + pending.addByte(b); + } + }, + onError: fail, + onDone: () { + if (pending.length > 0) { + if (pending.length > maxLineBytes) { + fail( + JsonRpcPayloadTooLargeException( + maxLineBytes: maxLineBytes, + receivedBytes: pending.length, + ), + ); + return; + } + emitLine(); + } + controller.close(); + }, + cancelOnError: true, + ); + + controller.onCancel = () => sub.cancel(); + return controller.stream; + } +} diff --git a/lib/core/extensions/rpc/json_rpc_stdio_client.dart b/lib/core/extensions/rpc/json_rpc_stdio_client.dart index e277ee2d..130f56f5 100644 --- a/lib/core/extensions/rpc/json_rpc_stdio_client.dart +++ b/lib/core/extensions/rpc/json_rpc_stdio_client.dart @@ -1,24 +1,39 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; + +import 'package:querya_desktop/core/extensions/rpc/json_rpc_payload_limits.dart'; /// Minimal JSON-RPC 2.0 client over newline-delimited JSON on stdio. /// /// Enough for Block E credential injection and later Block C methods without /// pulling `json_rpc_2` yet. One JSON object per line on stdin/stdout. +/// +/// Incoming lines are bounded by [maxLineBytes] (see +/// [kDefaultJsonRpcMaxLineBytes]); oversized payloads fail closed. class JsonRpcStdioClient { JsonRpcStdioClient({ required Stream> stdout, required IOSink stdin, this.requestTimeout = const Duration(seconds: 10), + this.maxLineBytes = kDefaultJsonRpcMaxLineBytes, }) : _stdin = stdin, - _lines = utf8.decoder.bind(stdout).transform(const LineSplitter()) { - _subscription = _lines.listen(_onLine, onError: _onError, onDone: _onDone); + _lines = stdout.transform( + boundedUtf8LineSplitter(maxLineBytes: maxLineBytes), + ) { + _subscription = _lines.listen( + _onLine, + onError: _onError, + onDone: _onDone, + cancelOnError: false, + ); } final IOSink _stdin; final Stream _lines; final Duration requestTimeout; + final int maxLineBytes; final Map> _pending = {}; var _nextId = 1; @@ -26,6 +41,9 @@ class JsonRpcStdioClient { StreamSubscription? _subscription; Object? _fatalError; + /// Serializes async line handling so large-line isolate decode stays ordered. + Future _lineChain = Future.value(); + /// Sends a JSON-RPC request and waits for the matching response. Future sendRequest( String method, [ @@ -82,12 +100,21 @@ class JsonRpcStdioClient { } void _onLine(String line) { + _lineChain = _lineChain.then((_) => _handleLine(line)); + } + + Future _handleLine(String line) async { if (line.trim().isEmpty) return; late final Map message; try { - final decoded = jsonDecode(line); - if (decoded is! Map) return; - message = decoded; + final Object decoded; + if (line.length > kJsonRpcOffIsolateDecodeThresholdBytes) { + decoded = await Isolate.run(() => jsonDecode(line)); + } else { + decoded = jsonDecode(line); + } + if (decoded is! Map) return; + message = Map.from(decoded); } catch (_) { return; } diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index a1dce00b..25dd8c25 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -50,6 +50,7 @@ class _ExtensionSqlWorkspaceState String? _statusLine; int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; + int _resultMaxRows = kDefaultSqlResultMaxRows; double _editorFontSize = kDefaultSqlEditorFontSize; static const _previewRowLimit = 200; @@ -89,10 +90,12 @@ class _ExtensionSqlWorkspaceState Future _loadWorkspaceSettings() async { final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); + final rows = await AppSettings.instance.getSqlResultMaxRows(); final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; setState(() { _historyMaxEntries = hist; + _resultMaxRows = rows; _editorFontSize = font; }); } @@ -124,8 +127,11 @@ class _ExtensionSqlWorkspaceState }); try { - final result = await ExtensionDriverSession.instance - .query(widget.connectionRow, userSql); + final result = await ExtensionDriverSession.instance.query( + widget.connectionRow, + userSql, + limit: _resultMaxRows, + ); if (!mounted) return; setState(() { @@ -136,7 +142,10 @@ class _ExtensionSqlWorkspaceState } else { final elapsed = result.elapsedMs != null ? ' in ${result.elapsedMs}ms' : ''; - _statusLine = '${result.rows.length} row(s)$elapsed.'; + final capped = result.rows.length >= _resultMaxRows; + _statusLine = capped + ? 'Showing first $_resultMaxRows row(s)$elapsed (result capped).' + : '${result.rows.length} row(s)$elapsed.'; } _running = false; }); diff --git a/test/core/extensions/rpc/json_rpc_stdio_client_test.dart b/test/core/extensions/rpc/json_rpc_stdio_client_test.dart new file mode 100644 index 00000000..b8d454e6 --- /dev/null +++ b/test/core/extensions/rpc/json_rpc_stdio_client_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/rpc/json_rpc_payload_limits.dart'; +import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart'; + +void main() { + group('boundedUtf8LineSplitter', () { + test('splits lines and strips CR', () async { + final lines = await Stream>.fromIterable([ + utf8.encode('one\r\n'), + utf8.encode('two\n'), + ]).transform(boundedUtf8LineSplitter(maxLineBytes: 1024)).toList(); + expect(lines, ['one', 'two']); + }); + + test('fails closed when line exceeds max bytes', () async { + final controller = StreamController>(); + final errors = []; + final sub = controller.stream + .transform(boundedUtf8LineSplitter(maxLineBytes: 8)) + .listen((_) {}, onError: errors.add); + + controller.add(utf8.encode('123456789')); // 9 bytes, no newline yet + await Future.delayed(Duration.zero); + expect(errors, isNotEmpty); + expect(errors.first, isA()); + await sub.cancel(); + await controller.close(); + }); + }); + + group('JsonRpcStdioClient payload bounds', () { + test('completes pending request with payload-too-large error', () async { + final stdout = StreamController>(); + final stdin = StreamController>(); + final client = JsonRpcStdioClient( + stdout: stdout.stream, + stdin: IOSink(stdin.sink), + maxLineBytes: 32, + requestTimeout: const Duration(seconds: 2), + ); + + final pending = client.sendRequest('db.query', {'sql': 'SELECT 1'}); + // Drain request line from fake stdin. + await stdin.stream.first; + + // Oversized reply line (no newline until after overflow). + stdout.add(List.filled(40, 0x61)); // 'a' * 40 + await expectLater(pending, throwsA(isA())); + + await client.close(); + await stdout.close(); + await stdin.close(); + }); + + test('decodes normal response', () async { + final stdout = StreamController>(); + final stdin = StreamController>(); + final client = JsonRpcStdioClient( + stdout: stdout.stream, + stdin: IOSink(stdin.sink), + requestTimeout: const Duration(seconds: 2), + ); + + final pending = client.sendRequest('ping'); + await stdin.stream.first; + stdout.add( + utf8.encode( + '${jsonEncode({ + 'jsonrpc': '2.0', + 'id': 1, + 'result': {'ok': true}, + })}\n', + ), + ); + final result = await pending; + expect(result, isA()); + expect((result as Map)['ok'], true); + + await client.close(); + await stdout.close(); + await stdin.close(); + }); + }); +} From 7a1b719b6e58c19d48f5e133ee2c058f999ba40c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:31:58 +0300 Subject: [PATCH 06/23] perf(sdui): virtualize SduiTreeBuilder with flattened ListView Flatten expanded tree rows and render via ListView.builder + itemExtent so large schemas only build viewport widgets instead of a full Column. Closes #420 --- lib/core/sdui/sdui_tree_builder.dart | 201 ++++++++++++++++----------- 1 file changed, 118 insertions(+), 83 deletions(-) diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 0073c10d..70db9a32 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -3,6 +3,9 @@ import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Renders a sidebar-style tree from an SDUI schema with lazy expansion. +/// +/// Visible rows are flattened into a [ListView.builder] so only viewport +/// rows are built (large schemas no longer create a full widget Column). class SduiTreeBuilder extends material.StatefulWidget { const SduiTreeBuilder({ super.key, @@ -23,6 +26,21 @@ class SduiTreeBuilder extends material.StatefulWidget { material.State createState() => SduiTreeBuilderState(); } +class _VisibleRow { + const _VisibleRow.node(this.node, this.depth) + : error = null, + isError = false; + + const _VisibleRow.error(this.error, this.depth) + : node = null, + isError = true; + + final SduiTreeNode? node; + final int depth; + final String? error; + final bool isError; +} + class SduiTreeBuilderState extends material.State { late List _roots; final Set _loading = {}; @@ -30,6 +48,8 @@ class SduiTreeBuilderState extends material.State { final Set _expanded = {}; final Map _expandErrors = {}; + static const double _rowExtent = 36; + @override void initState() { super.initState(); @@ -104,109 +124,124 @@ class SduiTreeBuilderState extends material.State { ]; } + List<_VisibleRow> _flattenVisible() { + final out = <_VisibleRow>[]; + void walk(SduiTreeNode node, int depth) { + out.add(_VisibleRow.node(node, depth)); + if (!_expanded.contains(node.id)) return; + final err = _expandErrors[node.id]; + if (err != null) { + out.add(_VisibleRow.error(err, depth)); + } + for (final child in node.children) { + walk(child, depth + 1); + } + } + + for (final root in _roots) { + walk(root, 0); + } + return out; + } + @override material.Widget build(material.BuildContext context) { - final tree = material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - for (final root in _roots) _buildNode(root, depth: 0), - ], + final rows = _flattenVisible(); + final list = material.ListView.builder( + shrinkWrap: widget.maxHeight == null, + physics: widget.maxHeight == null + ? const material.NeverScrollableScrollPhysics() + : const material.ClampingScrollPhysics(), + itemExtent: _rowExtent, + itemCount: rows.length, + itemBuilder: (context, index) { + final row = rows[index]; + if (row.isError) { + return material.Padding( + padding: material.EdgeInsets.only(left: 36.0 + row.depth * 16.0), + child: material.Align( + alignment: material.Alignment.centerLeft, + child: Text(row.error!).muted().xSmall(), + ), + ); + } + return _buildNodeRow(row.node!, depth: row.depth); + }, ); - if (widget.maxHeight == null) return tree; + if (widget.maxHeight == null) return list; return material.ConstrainedBox( constraints: material.BoxConstraints(maxHeight: widget.maxHeight!), - child: material.SingleChildScrollView( - physics: const material.ClampingScrollPhysics(), - child: tree, - ), + child: list, ); } - material.Widget _buildNode(SduiTreeNode node, {required int depth}) { + material.Widget _buildNodeRow(SduiTreeNode node, {required int depth}) { final canExpand = node.expandable || node.hasChildren; final isExpanded = _expanded.contains(node.id); final isLoading = _loading.contains(node.id); - final expandError = _expandErrors[node.id]; final nodeKind = _resolveNodeKind(node); final isBrowsable = nodeKind == 'table' || nodeKind == 'view'; - return material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.InkWell( - onTap: isBrowsable ? () => widget.onNodeSelected?.call(node) : null, - child: material.Padding( - padding: material.EdgeInsets.only( - left: 8.0 + depth * 16.0, - right: 8, - top: 4, - bottom: 4, - ), - child: material.Row( - children: [ - if (canExpand) - material.SizedBox( - width: 28, - height: 28, - child: material.IconButton( - padding: material.EdgeInsets.zero, - iconSize: 18, - onPressed: () { - if (isExpanded) { - _onCollapse(node); - } else { - _onExpand(node); - } - }, - icon: material.Icon( - isExpanded - ? material.Icons.expand_more - : material.Icons.chevron_right, - ), - ), - ) - else - const material.SizedBox(width: 28), - if (isLoading) - const material.SizedBox( - width: 14, - height: 14, - child: material.CircularProgressIndicator(strokeWidth: 2), - ) - else - material.Icon( - _iconFor(node), - size: 16, - ), - const Gap(8), - material.Expanded( - child: material.Text( - node.label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, - fontWeight: - isBrowsable ? material.FontWeight.w600 : null, - ), + return material.InkWell( + onTap: isBrowsable ? () => widget.onNodeSelected?.call(node) : null, + child: material.Padding( + padding: material.EdgeInsets.only( + left: 8.0 + depth * 16.0, + right: 8, + ), + child: material.Row( + children: [ + if (canExpand) + material.SizedBox( + width: 28, + height: 28, + child: material.IconButton( + padding: material.EdgeInsets.zero, + iconSize: 18, + onPressed: () { + if (isExpanded) { + _onCollapse(node); + } else { + _onExpand(node); + } + }, + icon: material.Icon( + isExpanded + ? material.Icons.expand_more + : material.Icons.chevron_right, ), ), - ], + ) + else + const material.SizedBox(width: 28), + if (isLoading) + const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator(strokeWidth: 2), + ) + else + material.Icon( + _iconFor(node), + size: 16, + ), + const Gap(8), + material.Expanded( + child: material.Text( + node.label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + fontWeight: isBrowsable ? material.FontWeight.w600 : null, + ), + ), ), - ), + ], ), - if (isExpanded && expandError != null) - material.Padding( - padding: material.EdgeInsets.only(left: 36.0 + depth * 16.0), - child: Text(expandError).muted().xSmall(), - ), - if (isExpanded) - for (final child in node.children) - _buildNode(child, depth: depth + 1), - ], + ), ); } From 79e055c3c65715e2129ca9d1ba09e4a07eff5859 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:32:24 +0300 Subject: [PATCH 07/23] perf(editor): honor kSyntaxHighlightIsolateThreshold Small buffers highlight inline; large buffers still use compute(), avoiding isolate overhead on every keystroke in short SQL editors. Closes #424 --- lib/core/editor/syntax_highlight_isolate.dart | 7 +++++- .../editor/syntax_highlight_isolate_test.dart | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/core/editor/syntax_highlight_isolate.dart b/lib/core/editor/syntax_highlight_isolate.dart index ca69d171..74c4cc0d 100644 --- a/lib/core/editor/syntax_highlight_isolate.dart +++ b/lib/core/editor/syntax_highlight_isolate.dart @@ -118,9 +118,14 @@ TextStyle? _styleFromSegment(HighlightSegment s, TextStyle? base) { ); } -/// Runs [syntaxHighlightInIsolate] off the UI thread. +/// Runs highlighting on a worker isolate when [job.code] is large enough; +/// otherwise highlights synchronously on the calling isolate (avoids +/// `compute` overhead for small editors). Future> highlightOffMainThread( SyntaxHighlightJob job, ) { + if (job.code.length < kSyntaxHighlightIsolateThreshold) { + return Future>.value(syntaxHighlightInIsolate(job)); + } return compute(syntaxHighlightInIsolate, job); } diff --git a/test/core/editor/syntax_highlight_isolate_test.dart b/test/core/editor/syntax_highlight_isolate_test.dart index 97be6345..601e6407 100644 --- a/test/core/editor/syntax_highlight_isolate_test.dart +++ b/test/core/editor/syntax_highlight_isolate_test.dart @@ -33,4 +33,27 @@ void main() { expect(segments, isNotEmpty); expect(segments.map((s) => s.text).join(), code); }); + + test('small SQL buffer highlights inline below isolate threshold', () async { + const code = 'SELECT 1;'; + expect(code.length, lessThan(kSyntaxHighlightIsolateThreshold)); + + final config = buildDefaultEditorHighlighterConfig( + QueryaTheme.darkDefault.editor, + ); + final segments = await highlightOffMainThread( + SyntaxHighlightJob( + code: code, + language: 'sql', + themeConfigJson: config, + grammarJson: SyntaxHighlightService.grammarJsonFor( + QueryaCodeLanguage.sql, + ), + wrapperArgb: QueryaTheme.darkDefault.editor.foreground.toARGB32(), + ), + ); + + expect(segments, isNotEmpty); + expect(segments.map((s) => s.text).join(), code); + }); } From 8cd378694c19af16b3b587a73e4c9d1d97c5e242 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:38:23 +0300 Subject: [PATCH 08/23] perf(results): convert row cells with yielding instead of compute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid shipping full Object? matrices across isolates (peak ~2× copy). Shared convertResultRowsToStringsYielding; MySQL SQL workspace converts while streaming rowsStream. Postgres/SQLite/table browse use the helper. Closes #421 --- .../database/result_row_string_convert.dart | 33 +++++++++++++++++++ lib/features/mysql/mysql_sql_workspace.dart | 21 ++++++------ .../postgresql/postgres_sql_workspace.dart | 9 ++--- .../postgresql/postgres_table_view.dart | 20 ++++++----- lib/features/sqlite/sqlite_sql_workspace.dart | 11 +++---- .../result_row_string_convert_test.dart | 25 ++++++++++++++ 6 files changed, 87 insertions(+), 32 deletions(-) create mode 100644 lib/core/database/result_row_string_convert.dart create mode 100644 test/core/database/result_row_string_convert_test.dart diff --git a/lib/core/database/result_row_string_convert.dart b/lib/core/database/result_row_string_convert.dart new file mode 100644 index 00000000..16f9f254 --- /dev/null +++ b/lib/core/database/result_row_string_convert.dart @@ -0,0 +1,33 @@ +/// Converts SQL result cells to display strings without a second isolate copy. +/// +/// Prefer this over [compute] for large matrices: shipping `List>` +/// across isolates often costs more than `toString()` itself and roughly +/// doubles peak memory. Yielding every [yieldEvery] rows keeps the UI isolate +/// responsive for 10k+ row caps. +library; + +const int kResultStringConvertYieldEvery = 250; + +/// Maps null cells to `'NULL'` and others via [Object.toString]. +String resultCellToDisplayString(Object? value) => + value == null ? 'NULL' : value.toString(); + +/// Converts [rowValues] to string rows, yielding periodically. +Future>> convertResultRowsToStringsYielding( + List> rowValues, { + int yieldEvery = kResultStringConvertYieldEvery, +}) async { + if (rowValues.isEmpty) return const []; + + final out = >[]; + for (var i = 0; i < rowValues.length; i++) { + final row = rowValues[i]; + out.add([ + for (final value in row) resultCellToDisplayString(value), + ]); + if (yieldEvery > 0 && (i + 1) % yieldEvery == 0) { + await Future.delayed(Duration.zero); + } + } + return out; +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 0a79a82b..955ff704 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -1,13 +1,13 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; 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/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -18,7 +18,6 @@ import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; import 'package:querya_desktop/features/main_screen/sql_query_history_dialog.dart'; -import 'package:querya_desktop/features/mysql/mysql_result_utils.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Ad-hoc SQL editor + results for MySQL / MariaDB. @@ -192,7 +191,8 @@ class _MysqlSqlWorkspaceState extends material.State { cols.add(c.name.isNotEmpty ? c.name : 'col_${cols.length}'); } - final rawRows = >[]; + // Convert while streaming — no Object? matrix + isolate double-copy (#421). + final outRows = >[]; var n = 0; final cap = _resultMaxRows; var truncated = false; @@ -201,17 +201,18 @@ class _MysqlSqlWorkspaceState extends material.State { truncated = true; break; } - rawRows.add( - List.generate(row.numOfColumns, (i) => row.colAt(i)), + outRows.add( + List.generate( + row.numOfColumns, + (i) => resultCellToDisplayString(row.colAt(i)), + ), ); n++; + if (n % kResultStringConvertYieldEvery == 0) { + await Future.delayed(Duration.zero); + } } - final job = MysqlResultConvertJob(rowValues: rawRows); - final outRows = rawRows.length > 500 - ? await compute(convertMysqlResultRowsToStrings, job) - : convertMysqlResultRowsToStrings(job); - int? affected; if (cols.isEmpty && outRows.isEmpty) { affected = _affectedInt(rs.affectedRows); diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 5e266483..464ae4df 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1,16 +1,15 @@ 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; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; +import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -359,10 +358,8 @@ class _PostgresSqlWorkspaceState extends material.State { n++; } - final job = PostgresResultConvertJob(rowValues: rawRows); - final outRows = rawRows.length > 500 - ? await compute(convertPostgresResultRowsToStrings, job) - : convertPostgresResultRowsToStrings(job); + // Yielding convert avoids isolate double-copy of the matrix (#421). + final outRows = await convertResultRowsToStringsYielding(rawRows); setState(() { _columns = cols; diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index b76e8658..05e6ea26 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -1,7 +1,7 @@ -import 'package:flutter/foundation.dart' show compute; 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/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_editor_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_privileges_dialog.dart'; @@ -187,11 +187,12 @@ class _PostgresTableViewState extends material.State { (i) => result.schema.columns[i].columnName ?? 'col_$i', ); - final rawRows = result.map((row) { - return List.generate(row.length, (i) => row[i]); - }).toList(); + final rawRows = >[ + for (final row in result) + List.generate(row.length, (i) => row[i]), + ]; - final stringRows = await compute(convertResultRowsToStrings, rawRows); + final stringRows = await convertResultRowsToStringsYielding(rawRows); if (!mounted) return; setState(() { @@ -235,11 +236,12 @@ class _PostgresTableViewState extends material.State { (i) => result.schema.columns[i].columnName ?? 'col_$i', ); - final rawRows = result.map((row) { - return List.generate(row.length, (i) => row[i]); - }).toList(); + final rawRows = >[ + for (final row in result) + List.generate(row.length, (i) => row[i]), + ]; - final stringRows = await compute(convertResultRowsToStrings, rawRows); + final stringRows = await convertResultRowsToStringsYielding(rawRows); if (!mounted) return; setState(() { diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 64f034a1..8a98276f 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -1,14 +1,13 @@ 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/sql_limit.dart'; +import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -178,10 +177,8 @@ class _SqliteSqlWorkspaceState extends material.State { return cols.map((col) => row[col]).toList(); }).toList(); - final job = SqliteResultConvertJob(rowValues: rawRows); - final outRows = rawRows.length > 500 - ? await compute(convertSqliteResultRowsToStrings, job) - : convertSqliteResultRowsToStrings(job); + // Yielding convert avoids isolate double-copy of the matrix (#421). + final outRows = await convertResultRowsToStringsYielding(rawRows); setState(() { _columns = cols; diff --git a/test/core/database/result_row_string_convert_test.dart b/test/core/database/result_row_string_convert_test.dart new file mode 100644 index 00000000..de451b1b --- /dev/null +++ b/test/core/database/result_row_string_convert_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/result_row_string_convert.dart'; + +void main() { + group('convertResultRowsToStringsYielding', () { + test('maps null to NULL and yields without isolate', () async { + final rows = >[ + [1, null, 'a'], + [2, 'x', null], + ]; + final out = await convertResultRowsToStringsYielding( + rows, + yieldEvery: 1, + ); + expect(out, [ + ['1', 'NULL', 'a'], + ['2', 'x', 'NULL'], + ]); + }); + + test('empty input returns empty', () async { + expect(await convertResultRowsToStringsYielding(const []), isEmpty); + }); + }); +} From 46c5a524270cb98774a575a6c19b8e9f6fe98127 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:43:50 +0300 Subject: [PATCH 09/23] =?UTF-8?q?perf(mysql):=20yield=20during=20table=20b?= =?UTF-8?q?rowse=20row=E2=86=92string=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-land #422 onto dev — prior PR merged into issue/421 base and never reached the default branch. Closes #422 --- lib/features/mysql/mysql_table_view.dart | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index a234d3f3..d365c2fc 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/mysql/mysql_sql_editor_dialog.dart'; import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; @@ -144,15 +145,20 @@ class _MysqlTableViewState extends material.State { return rs.cols.map((c) => c.name.isNotEmpty ? c.name : 'col').toList(); } - List> _resultRows(IResultSet rs) { + Future>> _resultRowsAsync(IResultSet rs) async { final out = >[]; + var n = 0; for (final row in rs.rows) { out.add( List.generate( row.numOfColumns, - (i) => row.colAt(i) ?? 'NULL', + (i) => resultCellToDisplayString(row.colAt(i)), ), ); + n++; + if (n % kResultStringConvertYieldEvery == 0) { + await Future.delayed(Duration.zero); + } } return out; } @@ -186,7 +192,7 @@ class _MysqlTableViewState extends material.State { if (!mounted) return; final colNames = _resultColumns(result); - final stringRows = _resultRows(result); + final stringRows = await _resultRowsAsync(result); setState(() { _columnNames = colNames; @@ -223,9 +229,10 @@ class _MysqlTableViewState extends material.State { try { final result = await conn.execute(sql); if (!mounted) return; + final stringRows = await _resultRowsAsync(result); setState(() { _columnNames = _resultColumns(result); - _rows = _resultRows(result); + _rows = stringRows; _rowsOnPage = _rows.length; _totalRowCount = null; _loading = false; From 6c510bf9ea0ce444ceee06ad86313a15ce240859 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:45:15 +0300 Subject: [PATCH 10/23] perf(redis): pipeline TYPE/TTL for SCAN batches Enqueue all TYPE then all TTL before awaiting replies (pipe_start/end), so a 100-key batch is one RTT burst instead of ~200 round-trips. Closes #426 --- lib/core/database/redis_connection.dart | 39 +++++++++++++++++ lib/features/redis/redis_keys_view.dart | 29 ++++++++----- .../database/redis_types_and_ttls_test.dart | 43 +++++++++++++++++++ 3 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 test/core/database/redis_types_and_ttls_test.dart diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index ee6b2528..f7f8fbe9 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -206,6 +206,45 @@ class RedisConnection { return result is int ? result : int.tryParse(result.toString()) ?? -1; } + /// Pipelined TYPE + TTL for a SCAN batch. + /// + /// Writes all commands before awaiting replies (redis-dart FIFO parse + /// queue + optional Nagle via [Command.pipe_start]), so a batch of N keys + /// costs ~1 RTT instead of ~2N sequential round-trips. + Future> typesAndTtls(List keys) async { + if (keys.isEmpty) return const []; + if (!isConnected) { + throw StateError('Not connected to Redis'); + } + + final cmd = _command; + cmd?.pipe_start(); + try { + final typeFutures = >[ + for (final key in keys) + sendCommand(['TYPE', key]).then( + (v) => v?.toString() ?? 'none', + onError: (_) => 'unknown', + ), + ]; + final ttlFutures = >[ + for (final key in keys) + sendCommand(['TTL', key]).then( + (v) => v is int ? v : int.tryParse(v.toString()) ?? -1, + onError: (_) => -1, + ), + ]; + final types = await Future.wait(typeFutures); + final ttls = await Future.wait(ttlFutures); + return [ + for (var i = 0; i < keys.length; i++) + (type: types[i], ttl: ttls[i]), + ]; + } finally { + cmd?.pipe_end(); + } + } + /// GET (string). Future get(String key) async { final result = await sendCommand(['GET', key]); diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 1d6cc7ba..acba5dbc 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -78,17 +78,24 @@ class _RedisKeysViewState extends material.State { count: 100, ); - // Fetch type and TTL for each key concurrently - final futures = keyNames.map((name) async { - try { - final type = await widget.connection.keyType(name); - final ttl = await widget.connection.ttl(name); - return _KeyInfo(name: name, type: type, ttl: ttl); - } catch (_) { - return _KeyInfo(name: name, type: 'unknown', ttl: -1); - } - }); - final infos = await Future.wait(futures); + // One pipelined burst of TYPE+TTL (not N× Future.wait round-trips). + List<_KeyInfo> infos; + try { + final metas = await widget.connection.typesAndTtls(keyNames); + infos = [ + for (var i = 0; i < keyNames.length; i++) + _KeyInfo( + name: keyNames[i], + type: metas[i].type, + ttl: metas[i].ttl, + ), + ]; + } catch (_) { + infos = [ + for (final name in keyNames) + _KeyInfo(name: name, type: 'unknown', ttl: -1), + ]; + } if (!mounted) return; setState(() { diff --git a/test/core/database/redis_types_and_ttls_test.dart b/test/core/database/redis_types_and_ttls_test.dart new file mode 100644 index 00000000..b7067209 --- /dev/null +++ b/test/core/database/redis_types_and_ttls_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; + +/// Counts outbound commands to prove [typesAndTtls] fires TYPE+TTL without +/// awaiting between keys (true pipeline enqueue). +class _CountingRedisFake extends RedisConnectionTestFake { + _CountingRedisFake() : super(firstScanKeys: const []); + + final List ops = []; + + @override + Future sendCommand(List args) async { + ops.add(args.first.toString().toUpperCase()); + // Delay so overlapping awaits would change order if callers awaited per key. + await Future.delayed(Duration.zero); + return super.sendCommand(args); + } +} + +void main() { + test('typesAndTtls enqueues all TYPE then all TTL before settling', () async { + final fake = _CountingRedisFake(); + await fake.connect(); + + final metas = await fake.typesAndTtls(['a', 'b', 'c']); + + expect(metas, hasLength(3)); + expect(metas.map((m) => m.type), everyElement('string')); + expect(metas.map((m) => m.ttl), everyElement(-1)); + + // All TYPE writes precede all TTL writes (single burst, not TYPE+TTL per key). + expect( + fake.ops, + ['TYPE', 'TYPE', 'TYPE', 'TTL', 'TTL', 'TTL'], + ); + }); + + test('typesAndTtls returns empty for empty keys', () async { + final fake = RedisConnectionTestFake(); + await fake.connect(); + expect(await fake.typesAndTtls(const []), isEmpty); + }); +} From cd110d176315466be4d204cd607c8bd4942a190f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:46:33 +0300 Subject: [PATCH 11/23] perf(sandbox): bound stderr carry and scan newlines incrementally Cap incomplete-line carry, drop overflow until the next newline, and avoid toString+split rebuilds on every chunk. Bound sanitize input length. Closes #427 --- .../extensions/sandbox/sandbox_sanitizer.dart | 6 + .../sandbox/sandbox_stderr_pipe.dart | 108 +++++++++++++++--- .../sandbox_sanitization_pipe_test.dart | 77 +++++++++++++ 3 files changed, 175 insertions(+), 16 deletions(-) diff --git a/lib/core/extensions/sandbox/sandbox_sanitizer.dart b/lib/core/extensions/sandbox/sandbox_sanitizer.dart index 2b2bca53..93300b2f 100644 --- a/lib/core/extensions/sandbox/sandbox_sanitizer.dart +++ b/lib/core/extensions/sandbox/sandbox_sanitizer.dart @@ -32,9 +32,15 @@ class SandboxSanitizer { caseSensitive: false, ); + /// Soft bound for regex work on a single line (stderr pipe also caps). + static const maxInputChars = 256 * 1024; + /// Sanitizes a single chunk / line of plugin output. static String sanitize(String input) { if (input.isEmpty) return input; + if (input.length > maxInputChars) { + input = input.substring(0, maxInputChars); + } var out = input; out = out.replaceAll(_privateKey, redactionToken); out = out.replaceAll(_jwt, redactionToken); diff --git a/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart b/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart index e678c660..5eb1f8b0 100644 --- a/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart +++ b/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart @@ -9,21 +9,40 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_sanitizer.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_security_audit.dart'; /// Captures `process.stderr`, sanitizes it, and writes to a rotating log. +/// +/// Incomplete lines are held in a capped carry buffer. Newline scanning walks +/// only the new chunk (no full-buffer `split` rebuild each time). Oversized +/// lines are truncated and the remainder is dropped until the next newline. class SandboxStderrPipe { SandboxStderrPipe({ required this.pluginId, required this.log, this.audit, this.onSanitizedLine, - }); + this.maxCarryChars = defaultMaxCarryChars, + this.maxSanitizeChars = defaultMaxSanitizeChars, + }) : assert(maxCarryChars > 0), + assert(maxSanitizeChars > 0); + + /// Default cap for an incomplete stderr line held across chunks. + static const defaultMaxCarryChars = 256 * 1024; + + /// Default max length passed into [SandboxSanitizer.sanitize]. + static const defaultMaxSanitizeChars = 256 * 1024; + + static const _truncatedSuffix = '…[truncated]'; final String pluginId; final SandboxRotatingLog log; final SandboxSecurityAudit? audit; final void Function(String line)? onSanitizedLine; + final int maxCarryChars; + final int maxSanitizeChars; StreamSubscription>? _subscription; final StringBuffer _carry = StringBuffer(); + int _carryLength = 0; + var _dropUntilNewline = false; Future _writeChain = Future.value(); var _closed = false; @@ -35,6 +54,8 @@ class SandboxStderrPipe { SandboxSecurityAudit? audit, int maxBytes = 5 * 1024 * 1024, int maxFiles = 2, + int maxCarryChars = defaultMaxCarryChars, + int maxSanitizeChars = defaultMaxSanitizeChars, void Function(String line)? onSanitizedLine, }) async { final file = await SandboxLogPaths.pluginLogFile(handle.pluginId); @@ -47,6 +68,8 @@ class SandboxStderrPipe { ), audit: audit, onSanitizedLine: onSanitizedLine, + maxCarryChars: maxCarryChars, + maxSanitizeChars: maxSanitizeChars, ); pipe.listen(handle.process.stderr); return pipe; @@ -80,36 +103,89 @@ class SandboxStderrPipe { void _onBytes(List chunk) { if (chunk.isEmpty) return; - _carry.write(utf8.decode(chunk, allowMalformed: true)); - _drainLines(); + var text = utf8.decode(chunk, allowMalformed: true); + if (_dropUntilNewline) { + final nl = text.indexOf('\n'); + if (nl < 0) return; + _dropUntilNewline = false; + text = text.substring(nl + 1); + if (text.isEmpty) return; + } + _drainIncoming(text); } - void _drainLines() { - final text = _carry.toString(); - final parts = text.split('\n'); - _carry.clear(); - if (!text.endsWith('\n')) { - _carry.write(parts.removeLast()); - } else if (parts.isNotEmpty && parts.last.isEmpty) { - parts.removeLast(); + /// Scan [incoming] for newlines; only the incomplete tail stays in [_carry]. + void _drainIncoming(String incoming) { + var start = 0; + while (true) { + final nl = incoming.indexOf('\n', start); + if (nl < 0) { + _appendCarry(incoming.substring(start)); + return; + } + final segment = incoming.substring(start, nl); + final line = _carryLength == 0 + ? segment + : (_carry..write(segment)).toString(); + if (_carryLength != 0) { + _carry.clear(); + _carryLength = 0; + } + _enqueueLine(line); + start = nl + 1; + } + } + + void _appendCarry(String rest) { + if (rest.isEmpty) return; + if (_carryLength + rest.length <= maxCarryChars) { + _carry.write(rest); + _carryLength += rest.length; + return; + } + + final room = maxCarryChars - _carryLength; + if (room > 0) { + _carry.write(rest.substring(0, room)); + _carryLength += room; } + final flushed = '${_carry.toString()}$_truncatedSuffix'; + _carry.clear(); + _carryLength = 0; + _dropUntilNewline = true; + _enqueueLine(flushed); - for (final raw in parts) { - _writeChain = _writeChain.then((_) => _writeSanitized(raw)); + if (room < rest.length) { + final nl = rest.indexOf('\n', room); + if (nl >= 0) { + _dropUntilNewline = false; + final after = rest.substring(nl + 1); + if (after.isNotEmpty) { + _drainIncoming(after); + } + } } } + void _enqueueLine(String raw) { + _writeChain = _writeChain.then((_) => _writeSanitized(raw)); + } + Future _flushCarry() async { - if (_carry.isEmpty) return; + if (_carryLength == 0) return; final raw = _carry.toString(); _carry.clear(); + _carryLength = 0; await _writeSanitized(raw); } Future _writeSanitized(String raw) async { try { - final sanitized = SandboxSanitizer.sanitize(raw); - if (sanitized != raw && audit != null) { + final bounded = raw.length > maxSanitizeChars + ? raw.substring(0, maxSanitizeChars) + : raw; + final sanitized = SandboxSanitizer.sanitize(bounded); + if (sanitized != bounded && audit != null) { await audit!.record( type: SandboxSecurityEventType.secretLeakBlocked, pluginId: pluginId, diff --git a/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart b/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart index 83934dda..1f6b33a1 100644 --- a/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart +++ b/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart @@ -270,5 +270,82 @@ QyNTUxOQAAACBA1m7X8J9H6P8Q9J8H6P8Q9J8H6P8Q9J8H6P8Q9J8H6Q== await handle.dispose(); }); + + test('caps carry and drops remainder until newline', () async { + final process = _FakeProcess(); + final scratch = await SandboxScratchDirectory.create( + pluginId: 'pipe.cap', + baseDirectory: temp, + token: '3', + ); + final handle = SandboxProcessHandle( + pluginId: 'pipe.cap', + process: process, + scratch: scratch, + launchCommand: const SandboxLaunchCommand( + executable: '/bin/true', + arguments: [], + platform: 'linux', + usesOsSandbox: false, + ), + ); + + final lines = []; + final pipe = await SandboxStderrPipe.attach( + handle, + maxCarryChars: 8, + onSanitizedLine: lines.add, + ); + + // No newline: force truncate at 8 chars, then more without newline. + process.emitStderr('abcdefghij'); + process.emitStderr('ignored'); + process.emitStderr('\nafter\n'); + await Future.delayed(const Duration(milliseconds: 50)); + await pipe.close(); + + expect(lines, hasLength(2)); + expect(lines[0], startsWith('abcdefgh')); + expect(lines[0], contains('[truncated]')); + expect(lines[0], isNot(contains('ij'))); + expect(lines[1], 'after'); + + await handle.dispose(); + }); + + test('drains split lines without rebuilding full carry each chunk', () async { + final process = _FakeProcess(); + final scratch = await SandboxScratchDirectory.create( + pluginId: 'pipe.split', + baseDirectory: temp, + token: '4', + ); + final handle = SandboxProcessHandle( + pluginId: 'pipe.split', + process: process, + scratch: scratch, + launchCommand: const SandboxLaunchCommand( + executable: '/bin/true', + arguments: [], + platform: 'linux', + usesOsSandbox: false, + ), + ); + + final lines = []; + final pipe = await SandboxStderrPipe.attach( + handle, + onSanitizedLine: lines.add, + ); + + process.emitStderr('hel'); + process.emitStderr('lo\nwor'); + process.emitStderr('ld\n'); + await Future.delayed(const Duration(milliseconds: 50)); + await pipe.close(); + + expect(lines, ['hello', 'world']); + await handle.dispose(); + }); }); } From d43dcf2e0850874f3678b9e7e7f5659ba4ab189f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:49:23 +0300 Subject: [PATCH 12/23] perf(storage): composite SQL history index and PK prune Index (connection_id, database_name, recorded_at, id); prune via count + oldest-id DELETE instead of nested OFFSET subquery. Schema v8 migration. Closes #428 --- lib/core/storage/local_db.dart | 70 +++++++++++++++---- test/core/storage/sql_query_history_test.dart | 18 +++++ 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 0580c7ca..bd7ce795 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -6,7 +6,7 @@ import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; -const _dbVersion = 7; +const _dbVersion = 8; /// Fallback when [recordSqlQueryHistory] is called without `maxEntries`. /// Keep in sync with [kDefaultSqlHistoryMaxEntries] in `app_settings.dart`. @@ -108,7 +108,7 @@ class LocalDb { '''); await db.execute(''' CREATE INDEX idx_sql_query_history_lookup - ON sql_query_history (connection_id, recorded_at DESC) + ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) '''); } @@ -195,6 +195,13 @@ class LocalDb { await db.execute('ALTER TABLE connections ADD COLUMN extension_id TEXT'); await db.execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); } + if (oldVersion < 8) { + await db.execute('DROP INDEX IF EXISTS idx_sql_query_history_lookup'); + await db.execute(''' + CREATE INDEX idx_sql_query_history_lookup + ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) + '''); + } } Future getAppSetting(String key) async { @@ -241,18 +248,57 @@ class LocalDb { 'sql_text': sql, 'recorded_at': now, }); - await db.rawDelete( + await _pruneSqlQueryHistoryBucket( + db, + connectionId: connectionId, + databaseName: dbKey, + maxEntries: maxEntries, + ); + } + + /// Keeps the newest [maxEntries] rows in a (connection, database) bucket. + /// + /// Selects overflow ids (oldest first), then deletes by primary key — avoids + /// nested `DELETE … SELECT … OFFSET` plans as history grows. + Future _pruneSqlQueryHistoryBucket( + Database db, { + required int connectionId, + required String? databaseName, + required int maxEntries, + }) async { + final countRows = await db.rawQuery( ''' - DELETE FROM sql_query_history WHERE id IN ( - SELECT id FROM ( - SELECT id FROM sql_query_history - WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ORDER BY recorded_at DESC, id DESC - LIMIT -1 OFFSET ? - ) - ) + SELECT COUNT(*) AS c FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ''', + [connectionId, databaseName], + ); + final count = _sqliteInt(countRows.first['c']) ?? 0; + final excess = count - maxEntries; + if (excess <= 0) return; + + final overflow = await db.rawQuery( + ''' + SELECT id FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ORDER BY recorded_at ASC, id ASC + LIMIT ? ''', - [connectionId, dbKey, maxEntries], + [connectionId, databaseName, excess], + ); + if (overflow.isEmpty) return; + + final ids = []; + for (final row in overflow) { + final id = row['id']; + if (id != null) ids.add(id); + } + if (ids.isEmpty) return; + + final placeholders = List.filled(ids.length, '?').join(','); + await db.rawDelete( + 'DELETE FROM sql_query_history WHERE id IN ($placeholders)', + ids, ); } diff --git a/test/core/storage/sql_query_history_test.dart b/test/core/storage/sql_query_history_test.dart index 77fe48d7..e0342270 100644 --- a/test/core/storage/sql_query_history_test.dart +++ b/test/core/storage/sql_query_history_test.dart @@ -124,6 +124,24 @@ void main() { expect(list.map((e) => e.sqlText), ['q4', 'q3', 'q2']); }); + test('history lookup index includes database_name', () async { + await LocalDb.instance.getAppSetting('__touch__'); // ensure DB open + final dbFile = p.join(tempDir.path, 'querya_desktop', 'querya.db'); + final raw = await databaseFactoryFfi.openDatabase( + dbFile, + options: OpenDatabaseOptions(readOnly: true), + ); + try { + final rows = await raw.rawQuery( + "SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_sql_query_history_lookup'", + ); + expect(rows, isNotEmpty); + expect(rows.first['sql'], contains('database_name')); + } finally { + await raw.close(); + } + }); + test('separate buckets for different database_name', () async { const row = ConnectionRow( type: 'postgres', From ea48ce0a3341e8c1cf95ba4ce55169503b074449 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:51:56 +0300 Subject: [PATCH 13/23] perf(theme): incremental definition scan by mtime Skip unchanged theme file reads on refresh; cache builtins; theme-only watcher modify events avoid full extension registry reload. Closes #425 --- lib/core/theme/theme_controller.dart | 9 ++- lib/core/theme/theme_folder_watcher.dart | 22 ++++-- lib/core/theme/theme_registry_service.dart | 78 ++++++++++++++----- test/core/theme/theme_controller_test.dart | 6 +- .../core/theme/theme_folder_watcher_test.dart | 6 +- .../core/theme/theme_registry_cache_test.dart | 19 +++++ 6 files changed, 109 insertions(+), 31 deletions(-) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index a156a99d..148c40af 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -227,7 +227,8 @@ class ThemeController extends ChangeNotifier { } _themeFolderWatcher ??= ThemeFolderWatcher( themesDirectory: ExtensionPaths.extensionsDirectory, - onThemesChanged: loadAvailableThemes, + onThemesChanged: ({required bool structuralChange}) => + loadAvailableThemes(reloadExtensions: structuralChange), ); await _themeFolderWatcher!.start(); } @@ -296,14 +297,16 @@ class ThemeController extends ChangeNotifier { _notifyThemeChanged(); } - Future loadAvailableThemes() async { + Future loadAvailableThemes({bool reloadExtensions = true}) async { if (_isLoadingAvailableThemes) return; _isLoadingAvailableThemes = true; notifyListeners(); try { - final scanned = await _registryService.loadThemeDefinitions(); + final scanned = await _registryService.loadThemeDefinitions( + reloadExtensions: reloadExtensions, + ); _availableThemes = _mergeBuiltinThemes(scanned); _syncSelectedThemeAfterRefresh(); } on Object { diff --git a/lib/core/theme/theme_folder_watcher.dart b/lib/core/theme/theme_folder_watcher.dart index 5eeae28d..123bb158 100644 --- a/lib/core/theme/theme_folder_watcher.dart +++ b/lib/core/theme/theme_folder_watcher.dart @@ -8,19 +8,22 @@ import 'package:path/path.dart' as p; class ThemeFolderWatcher { ThemeFolderWatcher({ required Future Function() themesDirectory, - required Future Function() onThemesChanged, + required Future Function({required bool structuralChange}) + onThemesChanged, this.debounce = const Duration(milliseconds: 400), }) : _themesDirectory = themesDirectory, _onThemesChanged = onThemesChanged; final Future Function() _themesDirectory; - final Future Function() _onThemesChanged; + final Future Function({required bool structuralChange}) + _onThemesChanged; final Duration debounce; StreamSubscription? _subscription; Timer? _debounceTimer; bool _started = false; bool _refreshInFlight = false; + bool _pendingStructural = false; bool get isStarted => _started; @@ -71,23 +74,32 @@ class ThemeFolderWatcher { _subscription = null; _started = false; _refreshInFlight = false; + _pendingStructural = false; await Future.delayed(const Duration(milliseconds: 150)); } void _onFilesystemEvent(FileSystemEvent event) { if (!_isRelevantEvent(event)) return; + if (event is FileSystemCreateEvent || + event is FileSystemDeleteEvent || + event is FileSystemMoveEvent) { + _pendingStructural = true; + } + _debounceTimer?.cancel(); _debounceTimer = Timer(debounce, () { - unawaited(_triggerRefresh()); + final structural = _pendingStructural; + _pendingStructural = false; + unawaited(_triggerRefresh(structuralChange: structural)); }); } - Future _triggerRefresh() async { + Future _triggerRefresh({required bool structuralChange}) async { if (_refreshInFlight) return; _refreshInFlight = true; try { - await _onThemesChanged(); + await _onThemesChanged(structuralChange: structuralChange); } on Object catch (error) { debugPrint('ThemeFolderWatcher: refresh failed ($error)'); } finally { diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index 1d66f21b..9fbdcc26 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -48,19 +48,31 @@ class ThemeRegistryService { final List _bundledThemeAssetFiles; final _ThemeLruCache _themeCache; int _themeParseCount = 0; + int _themeFileReadCount = 0; bool _hasMigratedThemes = false; + final Map _definitionScanCache = {}; + List? _cachedBuiltinDefinitions; /// Number of cache misses that performed a full theme parse. @visibleForTesting int get themeParseCount => _themeParseCount; + /// Number of theme files read from disk during definition scans. + @visibleForTesting + int get themeFileReadCount => _themeFileReadCount; + /// Clears parsed theme cache and parse counter. void clearCache() { _themeCache.clear(); _themeParseCount = 0; + _themeFileReadCount = 0; + _definitionScanCache.clear(); + _cachedBuiltinDefinitions = null; } - Future> loadThemeDefinitions() async { + Future> loadThemeDefinitions({ + bool reloadExtensions = true, + }) async { final definitions = []; await _loadBuiltinAssetDefinitions(definitions); @@ -68,9 +80,13 @@ class ThemeRegistryService { if (!_hasMigratedThemes) { await _migrateLegacyThemesToExtensions(); _hasMigratedThemes = true; + } else if (reloadExtensions) { + await LocalExtensionRegistry.instance.reload(); + } else { + await LocalExtensionRegistry.instance.load(); } - await LocalExtensionRegistry.instance.reload(); + final seenPaths = {}; for (final manifest in LocalExtensionRegistry.instance.manifests) { if (manifest.type != ExtensionType.theme) continue; final installPath = manifest.installPath; @@ -84,9 +100,16 @@ class ThemeRegistryService { ? ThemeSource.imported : ThemeSource.filesystem; - final definition = await _definitionFromFile(file, source, extensionId: manifest.id); + final definition = await _definitionFromFile( + file, + source, + extensionId: manifest.id, + ); if (definition != null) { definitions.add(definition); + if (definition.path != null) { + seenPaths.add(definition.path!); + } } } @@ -98,8 +121,15 @@ class ThemeRegistryService { definition.source != ThemeSource.legacyImported, ); definitions.add(legacy); + if (legacy.path != null) { + seenPaths.add(legacy.path!); + } } + _definitionScanCache.removeWhere( + (path, _) => !_isAssetPath(path) && !seenPaths.contains(path), + ); + definitions.sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); @@ -492,6 +522,12 @@ class ThemeRegistryService { } Future _loadBuiltinAssetDefinitions(List out) async { + if (_cachedBuiltinDefinitions != null) { + out.addAll(_cachedBuiltinDefinitions!); + return; + } + + final builtins = []; for (final fileName in _bundledThemeAssetFiles) { final assetPath = BuiltinThemeAssets.assetPath(fileName); try { @@ -511,12 +547,15 @@ class ThemeRegistryService { contentHash: hash, ); if (definition != null) { - out.add(definition); + builtins.add(definition); + _definitionScanCache[assetPath] = definition; } } on Object catch (e) { _logScanError(assetPath, e); } } + _cachedBuiltinDefinitions = List.unmodifiable(builtins); + out.addAll(_cachedBuiltinDefinitions!); } Future _readThemeRaw(ThemeDefinition definition) async { @@ -547,28 +586,25 @@ class ThemeRegistryService { }) async { try { final stat = await file.stat(); + final cached = _definitionScanCache[file.path]; + if (cached != null && + cached.lastModified != null && + cached.lastModified == stat.modified && + (extensionId == null || cached.id == extensionId)) { + return cached; + } + + _themeFileReadCount++; final raw = await file.readAsString(); final hash = _contentHash(raw); final json = _decodeRoot(raw); if (json == null) { _logScanError(file.path, 'Invalid JSON'); + _definitionScanCache.remove(file.path); return null; } - final schema = json['schema']?.toString(); - if (schema == queryaThemeSchemaV1) { - return _definitionFromRaw( - json: json, - path: file.path, - fileBaseName: p.basenameWithoutExtension(file.path), - source: source, - contentHash: hash, - lastModified: stat.modified, - extensionId: extensionId, - ); - } - - return _definitionFromRaw( + final definition = _definitionFromRaw( json: json, path: file.path, fileBaseName: p.basenameWithoutExtension(file.path), @@ -577,6 +613,12 @@ class ThemeRegistryService { lastModified: stat.modified, extensionId: extensionId, ); + if (definition != null) { + _definitionScanCache[file.path] = definition; + } else { + _definitionScanCache.remove(file.path); + } + return definition; } on Object catch (e) { _logScanError(file.path, e); return null; diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 6cea1a06..0be27a8f 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -46,9 +46,11 @@ class _GatedRegistryService extends ThemeRegistryService { final gate = Completer(); @override - Future> loadThemeDefinitions() async { + Future> loadThemeDefinitions({ + bool reloadExtensions = true, + }) async { await gate.future; - return super.loadThemeDefinitions(); + return super.loadThemeDefinitions(reloadExtensions: reloadExtensions); } } diff --git a/test/core/theme/theme_folder_watcher_test.dart b/test/core/theme/theme_folder_watcher_test.dart index bf127861..76084642 100644 --- a/test/core/theme/theme_folder_watcher_test.dart +++ b/test/core/theme/theme_folder_watcher_test.dart @@ -80,7 +80,7 @@ void main() { var refreshCount = 0; final watcher = ThemeFolderWatcher( themesDirectory: () async => themesDir, - onThemesChanged: () async { + onThemesChanged: ({required bool structuralChange}) async { refreshCount++; }, debounce: const Duration(milliseconds: 80), @@ -104,7 +104,7 @@ void main() { var refreshCount = 0; final watcher = ThemeFolderWatcher( themesDirectory: () async => themesDir, - onThemesChanged: () async { + onThemesChanged: ({required bool structuralChange}) async { refreshCount++; if (!refreshGate.isCompleted) { refreshGate.complete(); @@ -132,7 +132,7 @@ void main() { var refreshCount = 0; final watcher = ThemeFolderWatcher( themesDirectory: () async => themesDir, - onThemesChanged: () async { + onThemesChanged: ({required bool structuralChange}) async { refreshCount++; }, debounce: const Duration(milliseconds: 80), diff --git a/test/core/theme/theme_registry_cache_test.dart b/test/core/theme/theme_registry_cache_test.dart index 4a4d7078..63cf85e9 100644 --- a/test/core/theme/theme_registry_cache_test.dart +++ b/test/core/theme/theme_registry_cache_test.dart @@ -73,6 +73,25 @@ void main() { }); group('ThemeRegistryService cache', () { + test('skips theme file read when mtime unchanged on refresh', () async { + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + + final first = await registry.loadThemeDefinitions(); + expect(first, hasLength(1)); + final readsAfterCold = registry.themeFileReadCount; + expect(readsAfterCold, greaterThan(0)); + + final second = await registry.loadThemeDefinitions( + reloadExtensions: false, + ); + expect(second, hasLength(1)); + expect(second.single.id, first.single.id); + expect(registry.themeFileReadCount, readsAfterCold); + }); + test('loads same definition twice with a single parse', () async { await _copyFixture( 'querya_custom_dark.json', From 7867feac7fe76c42474694d29eb3947af4e0f203 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 20:13:25 +0300 Subject: [PATCH 14/23] perf(ui): 2D-virtualize VirtualResultGrid columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build only the visible column window (plus overscan) with leading/trailing spacers so wide result sets do not mount O(rows×cols) cells. Header stays aligned with the body. Closes #423 --- .../main_screen/result_grid_view.dart | 173 +++++++++++++++++- .../main_screen/results_tab_test.dart | 79 ++++++++ 2 files changed, 244 insertions(+), 8 deletions(-) diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index 6e3f923d..332e13c6 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/layout/ui_scale.dart'; @@ -11,6 +12,56 @@ abstract final class ResultGridMetrics { static const double maxColumnWidth = 280; static const int columnWidthSampleRows = 40; static const int tooltipMinLength = 48; + + /// Extra columns built beyond the viewport to reduce scroll flicker. + static const int columnOverscan = 2; +} + +/// Inclusive visible column window with spacer widths for off-screen columns. +@immutable +class ResultGridColumnWindow { + const ResultGridColumnWindow({ + required this.first, + required this.last, + required this.leadingWidth, + required this.trailingWidth, + }); + + /// Empty window (no columns). + static const empty = ResultGridColumnWindow( + first: 0, + last: -1, + leadingWidth: 0, + trailingWidth: 0, + ); + + /// Inclusive first visible (or overscanned) column index. + final int first; + + /// Inclusive last visible (or overscanned) column index. + final int last; + + /// Width of columns strictly before [first] (left spacer). + final double leadingWidth; + + /// Width of columns strictly after [last] (right spacer). + final double trailingWidth; + + bool get isEmpty => last < first; + + int get columnCount => isEmpty ? 0 : last - first + 1; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResultGridColumnWindow && + first == other.first && + last == other.last && + leadingWidth == other.leadingWidth && + trailingWidth == other.trailingWidth; + + @override + int get hashCode => Object.hash(first, last, leadingWidth, trailingWidth); } /// Computes fixed column widths from headers and a sample of [rows]. @@ -38,7 +89,68 @@ List computeResultGridColumnWidths({ return widths; } -/// Virtualized read-only grid for SQL query results. +/// Prefix sums: `offsets[i]` = sum of widths `[0, i)`. +@visibleForTesting +List computeResultGridColumnOffsets(List columnWidths) { + final offsets = List.filled(columnWidths.length + 1, 0); + for (var i = 0; i < columnWidths.length; i++) { + offsets[i + 1] = offsets[i] + columnWidths[i]; + } + return offsets; +} + +/// Visible column range for a horizontal viewport (with overscan). +@visibleForTesting +ResultGridColumnWindow computeVisibleColumnWindow({ + required List columnWidths, + required List columnOffsets, + required double scrollOffset, + required double viewportWidth, + int overscanColumns = ResultGridMetrics.columnOverscan, +}) { + final n = columnWidths.length; + if (n == 0) return ResultGridColumnWindow.empty; + assert(columnOffsets.length == n + 1); + + final total = columnOffsets[n]; + if (viewportWidth <= 0) { + return ResultGridColumnWindow( + first: 0, + last: n - 1, + leadingWidth: 0, + trailingWidth: 0, + ); + } + + final start = scrollOffset.clamp(0.0, total); + final end = (scrollOffset + viewportWidth).clamp(0.0, total); + + // First column with any pixel past [start]. + var first = 0; + while (first < n && columnOffsets[first + 1] <= start) { + first++; + } + // Last column with any pixel before [end]. + var last = n - 1; + while (last > 0 && columnOffsets[last] >= end) { + last--; + } + if (first > last) { + first = last.clamp(0, n - 1); + } + + first = (first - overscanColumns).clamp(0, n - 1); + last = (last + overscanColumns).clamp(0, n - 1); + + return ResultGridColumnWindow( + first: first, + last: last, + leadingWidth: columnOffsets[first], + trailingWidth: total - columnOffsets[last + 1], + ); +} + +/// Virtualized read-only grid for SQL query results (rows + columns). class VirtualResultGrid extends material.StatefulWidget { const VirtualResultGrid({ super.key, @@ -58,7 +170,15 @@ class _VirtualResultGridState extends material.State { final _verticalController = material.ScrollController(); List _columnWidths = const []; + List _columnOffsets = const [0]; bool _widthsNeedUpdate = true; + double _scrollOffset = 0; + + @override + void initState() { + super.initState(); + _horizontalController.addListener(_onHorizontalScroll); + } @override void didChangeDependencies() { @@ -76,11 +196,19 @@ class _VirtualResultGridState extends material.State { @override void dispose() { + _horizontalController.removeListener(_onHorizontalScroll); _horizontalController.dispose(); _verticalController.dispose(); super.dispose(); } + void _onHorizontalScroll() { + if (!_horizontalController.hasClients) return; + final offset = _horizontalController.offset; + if ((offset - _scrollOffset).abs() < 0.5) return; + setState(() => _scrollOffset = offset); + } + List _computeColumnWidths() { return computeResultGridColumnWidths( columns: widget.columns, @@ -92,7 +220,7 @@ class _VirtualResultGridState extends material.State { double get _tableWidth { if (_columnWidths.isEmpty) return 0; - return _columnWidths.reduce((a, b) => a + b); + return _columnOffsets[_columnWidths.length]; } double _scaledRowHeight(material.BuildContext context) => @@ -101,14 +229,29 @@ class _VirtualResultGridState extends material.State { double _scaledHeaderHeight(material.BuildContext context) => context.scaled(ResultGridMetrics.headerHeight); + ResultGridColumnWindow _columnWindow( + List displayWidths, + double viewportWidth, + ) { + final offsets = identical(displayWidths, _columnWidths) + ? _columnOffsets + : computeResultGridColumnOffsets(displayWidths); + return computeVisibleColumnWindow( + columnWidths: displayWidths, + columnOffsets: offsets, + scrollOffset: _scrollOffset, + viewportWidth: viewportWidth, + ); + } + @override material.Widget build(material.BuildContext context) { if (_widthsNeedUpdate) { _columnWidths = _computeColumnWidths(); + _columnOffsets = computeResultGridColumnOffsets(_columnWidths); _widthsNeedUpdate = false; } final cs = Theme.of(context).colorScheme; - final colCount = widget.columns.length; final rowHeight = _scaledRowHeight(context); final headerHeight = _scaledHeaderHeight(context); @@ -116,6 +259,7 @@ class _VirtualResultGridState extends material.State { child: material.LayoutBuilder( builder: (context, constraints) { final availableWidth = constraints.maxWidth; + var displayWidths = _columnWidths; var tableWidth = _tableWidth; if (tableWidth < availableWidth && _columnWidths.isNotEmpty) { @@ -129,6 +273,8 @@ class _VirtualResultGridState extends material.State { tableWidth = availableWidth; } + final window = _columnWindow(displayWidths, availableWidth); + return material.Scrollbar( controller: _horizontalController, thumbVisibility: true, @@ -144,6 +290,7 @@ class _VirtualResultGridState extends material.State { _HeaderRow( columns: widget.columns, columnWidths: displayWidths, + window: window, height: headerHeight, colorScheme: cs, ), @@ -162,7 +309,7 @@ class _VirtualResultGridState extends material.State { key: ValueKey('result-row-$rowIndex'), row: row, columnWidths: displayWidths, - columnCount: colCount, + window: window, height: rowHeight, colorScheme: cs, striped: !isEven, @@ -186,12 +333,14 @@ class _HeaderRow extends material.StatelessWidget { const _HeaderRow({ required this.columns, required this.columnWidths, + required this.window, required this.height, required this.colorScheme, }); final List columns; final List columnWidths; + final ResultGridColumnWindow window; final double height; final ColorScheme colorScheme; @@ -209,13 +358,17 @@ class _HeaderRow extends material.StatelessWidget { ), child: material.Row( children: [ - for (var i = 0; i < columns.length; i++) + if (window.leadingWidth > 0) + material.SizedBox(width: window.leadingWidth), + for (var i = window.first; i <= window.last; i++) _GridCell( text: columns[i], width: columnWidths[i], isHeader: true, colorScheme: colorScheme, ), + if (window.trailingWidth > 0) + material.SizedBox(width: window.trailingWidth), ], ), ); @@ -227,7 +380,7 @@ class _DataRow extends material.StatelessWidget { super.key, required this.row, required this.columnWidths, - required this.columnCount, + required this.window, required this.height, required this.colorScheme, required this.striped, @@ -235,7 +388,7 @@ class _DataRow extends material.StatelessWidget { final List row; final List columnWidths; - final int columnCount; + final ResultGridColumnWindow window; final double height; final ColorScheme colorScheme; final bool striped; @@ -257,12 +410,16 @@ class _DataRow extends material.StatelessWidget { ), child: material.Row( children: [ - for (var c = 0; c < columnCount; c++) + if (window.leadingWidth > 0) + material.SizedBox(width: window.leadingWidth), + for (var c = window.first; c <= window.last; c++) _GridCell( text: c < row.length ? row[c] : '', width: columnWidths[c], colorScheme: colorScheme, ), + if (window.trailingWidth > 0) + material.SizedBox(width: window.trailingWidth), ], ), ), diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index a3b6ce9e..e410c655 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -61,6 +61,53 @@ void main() { }); }); + group('computeVisibleColumnWindow', () { + test('returns empty for no columns', () { + expect( + computeVisibleColumnWindow( + columnWidths: const [], + columnOffsets: const [0], + scrollOffset: 0, + viewportWidth: 400, + ), + ResultGridColumnWindow.empty, + ); + }); + + test('keeps far columns out of a narrow viewport', () { + final widths = List.filled(80, 120); + final offsets = computeResultGridColumnOffsets(widths); + final window = computeVisibleColumnWindow( + columnWidths: widths, + columnOffsets: offsets, + scrollOffset: 0, + viewportWidth: 400, + overscanColumns: 1, + ); + // ~4 visible + 1 overscan on the right → last around 4. + expect(window.first, 0); + expect(window.last, lessThan(10)); + expect(window.columnCount, lessThan(12)); + expect(window.leadingWidth, 0); + expect(window.trailingWidth, greaterThan(0)); + }); + + test('shifts window when scrolled horizontally', () { + final widths = List.filled(50, 100); + final offsets = computeResultGridColumnOffsets(widths); + final window = computeVisibleColumnWindow( + columnWidths: widths, + columnOffsets: offsets, + scrollOffset: 2000, + viewportWidth: 300, + overscanColumns: 0, + ); + expect(window.first, greaterThan(15)); + expect(window.last, lessThan(30)); + expect(window.leadingWidth, greaterThan(0)); + }); + }); + group('ResultsTab', () { testWidgets('uses virtualized grid instead of Table', (tester) async { final rows = List.generate( @@ -117,6 +164,38 @@ void main() { expect(dataRowWidgets, lessThan(80)); }); + testWidgets('does not build off-screen columns in a wide grid', + (tester) async { + final columns = List.generate(80, (i) => 'col_$i'); + final rows = List.generate( + 40, + (r) => List.generate(80, (c) => 'r${r}_c$c'), + ); + + await tester.pumpWidget( + resultsShell( + child: material.SizedBox( + height: 400, + width: 360, + child: VirtualResultGrid( + columns: columns, + rows: rows, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('col_0'), findsOneWidget); + expect(find.text('col_79'), findsNothing); + expect(find.text('r0_c0'), findsOneWidget); + expect(find.text('r0_c79'), findsNothing); + + // Far fewer Text widgets than rows×cols (40×80=3200). + final texts = tester.widgetList(find.byType(material.Text)).length; + expect(texts, lessThan(400)); + }); + testWidgets( 'recalculates column widths when updated with different columns without throwing RangeError', (tester) async { From 4d77ecfd8f1a5bf1f485cff404225f0db9e657a7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 20:28:06 +0300 Subject: [PATCH 15/23] fix(ui): drop redundant foundation import in result grid --- lib/features/main_screen/result_grid_view.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index 332e13c6..2ce785d1 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/layout/ui_scale.dart'; From 07ac20e8b7f31b4c15d1d442caee1dc3df313108 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 21:22:15 +0300 Subject: [PATCH 16/23] fix(ui): honor barrierDismissible Escape on showAppDialog Pass the flag through to showGeneralDialog so ModalRoute DismissIntent closes on Escape when dismissible; keep frosted backdrop tap handling. Closes #446 --- lib/shared/widgets/app_dialog.dart | 6 ++++- test/shared/app_dialog_test.dart | 42 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index d47fc7d0..1a92ceaa 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -10,6 +10,10 @@ import 'package:querya_desktop/core/motion/querya_spring.dart'; /// /// Use instead of [showDialog] so every overlay has consistent blur. /// Enter: fade + slight slide; exit uses [QueryaMotion.exit] via reverseCurve. +/// +/// When [barrierDismissible] is true (default), Escape and backdrop tap dismiss. +/// Escape is handled by the modal route; backdrop tap by [_BlurredDialogScaffold] +/// (the route barrier stays transparent under the frosted layer). Future showAppDialog({ required BuildContext context, required WidgetBuilder builder, @@ -17,7 +21,7 @@ Future showAppDialog({ }) { return showGeneralDialog( context: context, - barrierDismissible: false, + barrierDismissible: barrierDismissible, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierColor: Colors.transparent, transitionDuration: context.motionDuration(QueryaMotion.standard), diff --git a/test/shared/app_dialog_test.dart b/test/shared/app_dialog_test.dart index 6f39ddee..6a6b8bb4 100644 --- a/test/shared/app_dialog_test.dart +++ b/test/shared/app_dialog_test.dart @@ -1,6 +1,7 @@ import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; @@ -70,6 +71,47 @@ void main() { await future; }); + testWidgets('barrierDismissible true closes dialog on Escape', (tester) async { + final ctx = await pumpHost(tester); + var completed = false; + + final future = showAppDialog( + context: ctx, + barrierDismissible: true, + builder: (c) => const AlertDialog(title: Text('Escapable')), + ).whenComplete(() => completed = true); + + await tester.pumpAndSettle(); + expect(find.text('Escapable'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Escapable'), findsNothing); + expect(completed, isTrue); + await future; + }); + + testWidgets('barrierDismissible false ignores Escape', (tester) async { + final ctx = await pumpHost(tester); + + final future = showAppDialog( + context: ctx, + barrierDismissible: false, + builder: (c) => const AlertDialog(title: Text('No escape')), + ); + + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('No escape'), findsOneWidget); + + Navigator.of(ctx, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await future; + }); + testWidgets('showAppDialog uses fade-slide (not scale) with BackdropFilter', (tester) async { final ctx = await pumpHost(tester); From 2cd9b6c80da4505fcc75d03d8038d72e4bf51cc5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 21:23:03 +0300 Subject: [PATCH 17/23] fix(ui): always pop DDL loading overlay Capture NavigatorState before await and pop loading even when the host widget unmounts mid-fetch (extension + SQLite table views). Closes #447 --- lib/features/extensions/extension_table_view.dart | 5 +++-- lib/features/sqlite/sqlite_table_view.dart | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index e62f572a..38ca00e7 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -177,6 +177,7 @@ class _ExtensionTableViewState extends material.State { } Future _openDdlDialog() async { + final navigator = material.Navigator.of(context, rootNavigator: true); unawaited(showAppDialog( context: context, barrierDismissible: false, @@ -191,8 +192,8 @@ class _ExtensionTableViewState extends material.State { nodeId: widget.tableName, nodeType: widget.isView ? 'view' : 'table', ); + if (navigator.canPop()) navigator.pop(); if (!mounted) return; - material.Navigator.of(context).pop(); final ddlText = meta.ddl?.trim().isNotEmpty == true ? meta.ddl! @@ -223,8 +224,8 @@ class _ExtensionTableViewState extends material.State { ), ); } catch (e) { + if (navigator.canPop()) navigator.pop(); if (!mounted) return; - material.Navigator.of(context).pop(); showAppToast( context: context, message: 'Failed to fetch DDL: $e', diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index fd3f4f7d..28d0e395 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -194,6 +194,7 @@ class _SqliteTableViewState extends material.State { Future _showDdlDialog() async { final conn = _connection; if (conn == null || !conn.isConnected) return; + final navigator = material.Navigator.of(context, rootNavigator: true); unawaited(showAppDialog( context: context, barrierDismissible: false, @@ -203,8 +204,8 @@ class _SqliteTableViewState extends material.State { )); try { final ddl = await conn.getObjectDdl(widget.tableName); + if (navigator.canPop()) navigator.pop(); if (!mounted) return; - material.Navigator.of(context).pop(); await showAppDialog( context: context, builder: (ctx) => material.AlertDialog( @@ -230,8 +231,8 @@ class _SqliteTableViewState extends material.State { ), ); } catch (e) { + if (navigator.canPop()) navigator.pop(); if (!mounted) return; - material.Navigator.of(context).pop(); showAppToast( context: context, message: 'Failed to fetch DDL: $e', From b5076a67ca7234a0c6a6d56fdc15e0b8e8bec1d5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 21:23:57 +0300 Subject: [PATCH 18/23] fix(ui): clear loading on stats/table early exits Match extension_stats: clear _loading when replaceIfChanged is a no-op. Set error + stop spinner when connection is missing after load started. Closes #448 --- lib/features/mysql/mysql_stats_view.dart | 14 +++++++++++-- lib/features/mysql/mysql_table_view.dart | 20 +++++++++++++++++-- .../postgresql/postgres_stats_view.dart | 18 +++++++++++++++-- .../postgresql/postgres_table_view.dart | 20 +++++++++++++++++-- lib/features/sqlite/sqlite_table_view.dart | 10 +++++++++- 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index a4040d49..d281bb0c 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -94,11 +94,21 @@ class _MysqlStatsViewState extends material.State { Future _fetch() async { final conn = _lease?.connection; - if (conn == null || !conn.isConnected) return; + if (conn == null || !conn.isConnected) { + if (!mounted) return; + setState(() { + _error = 'Not connected'; + _loading = false; + }); + return; + } try { final stats = await conn.serverStats(); if (!mounted) return; - if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) { + if (_loading) setState(() => _loading = false); + return; + } setState(() => _loading = false); } catch (e) { if (!mounted) return; diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index d365c2fc..ee065872 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -165,7 +165,15 @@ class _MysqlTableViewState extends material.State { Future _fetch({bool refreshCount = false}) async { final conn = _connection; - if (conn == null || !conn.isConnected) return; + if (conn == null || !conn.isConnected) { + if (mounted && _loading) { + setState(() { + _error = 'Not connected'; + _loading = false; + }); + } + return; + } if (_customSqlActive) { await _fetchCustom(); return; @@ -218,7 +226,15 @@ class _MysqlTableViewState extends material.State { Future _fetchCustom() async { final conn = _connection; - if (conn == null || !conn.isConnected) return; + if (conn == null || !conn.isConnected) { + if (mounted && _loading) { + setState(() { + _error = 'Not connected'; + _loading = false; + }); + } + return; + } final sql = _customSql; if (sql == null || sql.isEmpty) return; if (!mounted) return; diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 07c0fe03..48cd1988 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -104,11 +104,25 @@ class _PostgresStatsViewState extends material.State { Future _fetch() async { final c = _connection; - if (c == null || !c.isConnected) return; + if (c == null || !c.isConnected) { + if (!mounted) return; + setState(() { + _error = 'Not connected'; + _loading = false; + }); + return; + } try { final stats = await c.serverStats(); if (!mounted) return; - if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) { + if (_loading) { + setState(() { + _loading = false; + }); + } + return; + } setState(() { _loading = false; }); diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 05e6ea26..653f39e2 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -156,7 +156,15 @@ class _PostgresTableViewState extends material.State { /// [refreshCount] runs `COUNT(*)` (e.g. first load or Refresh). Pagination only runs SELECT. Future _fetch({bool refreshCount = false}) async { final conn = _connection; - if (conn == null || !conn.isConnected) return; + if (conn == null || !conn.isConnected) { + if (mounted && _loading) { + setState(() { + _error = 'Not connected'; + _loading = false; + }); + } + return; + } if (_customSqlActive) { await _fetchCustom(); return; @@ -219,7 +227,15 @@ class _PostgresTableViewState extends material.State { Future _fetchCustom() async { final conn = _connection; - if (conn == null || !conn.isConnected) return; + if (conn == null || !conn.isConnected) { + if (mounted && _loading) { + setState(() { + _error = 'Not connected'; + _loading = false; + }); + } + return; + } final sql = _customSql; if (sql == null || sql.isEmpty) return; if (!mounted) return; diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index fd3f4f7d..2e05929f 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -113,7 +113,15 @@ class _SqliteTableViewState extends material.State { Future _fetch({bool refreshCount = false}) async { final conn = _connection; - if (conn == null || !conn.isConnected) return; + if (conn == null || !conn.isConnected) { + if (mounted && _loading) { + setState(() { + _error = 'Not connected'; + _loading = false; + }); + } + return; + } setState(() { _loading = true; _error = null; From bfa426af9e9a07343533087d3a19fccea08c32e2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 21:27:01 +0300 Subject: [PATCH 19/23] fix(ui): tree errors, empty table state, scrollable toolbars Show PG/MySQL sidebar load errors with Retry; empty browse shows empty-state + Retry; MySQL/SQLite toolbars scroll horizontally when narrow. Closes #449 Closes #450 Closes #451 --- .../connections/connections_panel_mysql.dart | 37 ++++- .../connections_panel_pg_tree.dart | 74 +++++++++- lib/features/mysql/mysql_table_view.dart | 139 ++++++++++++------ .../postgresql/postgres_table_view.dart | 28 +++- lib/features/sqlite/sqlite_table_view.dart | 110 ++++++++++---- 5 files changed, 308 insertions(+), 80 deletions(-) diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index fb88a466..3d801c6f 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -369,6 +369,7 @@ class _MysqlDatabaseNode extends StatefulWidget { class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { bool _expanded = false; bool _loading = false; + String? _error; List _tables = []; List _views = []; List _procedures = []; @@ -388,7 +389,10 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { Future _loadTables() async { if (!mounted) return; - setState(() => _loading = true); + setState(() { + _loading = true; + _error = null; + }); MysqlLease? lease; try { final c = widget.connection; @@ -412,10 +416,14 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { _procedures = procs; _functions = funcs; _loading = false; + _error = null; }); } catch (e) { if (!mounted) return; - setState(() => _loading = false); + setState(() { + _error = e.toString(); + _loading = false; + }); } finally { lease?.release(); } @@ -480,6 +488,31 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { const Text('Loading...').muted().xSmall(), ], ), + ) + else if (_error != null) + material.Padding( + padding: const material.EdgeInsets.only( + left: 24, + top: 4, + bottom: 8, + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SelectableText( + _error!, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.destructive, + ), + ), + const material.SizedBox(height: 6), + GhostButton( + onPressed: _loadTables, + child: const Text('Retry'), + ), + ], + ), ), if (_tables.isNotEmpty || _views.isNotEmpty || diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index aee015ff..868cf1be 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -285,6 +285,7 @@ class _PgDatabaseNode extends StatefulWidget { class _PgDatabaseNodeState extends State<_PgDatabaseNode> { bool _expanded = false; bool _loading = false; + String? _error; List _schemas = []; void _toggle() { @@ -296,7 +297,10 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { Future _loadSchemas() async { if (!mounted) return; - setState(() => _loading = true); + setState(() { + _loading = true; + _error = null; + }); PgLease? lease; try { final c = widget.connection; @@ -310,10 +314,14 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { setState(() { _schemas = schemas; _loading = false; + _error = null; }); } catch (e) { if (!mounted) return; - setState(() => _loading = false); + setState(() { + _error = e.toString(); + _loading = false; + }); } finally { lease?.release(); } @@ -395,6 +403,31 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { const Text('Loading...').muted().xSmall(), ], ), + ) + else if (_error != null) + material.Padding( + padding: const material.EdgeInsets.only( + left: 24, + top: 4, + bottom: 8, + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SelectableText( + _error!, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.destructive, + ), + ), + const material.SizedBox(height: 6), + GhostButton( + onPressed: _loadSchemas, + child: const Text('Retry'), + ), + ], + ), ), if (_schemas.isNotEmpty) _PgSchemasNode( @@ -597,6 +630,7 @@ class _PgSchemaNode extends StatefulWidget { class _PgSchemaNodeState extends State<_PgSchemaNode> { bool _expanded = false; bool _loading = false; + String? _error; List _tables = []; List _views = []; List _matviews = []; @@ -613,7 +647,10 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { Future _loadObjects() async { if (!mounted) return; - setState(() => _loading = true); + setState(() { + _loading = true; + _error = null; + }); PgLease? lease; try { final c = widget.connection; @@ -642,10 +679,14 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { _sequences = sequences; _loading = false; _loaded = true; + _error = null; }); } catch (e) { if (!mounted) return; - setState(() => _loading = false); + setState(() { + _error = e.toString(); + _loading = false; + }); } finally { lease?.release(); } @@ -706,6 +747,31 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { const Text('Loading...').muted().xSmall(), ], ), + ) + else if (_error != null) + material.Padding( + padding: const material.EdgeInsets.only( + left: 24, + top: 4, + bottom: 8, + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SelectableText( + _error!, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.destructive, + ), + ), + const material.SizedBox(height: 6), + GhostButton( + onPressed: _loadObjects, + child: const Text('Retry'), + ), + ], + ), ), if (_loaded) ...[ _PgObjectGroup( diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index d365c2fc..5a1f2571 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -405,7 +405,33 @@ class _MysqlTableViewState extends material.State { } if (_columnNames.isEmpty) { - return material.Container(color: cs.background); + return material.Container( + color: cs.background, + child: material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('No columns returned').muted().small(), + const Gap(24), + OutlineButton( + onPressed: () { + if (_customSqlActive) { + unawaited(_fetchCustom()); + } else { + unawaited(_fetch(refreshCount: true)); + } + }, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); } const double rowHeight = 36; @@ -444,6 +470,7 @@ class _MysqlTableViewState extends material.State { child: material.Text( title, overflow: material.TextOverflow.ellipsis, + maxLines: 1, style: material.TextStyle( fontSize: 13, fontWeight: material.FontWeight.w600, @@ -451,49 +478,75 @@ class _MysqlTableViewState extends material.State { ), ), ), - material.Text( - _paginationLabel(), - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, - ), - ), - const Gap(8), - OutlineButton( - onPressed: _loading - ? null - : () { - if (_customSqlActive) { - unawaited(_fetchCustom()); - } else { - unawaited(_fetch(refreshCount: true)); - } - }, - child: const Text('Refresh'), - ), - const Gap(6), - OutlineButton( - onPressed: _openSqlEditor, - child: const Text('SQL'), - ), - if (_customSqlActive) ...[ - const Gap(6), - OutlineButton( - onPressed: _exitCustomMode, - child: const Text('Browse'), + material.Expanded( + flex: 2, + child: material.LayoutBuilder( + builder: (context, constraints) { + return material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + minWidth: constraints.maxWidth, + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + _paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + const Gap(8), + OutlineButton( + onPressed: _loading + ? null + : () { + if (_customSqlActive) { + unawaited(_fetchCustom()); + } else { + unawaited(_fetch(refreshCount: true)); + } + }, + child: const Text('Refresh'), + ), + const Gap(6), + OutlineButton( + onPressed: _openSqlEditor, + child: const Text('SQL'), + ), + if (_customSqlActive) ...[ + const Gap(6), + OutlineButton( + onPressed: _exitCustomMode, + child: const Text('Browse'), + ), + ], + const Gap(6), + GhostButton( + onPressed: (!_canGoPrevious || _loading) + ? null + : _goToPreviousPage, + child: const Icon( + material.Icons.chevron_left_rounded, + size: 20), + ), + GhostButton( + onPressed: (!_canGoNext || _loading) + ? null + : _goToNextPage, + child: const Icon( + material.Icons.chevron_right_rounded, + size: 20), + ), + ], + ), + ), + ); + }, ), - ], - const Gap(6), - GhostButton( - onPressed: - (!_canGoPrevious || _loading) ? null : _goToPreviousPage, - child: - const Icon(material.Icons.chevron_left_rounded, size: 20), - ), - GhostButton( - onPressed: (!_canGoNext || _loading) ? null : _goToNextPage, - child: const Icon(material.Icons.chevron_right_rounded, - size: 20), ), ], ), diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 05e6ea26..1733cf14 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -447,7 +447,33 @@ class _PostgresTableViewState extends material.State { } if (_columnNames.isEmpty) { - return material.Container(color: cs.background); + return material.Container( + color: cs.background, + child: material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('No columns returned').muted().small(), + const Gap(24), + OutlineButton( + onPressed: () { + if (_customSqlActive) { + _fetchCustom(); + } else { + _fetch(refreshCount: true); + } + }, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); } const double rowHeight = 36; diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index fd3f4f7d..483d1ab6 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -392,7 +392,27 @@ class _SqliteTableViewState extends material.State { } if (_columnNames.isEmpty) { - return material.Container(color: cs.background); + return material.Container( + color: cs.background, + child: material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('No columns returned').muted().small(), + const Gap(24), + OutlineButton( + onPressed: _connectAndLoad, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); } const double rowHeight = 36; @@ -430,6 +450,7 @@ class _SqliteTableViewState extends material.State { child: material.Text( title, overflow: material.TextOverflow.ellipsis, + maxLines: 1, style: material.TextStyle( fontSize: 13, fontWeight: material.FontWeight.w600, @@ -437,37 +458,66 @@ class _SqliteTableViewState extends material.State { ), ), ), - material.Text( - _paginationLabel(), - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, + material.Expanded( + flex: 2, + child: material.LayoutBuilder( + builder: (context, constraints) { + return material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + minWidth: constraints.maxWidth, + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + _paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _loading + ? null + : () => unawaited(_showDdlDialog()), + child: const Text('DDL'), + ), + const Gap(6), + OutlineButton( + onPressed: _loading + ? null + : () => unawaited(_fetch()), + child: const Text('Refresh'), + ), + const Gap(6), + GhostButton( + onPressed: (!_canGoPrevious || _loading) + ? null + : _goToPreviousPage, + child: const Icon( + material.Icons.chevron_left_rounded, + size: 20), + ), + GhostButton( + onPressed: (!_canGoNext || _loading) + ? null + : _goToNextPage, + child: const Icon( + material.Icons.chevron_right_rounded, + size: 20), + ), + ], + ), + ), + ); + }, ), ), - const Gap(8), - OutlineButton( - size: ButtonSize.small, - onPressed: - _loading ? null : () => unawaited(_showDdlDialog()), - child: const Text('DDL'), - ), - const Gap(6), - OutlineButton( - onPressed: _loading ? null : () => unawaited(_fetch()), - child: const Text('Refresh'), - ), - const Gap(6), - GhostButton( - onPressed: - (!_canGoPrevious || _loading) ? null : _goToPreviousPage, - child: - const Icon(material.Icons.chevron_left_rounded, size: 20), - ), - GhostButton( - onPressed: (!_canGoNext || _loading) ? null : _goToNextPage, - child: const Icon(material.Icons.chevron_right_rounded, - size: 20), - ), ], ), ), From e6ad70ea5ec0071ab0cd2230e2b76ccddba06db6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 21:30:07 +0300 Subject: [PATCH 20/23] fix(ui): SQL file toasts, chrome scale, Redis/Mongo empty states Surface open/save SQL errors; scale title bar; Redis TTL banner; blank stats Retry; key-editor mounted guards; ResultsTab error invariant. Closes #452 Closes #453 Closes #454 Closes #455 Closes #456 Closes #457 --- .../extensions/extension_sql_workspace.dart | 18 +++++++++++-- .../main_screen/querya_window_title_bar.dart | 3 ++- lib/features/main_screen/results_tab.dart | 4 +++ lib/features/mongodb/mongo_stats_view.dart | 26 ++++++++++++++++++- lib/features/mysql/mysql_sql_workspace.dart | 18 +++++++++++-- .../postgresql/postgres_sql_workspace.dart | 18 +++++++++++-- lib/features/redis/redis_key_editor.dart | 12 +++++++++ lib/features/redis/redis_keys_view.dart | 6 ++++- lib/features/redis/redis_view.dart | 26 ++++++++++++++++++- lib/features/sqlite/sqlite_sql_workspace.dart | 18 +++++++++++-- .../main_screen/results_tab_test.dart | 23 ++++++++++++++++ 11 files changed, 160 insertions(+), 12 deletions(-) diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index 25dd8c25..312db97d 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -185,7 +185,14 @@ class _ExtensionSqlWorkspaceState text: text, selection: material.TextSelection.collapsed(offset: text.length), ); - } catch (_) {} + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to open SQL file: $e', + variant: AppToastVariant.error, + ); + } } Future _saveSqlFile() async { @@ -201,7 +208,14 @@ class _ExtensionSqlWorkspaceState final path = location?.path; if (path == null || path.isEmpty) return; await File(path).writeAsString(_sqlController.text); - } catch (_) {} + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to save SQL file: $e', + variant: AppToastVariant.error, + ); + } } @override diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index ecaac67d..64f68860 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -1,5 +1,6 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; @@ -75,7 +76,7 @@ class QueryaWindowTitleBar extends StatelessWidget { final closeButtonColors = QueryaWindowTitleBar.closeButtonColors(context); return material.Container( - height: 40, + height: context.scaled(40), color: titleBarBackground(context), child: WindowTitleBarBox( child: Row( diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index cdc66f41..77b1c308 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -9,6 +9,10 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Query output: grid, loading, error, or placeholder. /// +/// Render order in [_buildBody]: **loading** first (only when [isLoading]), +/// then **error** when [errorMessage] is non-empty (including when +/// `isLoading` is false), then status / affected / idle / grid content. +/// /// Mode changes (idle / loading / error / status / grid) morph via /// [QueryaFadeSlide]. Keys are per **mode**, not per row — so grid data updates /// and scroll rebuilds do not re-trigger the transition. diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 50a92872..6c6dc3be 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -249,7 +249,31 @@ class _MongoStatsViewState extends material.State { } final status = _serverStatus; - if (status == null) return material.Container(color: cs.background); + if (status == null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('No stats available').large().semiBold(), + const Gap(8), + Text('serverStatus returned no data.').muted().small(), + const Gap(24), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } return material.Container( color: cs.background, diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 955ff704..5ece7c49 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -284,7 +284,14 @@ class _MysqlSqlWorkspaceState extends material.State { text: text, selection: material.TextSelection.collapsed(offset: text.length), ); - } catch (_) {} + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to open SQL file: $e', + variant: AppToastVariant.error, + ); + } } Future _saveSqlFile() async { @@ -299,7 +306,14 @@ class _MysqlSqlWorkspaceState extends material.State { final path = location?.path; if (path == null || path.isEmpty) return; await File(path).writeAsString(_sqlController.text); - } catch (_) {} + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to save SQL file: $e', + variant: AppToastVariant.error, + ); + } } Future _runTxCommand(String sql) async { diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 464ae4df..0cd85bed 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -431,7 +431,14 @@ class _PostgresSqlWorkspaceState extends material.State { text: text, selection: material.TextSelection.collapsed(offset: text.length), ); - } catch (_) {} + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to open SQL file: $e', + variant: AppToastVariant.error, + ); + } } Future _saveSqlFile() async { @@ -446,7 +453,14 @@ class _PostgresSqlWorkspaceState extends material.State { final path = location?.path; if (path == null || path.isEmpty) return; await File(path).writeAsString(_sqlController.text); - } catch (_) {} + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to save SQL file: $e', + variant: AppToastVariant.error, + ); + } } @override diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index b1c6e07d..5a46f646 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -114,9 +114,11 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.selectDatabase(widget.database); await widget.connection.set(widget.keyName, _stringController.text); + if (!mounted) return; setState(() => _success = 'Value saved'); _clearSuccessAfterDelay(); } catch (e) { + if (!mounted) return; setState(() => _error = 'Save failed: $e'); } } @@ -127,6 +129,7 @@ class _RedisKeyEditorState extends material.State { await widget.connection.del(widget.keyName); widget.onKeyDeleted?.call(); } catch (e) { + if (!mounted) return; setState(() => _error = 'Delete failed: $e'); } } @@ -140,11 +143,13 @@ class _RedisKeyEditorState extends material.State { await widget.connection.persist(widget.keyName); } _ttl = await widget.connection.ttl(widget.keyName); + if (!mounted) return; setState(() { _success = seconds > 0 ? 'TTL set to $seconds seconds' : 'TTL removed'; }); _clearSuccessAfterDelay(); } catch (e) { + if (!mounted) return; setState(() => _error = 'TTL failed: $e'); } } @@ -156,6 +161,7 @@ class _RedisKeyEditorState extends material.State { await widget.connection.hset(widget.keyName, field, value); await _load(); } catch (e) { + if (!mounted) return; setState(() => _error = 'HSET failed: $e'); } } @@ -166,6 +172,7 @@ class _RedisKeyEditorState extends material.State { await widget.connection.hdel(widget.keyName, field); await _load(); } catch (e) { + if (!mounted) return; setState(() => _error = 'HDEL failed: $e'); } } @@ -177,6 +184,7 @@ class _RedisKeyEditorState extends material.State { await widget.connection.rpush(widget.keyName, value); await _load(); } catch (e) { + if (!mounted) return; setState(() => _error = 'RPUSH failed: $e'); } } @@ -188,6 +196,7 @@ class _RedisKeyEditorState extends material.State { await widget.connection.sadd(widget.keyName, member); await _load(); } catch (e) { + if (!mounted) return; setState(() => _error = 'SADD failed: $e'); } } @@ -198,6 +207,7 @@ class _RedisKeyEditorState extends material.State { await widget.connection.srem(widget.keyName, member); await _load(); } catch (e) { + if (!mounted) return; setState(() => _error = 'SREM failed: $e'); } } @@ -209,6 +219,7 @@ class _RedisKeyEditorState extends material.State { await widget.connection.zadd(widget.keyName, score, member); await _load(); } catch (e) { + if (!mounted) return; setState(() => _error = 'ZADD failed: $e'); } } @@ -219,6 +230,7 @@ class _RedisKeyEditorState extends material.State { await widget.connection.zrem(widget.keyName, member); await _load(); } catch (e) { + if (!mounted) return; setState(() => _error = 'ZREM failed: $e'); } } diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index acba5dbc..6b396aa8 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -80,6 +80,7 @@ class _RedisKeysViewState extends material.State { // One pipelined burst of TYPE+TTL (not N× Future.wait round-trips). List<_KeyInfo> infos; + String? typeTtlError; try { final metas = await widget.connection.typesAndTtls(keyNames); infos = [ @@ -90,11 +91,13 @@ class _RedisKeysViewState extends material.State { ttl: metas[i].ttl, ), ]; - } catch (_) { + } catch (e) { + // Still show keys with unknown type/TTL; surface the failure non-blocking. infos = [ for (final name in keyNames) _KeyInfo(name: name, type: 'unknown', ttl: -1), ]; + typeTtlError = 'Failed to load key types/TTLs: $e'; } if (!mounted) return; @@ -102,6 +105,7 @@ class _RedisKeysViewState extends material.State { _keys.addAll(infos); _cursor = nextCursor; _hasMore = nextCursor != 0; + if (typeTtlError != null) _error = typeTtlError; }); } diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 08a9fa75..b9cfa3ea 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -226,7 +226,31 @@ class _RedisViewState extends material.State { } final info = _info; - if (info == null) return material.Container(color: cs.background); + if (info == null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('No stats available').large().semiBold(), + const Gap(8), + Text('Redis INFO returned no data.').muted().small(), + const Gap(24), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } return material.Container( color: cs.background, diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 8a98276f..5a478613 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -240,7 +240,14 @@ class _SqliteSqlWorkspaceState extends material.State { text: text, selection: material.TextSelection.collapsed(offset: text.length), ); - } catch (_) {} + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to open SQL file: $e', + variant: AppToastVariant.error, + ); + } } Future _saveSqlFile() async { @@ -255,7 +262,14 @@ class _SqliteSqlWorkspaceState extends material.State { final path = location?.path; if (path == null || path.isEmpty) return; await File(path).writeAsString(_sqlController.text); - } catch (_) {} + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to save SQL file: $e', + variant: AppToastVariant.error, + ); + } } @override diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index e410c655..e3bf96c1 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -237,6 +237,29 @@ void main() { expect(find.text('created_at'), findsOneWidget); }); + testWidgets('shows error when isLoading is false', (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab( + isLoading: false, + errorMessage: 'connection refused', + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const material.ValueKey('results_mode_error')), + findsOneWidget, + ); + expect(find.textContaining('connection refused'), findsOneWidget); + expect( + find.byKey(const material.ValueKey('results_mode_loading')), + findsNothing, + ); + }); + testWidgets('shows idle / loading / error / grid mode keys', (tester) async { await tester.pumpWidget( resultsShell( From d1119eb70750ad901358a301d67257e2fcade082 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 21:33:16 +0300 Subject: [PATCH 21/23] fix(ui): prefer const Text in Redis/Mongo empty states --- lib/features/mongodb/mongo_stats_view.dart | 2 +- lib/features/redis/redis_view.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 6c6dc3be..cce26a9c 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -261,7 +261,7 @@ class _MongoStatsViewState extends material.State { const Gap(16), const Text('No stats available').large().semiBold(), const Gap(8), - Text('serverStatus returned no data.').muted().small(), + const Text('serverStatus returned no data.').muted().small(), const Gap(24), OutlineButton( onPressed: _load, diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index b9cfa3ea..4ffa5d0a 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -238,7 +238,7 @@ class _RedisViewState extends material.State { const Gap(16), const Text('No stats available').large().semiBold(), const Gap(8), - Text('Redis INFO returned no data.').muted().small(), + const Text('Redis INFO returned no data.').muted().small(), const Gap(24), OutlineButton( onPressed: _load, From 44f6d3de98febf9d9c1df25cd5c23076e3d00a07 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 21:53:09 +0300 Subject: [PATCH 22/23] fix: address code-review follow-ups from epic #463 Skip LIMIT rewriting inside SQL string/dollar quotes, delete partial exports on failure, clear stale PG tree on error, queue theme watcher refreshes while in flight, and clear Redis TYPE/TTL error on success. Closes #464 Closes #465 Closes #466 Closes #467 Closes #468 --- lib/core/database/sql_limit.dart | 106 ++++++++++++++++-- lib/core/theme/theme_folder_watcher.dart | 16 ++- .../connections_panel_pg_tree.dart | 8 +- lib/features/redis/redis_keys_view.dart | 2 +- lib/shared/services/data_export_service.dart | 6 + test/core/database/sql_limit_test.dart | 28 +++++ .../core/theme/theme_folder_watcher_test.dart | 36 ++++++ 7 files changed, 189 insertions(+), 13 deletions(-) diff --git a/lib/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart index 40211e75..cbb16c3f 100644 --- a/lib/core/database/sql_limit.dart +++ b/lib/core/database/sql_limit.dart @@ -25,6 +25,84 @@ final _fetchFirst = RegExp( caseSensitive: false, ); +/// Masks SQL string / identifier / dollar-quoted literals with spaces so +/// keyword regexes do not match inside quotes (same length, same offsets). +String maskSqlLiteralsForLimitScan(String sql) { + final out = StringBuffer(); + var i = 0; + while (i < sql.length) { + final c = sql.codeUnitAt(i); + + // Single-quoted string; '' is an escaped quote. + if (c == 0x27 /* ' */) { + out.write(' '); + i++; + while (i < sql.length) { + out.write(' '); + if (sql.codeUnitAt(i) == 0x27) { + if (i + 1 < sql.length && sql.codeUnitAt(i + 1) == 0x27) { + out.write(' '); + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + + // Double-quoted identifier. + if (c == 0x22 /* " */) { + out.write(' '); + i++; + while (i < sql.length) { + out.write(' '); + if (sql.codeUnitAt(i) == 0x22) { + if (i + 1 < sql.length && sql.codeUnitAt(i + 1) == 0x22) { + out.write(' '); + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + + // Dollar-quoted string: $tag$ ... $tag$ + if (c == 0x24 /* $ */) { + final tagEnd = sql.indexOf('\$', i + 1); + if (tagEnd != -1) { + final tag = sql.substring(i, tagEnd + 1); + final close = sql.indexOf(tag, tagEnd + 1); + if (close != -1) { + final end = close + tag.length; + out.write(' ' * (end - i)); + i = end; + continue; + } + } + } + + out.write(sql[i]); + i++; + } + return out.toString(); +} + +Match? _firstMatchOutsideLiterals(RegExp pattern, String sql) { + final masked = maskSqlLiteralsForLimitScan(sql); + return pattern.firstMatch(masked); +} + +bool _hasMatchOutsideLiterals(RegExp pattern, String sql) { + return _firstMatchOutsideLiterals(pattern, sql) != null; +} + /// Injects or clamps a `LIMIT` on read-only queries (`SELECT`, `WITH`, `VALUES`). /// /// - No `LIMIT` / `FETCH … ONLY` → appends `LIMIT [limit]`. @@ -33,6 +111,7 @@ final _fetchFirst = RegExp( /// - `FETCH FIRST/NEXT n ROWS ONLY` where `n > limit` → clamped. /// - Non-select statements are returned unchanged. /// +/// Matches ignore `LIMIT` / `FETCH` text inside string or quoted identifiers. /// Trailing semicolons are preserved after an injected clause. String injectSqlLimit(String sql, int limit) { if (limit <= 0) return sql; @@ -48,37 +127,44 @@ String injectSqlLimit(String sql, int limit) { return sql; } - if (_limitAll.hasMatch(sql)) { - return sql.replaceFirst(_limitAll, 'LIMIT $limit'); + final limitAllMatch = _firstMatchOutsideLiterals(_limitAll, sql); + if (limitAllMatch != null) { + return sql.replaceRange( + limitAllMatch.start, + limitAllMatch.end, + 'LIMIT $limit', + ); } - final limitMatch = _limitCount.firstMatch(sql); + final limitMatch = _firstMatchOutsideLiterals(_limitCount, sql); if (limitMatch != null) { final existing = int.tryParse(limitMatch.group(1)!); if (existing == null || existing <= limit) { return sql; } final offsetPart = limitMatch.group(2) ?? ''; - return sql.replaceFirst( - limitMatch.group(0)!, + return sql.replaceRange( + limitMatch.start, + limitMatch.end, 'LIMIT $limit$offsetPart', ); } - final fetchMatch = _fetchFirst.firstMatch(sql); + final fetchMatch = _firstMatchOutsideLiterals(_fetchFirst, sql); if (fetchMatch != null) { final existing = int.tryParse(fetchMatch.group(1)!); if (existing == null || existing <= limit) { return sql; } - return sql.replaceFirst( - fetchMatch.group(0)!, + return sql.replaceRange( + fetchMatch.start, + fetchMatch.end, 'FETCH FIRST $limit ROWS ONLY', ); } - if (RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql) || - RegExp(r'\bFETCH\b', caseSensitive: false).hasMatch(sql)) { + if (_hasMatchOutsideLiterals(RegExp(r'\bLIMIT\b', caseSensitive: false), sql) || + _hasMatchOutsideLiterals(RegExp(r'\bFETCH\b', caseSensitive: false), sql)) { // Unrecognized LIMIT/FETCH shape — leave unchanged. return sql; } diff --git a/lib/core/theme/theme_folder_watcher.dart b/lib/core/theme/theme_folder_watcher.dart index 123bb158..3905ecca 100644 --- a/lib/core/theme/theme_folder_watcher.dart +++ b/lib/core/theme/theme_folder_watcher.dart @@ -24,6 +24,8 @@ class ThemeFolderWatcher { bool _started = false; bool _refreshInFlight = false; bool _pendingStructural = false; + bool _queuedRefresh = false; + bool _queuedStructural = false; bool get isStarted => _started; @@ -75,6 +77,8 @@ class ThemeFolderWatcher { _started = false; _refreshInFlight = false; _pendingStructural = false; + _queuedRefresh = false; + _queuedStructural = false; await Future.delayed(const Duration(milliseconds: 150)); } @@ -96,7 +100,11 @@ class ThemeFolderWatcher { } Future _triggerRefresh({required bool structuralChange}) async { - if (_refreshInFlight) return; + if (_refreshInFlight) { + _queuedRefresh = true; + _queuedStructural = _queuedStructural || structuralChange; + return; + } _refreshInFlight = true; try { await _onThemesChanged(structuralChange: structuralChange); @@ -104,6 +112,12 @@ class ThemeFolderWatcher { debugPrint('ThemeFolderWatcher: refresh failed ($error)'); } finally { _refreshInFlight = false; + if (_queuedRefresh) { + final structural = _queuedStructural; + _queuedRefresh = false; + _queuedStructural = false; + unawaited(_triggerRefresh(structuralChange: structural)); + } } } diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 868cf1be..51ead1e5 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -686,6 +686,12 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { setState(() { _error = e.toString(); _loading = false; + _loaded = false; + _tables = []; + _views = []; + _matviews = []; + _functions = []; + _sequences = []; }); } finally { lease?.release(); @@ -773,7 +779,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { ], ), ), - if (_loaded) ...[ + if (_loaded && _error == null) ...[ _PgObjectGroup( connection: widget.connection, databaseName: widget.databaseName, diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 6b396aa8..071827d3 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -105,7 +105,7 @@ class _RedisKeysViewState extends material.State { _keys.addAll(infos); _cursor = nextCursor; _hasMore = nextCursor != 0; - if (typeTtlError != null) _error = typeTtlError; + _error = typeTtlError; }); } diff --git a/lib/shared/services/data_export_service.dart b/lib/shared/services/data_export_service.dart index bef5df0f..ac282c80 100644 --- a/lib/shared/services/data_export_service.dart +++ b/lib/shared/services/data_export_service.dart @@ -357,6 +357,12 @@ class DataExportService { try { await sink?.close(); } catch (_) {} + try { + final partial = File(path); + if (await partial.exists()) { + await partial.delete(); + } + } catch (_) {} return SaveExportOutcome.error; } } diff --git a/test/core/database/sql_limit_test.dart b/test/core/database/sql_limit_test.dart index 24d586a0..b630c056 100644 --- a/test/core/database/sql_limit_test.dart +++ b/test/core/database/sql_limit_test.dart @@ -111,5 +111,33 @@ void main() { '-- comment\nSELECT * FROM t\nLIMIT 100', ); }); + + test('does not clamp LIMIT text inside string literals', () { + expect( + injectSqlLimit( + "SELECT * FROM t WHERE note = 'Use LIMIT 999999 rows'", + 5000, + ), + "SELECT * FROM t WHERE note = 'Use LIMIT 999999 rows'\nLIMIT 5000", + ); + expect( + injectSqlLimit( + "SELECT * FROM t WHERE note = 'LIMIT 999999' LIMIT 999999", + 5000, + ), + "SELECT * FROM t WHERE note = 'LIMIT 999999' LIMIT 5000", + ); + }); + + test('does not treat LIMIT inside dollar quotes as a clause', () { + expect( + injectSqlLimit( + r"SELECT $$LIMIT 999999$$ AS x FROM t", + 100, + ), + r"SELECT $$LIMIT 999999$$ AS x FROM t" + '\nLIMIT 100', + ); + }); }); } diff --git a/test/core/theme/theme_folder_watcher_test.dart b/test/core/theme/theme_folder_watcher_test.dart index 76084642..a9980e8a 100644 --- a/test/core/theme/theme_folder_watcher_test.dart +++ b/test/core/theme/theme_folder_watcher_test.dart @@ -147,6 +147,42 @@ void main() { expect(refreshCount, 0); await watcher.stop(); }); + + test('queues refresh while one is in flight and preserves structural', + () async { + final started = Completer(); + final release = Completer(); + final calls = []; + + final watcher = ThemeFolderWatcher( + themesDirectory: () async => themesDir, + onThemesChanged: ({required bool structuralChange}) async { + calls.add(structuralChange); + if (!started.isCompleted) started.complete(); + await release.future; + }, + debounce: const Duration(milliseconds: 40), + ); + + await watcher.start(); + if (!watcher.isStarted) return; + + await File(p.join(themesDir.path, 'a.json')).writeAsString('{}'); + await started.future.timeout(const Duration(seconds: 2)); + final callsDuringFlight = calls.length; + + // While first refresh is blocked, enqueue a structural event. + await Directory(p.join(themesDir.path, 'new_ext')).create(); + await Future.delayed(const Duration(milliseconds: 80)); + + release.complete(); + await Future.delayed(const Duration(milliseconds: 200)); + + expect(calls.length, greaterThan(callsDuringFlight)); + expect(calls.skip(callsDuringFlight), contains(true)); + + await watcher.stop(); + }); }); group('ThemeController folder watcher', () { From f5af240a8dbbc98861a0a54eada77cb74ede3711 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 22:05:07 +0300 Subject: [PATCH 23/23] chore(release): prepare pre-release 0.4.11-b MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship mistagged 0.4.11-a content (security #395–#402, packaging #386) plus perf #414, UI #445, and code-review #463. Bump pubspec and CHANGELOG. --- CHANGELOG.md | 24 ++++++++++++++++++++++-- docs/roadmap.md | 5 +++-- packaging/linux/aur/PKGBUILD | 2 +- pubspec.yaml | 2 +- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b358b175..4baaaeb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.4.11-a] - 2026-07-27 +## [0.4.11-b] - 2026-07-27 + +Post-0.4.11 patch that **ships** security + Linux distro packaging (intended for 0.4.11-a), plus performance bounds (#414), UI reliability (#445), and code-review correctness (#463). -Post-0.4.11 patch: security review hardening (#395–#402) and remaining Linux distro packages (#386). +> Note: GitHub tag `0.4.11-a` was mistakenly placed on the same commit as `0.4.11`, so those binaries did not include the 0.4.11-a changelog. Treat **0.4.11-b** as the first patch after **0.4.11**. ### Added @@ -26,6 +28,24 @@ Post-0.4.11 patch: security review hardening (#395–#402) and remaining Linux d - **Sandbox OS consent (#395)** — fail-closed unsandboxed driver launch without OS wrapper (bubblewrap / consent dialog). - **Sideload integrity UX (#402)** — local `.zip`/`.qext` install dialog with security notice and optional SHA256. +### Performance + +- **SQL result caps (#415 / #416)** — SQLite injects `LIMIT` before materializing rows; Postgres clamps oversized `LIMIT` / `FETCH`. +- **Streaming I/O (#417 / #418)** — export writes stream to disk; marketplace SHA256 + zip extract avoid dual full-buffer copies. +- **RPC / SDUI bounds (#419 / #420)** — NDJSON line size caps; virtualized `SduiTreeBuilder`. +- **Hot-path yielding (#421 / #422)** — result cell string conversion and MySQL table browse yield to the UI isolate. +- **Editor / Redis / sandbox / storage (#424–#428)** — syntax-highlight threshold, Redis TYPE/TTL pipeline, bounded stderr, SQL history index/prune, incremental theme mtime scan. +- **VirtualResultGrid (#423)** — 2D column virtualization for wide result sets. + +### Fixed + +- **UI reliability (#445 / #446–#457)** — Escape on `showAppDialog`; DDL overlay always pops; stats/table loading clears on early exit; tree error + Retry; empty table state; scrollable toolbars; SQL open/save toasts; title-bar scale; Redis/Mongo empty banners; mounted guards; ResultsTab invariants. +- **Correctness follow-ups (#463 / #464–#468)** — `injectSqlLimit` skips string/dollar quotes; delete partial export files on failure; PG tree clears stale children on error; theme watcher queues in-flight refresh; Redis keys error banner clears on success. + +## [0.4.11-a] - 2026-07-27 + +Prepared changelog for security (#395–#402) + Linux rpm/Flatpak/AUR (#386). **Tag/binaries were mistargeted** (same commit as `0.4.11`); content ships in **0.4.11-b**. + ## [0.4.11] - 2026-07-27 Universal UI standard for drivers/extensions, shell UX hardening, Fluid QueryaMotion morphing, virtual grid/pool reliability, performance follow-ups, and dual-channel packaging (portable + installable). diff --git a/docs/roadmap.md b/docs/roadmap.md index e1d4c610..f4e6ff3f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3,7 +3,7 @@ Living document for planned work. Not a commitment order; adjust as priorities change. **GitHub Latest Release:** [0.4.11](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.11) (2026-07-27). -**Next patch:** **0.4.11-a** — security review (#395–#402) + Linux rpm/Flatpak/AUR (#386); tag pending. +**Next patch:** **0.4.11-b** — security + packaging (from mistagged 0.4.11-a) + perf (#414) + UI (#445) + code-review (#463); tag after merge to `main`. **Next product release:** **0.5.0** — live Marketplace download and install — see below. ## Theme system @@ -26,7 +26,8 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.9:** PostgreSQL SSL & connection reliability — see [CHANGELOG.md](../CHANGELOG.md). - **Shipped in 0.4.10:** Sandboxed extension runtime (Block E), Plugin RPC bridge (Block C), SDUI form/tree builders, local `.zip`/`.qext` install, Registration/Activation for external database drivers (e.g. ClickHouse), in-app updater — see [CHANGELOG.md](../CHANGELOG.md). - **Shipped in 0.4.11:** Universal UI / SDUI RPC expand, ExtensionTableView, universal export, MySQL/SQLite parity, shell UX (#339), Fluid QueryaMotion (#342) + perf follow-ups (#356), grid/pool/timeout fixes, dual-channel packaging (portable zip + AppImage / `.deb` / Windows setup) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11]`, [packaging.md](packaging.md), epic [#379](https://github.com/QueryaHub/Querya-Desktop/issues/379). -- **Pending 0.4.11-a:** security hardening (#395–#402), Linux `.rpm` / Flatpak / AUR (#386) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11-a]`. +- **0.4.11-a:** changelog prepared; GitHub tag mistargeted onto `0.4.11` — do not treat as a shipped patch. +- **Pending 0.4.11-b:** ships security (#395–#402), Linux `.rpm` / Flatpak / AUR (#386), perf (#414), UI reliability (#445), code-review fixes (#463) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11-b]`. - **Planned 0.5.0:** Marketplace Launch — live download, `sha256` validation, install themes (and later DB drivers) from the network. ## Query history and favorites diff --git a/packaging/linux/aur/PKGBUILD b/packaging/linux/aur/PKGBUILD index f789bcdd..01f085e7 100644 --- a/packaging/linux/aur/PKGBUILD +++ b/packaging/linux/aur/PKGBUILD @@ -2,7 +2,7 @@ # AUR package — installs the official Release portable Linux zip under /opt. # Bump pkgver/pkgrel when a new GitHub Release is published. pkgname=querya-desktop -pkgver=0.4.11-a +pkgver=0.4.11-b pkgrel=1 pkgdesc="Multi-database desktop client (PostgreSQL, MySQL, Redis, MongoDB, SQLite)" arch=('x86_64') diff --git a/pubspec.yaml b/pubspec.yaml index 20394a8d..f76a6741 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.11-a +version: 0.4.11-b