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/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/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/docs/user-guide.md b/docs/user-guide.md index 12997878..4b0dfdd1 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -44,4 +44,13 @@ 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` 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/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/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/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/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart new file mode 100644 index 00000000..cbb16c3f --- /dev/null +++ b/lib/core/database/sql_limit.dart @@ -0,0 +1,186 @@ +// 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; + } +} + +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, +); + +/// 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]`. +/// - `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. +/// +/// 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; + + final cleanSql = stripLeadingWhitespaceAndLineComments(sql); + final upper = cleanSql.toUpperCase(); + + final isSelect = upper.startsWith('SELECT') || + upper.startsWith('WITH') || + upper.startsWith('VALUES'); + + if (!isSelect) { + return sql; + } + + final limitAllMatch = _firstMatchOutsideLiterals(_limitAll, sql); + if (limitAllMatch != null) { + return sql.replaceRange( + limitAllMatch.start, + limitAllMatch.end, + 'LIMIT $limit', + ); + } + + 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.replaceRange( + limitMatch.start, + limitMatch.end, + 'LIMIT $limit$offsetPart', + ); + } + + final fetchMatch = _firstMatchOutsideLiterals(_fetchFirst, sql); + if (fetchMatch != null) { + final existing = int.tryParse(fetchMatch.group(1)!); + if (existing == null || existing <= limit) { + return sql; + } + return sql.replaceRange( + fetchMatch.start, + fetchMatch.end, + 'FETCH FIRST $limit ROWS ONLY', + ); + } + + 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; + } + + 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/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/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/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/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/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/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/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), - ], + ), ); } 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/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/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..3905ecca 100644 --- a/lib/core/theme/theme_folder_watcher.dart +++ b/lib/core/theme/theme_folder_watcher.dart @@ -8,19 +8,24 @@ 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 _queuedRefresh = false; + bool _queuedStructural = false; bool get isStarted => _started; @@ -71,27 +76,48 @@ class ThemeFolderWatcher { _subscription = null; _started = false; _refreshInFlight = false; + _pendingStructural = false; + _queuedRefresh = false; + _queuedStructural = 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 { - if (_refreshInFlight) return; + Future _triggerRefresh({required bool structuralChange}) async { + if (_refreshInFlight) { + _queuedRefresh = true; + _queuedStructural = _queuedStructural || structuralChange; + return; + } _refreshInFlight = true; try { - await _onThemesChanged(); + await _onThemesChanged(structuralChange: structuralChange); } on Object catch (error) { 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/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/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..51ead1e5 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,20 @@ 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; + _loaded = false; + _tables = []; + _views = []; + _matviews = []; + _functions = []; + _sequences = []; + }); } finally { lease?.release(); } @@ -706,8 +753,33 @@ 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) ...[ + if (_loaded && _error == null) ...[ _PgObjectGroup( connection: widget.connection, databaseName: widget.databaseName, diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index a1dce00b..312db97d 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; }); @@ -176,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 { @@ -192,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/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/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/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index 6e3f923d..2ce785d1 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -11,6 +11,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 +88,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 +169,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 +195,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 +219,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 +228,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 +258,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 +272,8 @@ class _VirtualResultGridState extends material.State { tableWidth = availableWidth; } + final window = _columnWindow(displayWidths, availableWidth); + return material.Scrollbar( controller: _horizontalController, thumbVisibility: true, @@ -144,6 +289,7 @@ class _VirtualResultGridState extends material.State { _HeaderRow( columns: widget.columns, columnWidths: displayWidths, + window: window, height: headerHeight, colorScheme: cs, ), @@ -162,7 +308,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 +332,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 +357,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 +379,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 +387,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 +409,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/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..cce26a9c 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), + const 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 0a79a82b..5ece7c49 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); @@ -283,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 { @@ -298,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/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 a234d3f3..9f188247 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,22 +145,35 @@ 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; } 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; @@ -186,7 +200,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; @@ -212,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; @@ -223,9 +245,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; @@ -398,7 +421,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; @@ -437,6 +486,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, @@ -444,49 +494,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_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 5e266483..0cd85bed 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; @@ -434,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 { @@ -449,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/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 b76e8658..636aa0ce 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'; @@ -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; @@ -187,11 +195,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(() { @@ -218,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; @@ -235,11 +252,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(() { @@ -445,7 +463,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/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 1d6cc7ba..071827d3 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -78,23 +78,34 @@ 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; + String? typeTtlError; + 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 (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; setState(() { _keys.addAll(infos); _cursor = nextCursor; _hasMore = nextCursor != 0; + _error = typeTtlError; }); } diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 08a9fa75..4ffa5d0a 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), + const 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 718076b7..5a478613 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -1,13 +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/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'; @@ -156,7 +156,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,18 +169,16 @@ 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(); }).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; @@ -184,10 +186,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; }); @@ -238,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 { @@ -253,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/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index fd3f4f7d..4484d657 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; @@ -194,6 +202,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 +212,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 +239,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', @@ -392,7 +401,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 +459,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 +467,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), - ), ], ), ), diff --git a/lib/shared/services/data_export_service.dart b/lib/shared/services/data_export_service.dart index 7a60bb66..ac282c80 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,29 @@ 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 (_) {} + try { + final partial = File(path); + if (await partial.exists()) { + await partial.delete(); + } + } catch (_) {} return SaveExportOutcome.error; } } 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/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 140ffb21..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.12+ +version: 0.4.11-b 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/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); + }); +} 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); + }); + }); +} diff --git a/test/core/database/sql_limit_test.dart b/test/core/database/sql_limit_test.dart new file mode 100644 index 00000000..b630c056 --- /dev/null +++ b/test/core/database/sql_limit_test.dart @@ -0,0 +1,143 @@ +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 and 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 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), + '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', + ); + }); + + 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/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); + }); } 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(); + }); + }); +} 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(); + }); }); } 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()), + ); + }); }); } 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', 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..a9980e8a 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), @@ -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', () { 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', diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index a3b6ce9e..e3bf96c1 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 { @@ -158,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( 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); 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)); + }); }); }