From 0064bb05fd302b8363330666bfb1bef821e0b30b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 05:21:59 +0300 Subject: [PATCH 01/47] fix(dml): preserve leading zeros and boolean strings in string columns (#594) --- lib/core/database/table_mutation_engine.dart | 114 +++++++++++++++--- .../main_screen/data_grid_staging_buffer.dart | 2 + .../database/table_mutation_engine_test.dart | 56 +++++++++ 3 files changed, 156 insertions(+), 16 deletions(-) diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index b5eecbe..ca1099a 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -92,22 +92,93 @@ abstract final class TableMutationEngine { return quotedTable; } + static bool _isTextType(String dataTypeName) { + final lower = dataTypeName.toLowerCase().trim(); + return lower.contains('char') || + lower.contains('text') || + lower.contains('varchar') || + lower.contains('string') || + lower.contains('uuid') || + lower.contains('json') || + lower.contains('xml') || + lower.contains('clob') || + lower.contains('enum') || + lower.contains('citext') || + lower.contains('name') || + lower.contains('bpchar'); + } + + static bool _isBoolType(String dataTypeName) { + final lower = dataTypeName.toLowerCase().trim(); + return lower == 'bool' || + lower == 'boolean' || + lower.startsWith('tinyint(1)'); + } + + static bool _isNumericType(String dataTypeName) { + final lower = dataTypeName.toLowerCase().trim(); + return lower.contains('int') || + lower.contains('float') || + lower.contains('double') || + lower.contains('decimal') || + lower.contains('numeric') || + lower.contains('real') || + lower.contains('serial') || + lower.contains('number'); + } + /// Formats a cell string value safely as an SQL literal or `NULL`. - static String formatLiteral(String value, SqlDialect dialect) { + /// If [dataTypeName] is provided, formats according to the column type semantics. + static String formatLiteral( + String value, + SqlDialect dialect, { + String? dataTypeName, + }) { if (value == 'NULL' || value == 'null') { return 'NULL'; } - // Number literals (integer or floating point) - if (RegExp(r'^-?\d+(\.\d+)?$').hasMatch(value)) { - return value; + final trimmed = value.trim(); + + if (dataTypeName != null && dataTypeName.isNotEmpty) { + if (_isTextType(dataTypeName)) { + final escaped = value.replaceAll("'", "''"); + return "'$escaped'"; + } + + if (_isBoolType(dataTypeName)) { + if (trimmed.toLowerCase() == 'true' || trimmed == '1') { + return dialect == SqlDialect.sqlite ? '1' : 'TRUE'; + } + if (trimmed.toLowerCase() == 'false' || trimmed == '0') { + return dialect == SqlDialect.sqlite ? '0' : 'FALSE'; + } + } + + if (_isNumericType(dataTypeName)) { + if (RegExp(r'^-?\d+(\.\d+)?$').hasMatch(trimmed)) { + return trimmed; + } + } + } + + // Fallback heuristic: + // Leading zeros with more digits (e.g. '01234', '007') are preserved as strings + if (RegExp(r'^0\d+$').hasMatch(trimmed)) { + final escaped = value.replaceAll("'", "''"); + return "'$escaped'"; + } + + // Number literals (integer or floating point, e.g. '123', '0', '0.45', '-5.2') + if (RegExp(r'^-?\d+(\.\d+)?$').hasMatch(trimmed)) { + return trimmed; } // Boolean literals - if (value.toLowerCase() == 'true') { + if (trimmed.toLowerCase() == 'true') { return dialect == SqlDialect.sqlite ? '1' : 'TRUE'; } - if (value.toLowerCase() == 'false') { + if (trimmed.toLowerCase() == 'false') { return dialect == SqlDialect.sqlite ? '0' : 'FALSE'; } @@ -127,6 +198,7 @@ abstract final class TableMutationEngine { required Map> modifiedCells, required List> insertedRows, required Set deletedRowIndices, + Map? columnDataTypes, }) { final statements = []; final tableRef = quoteQualifiedTable( @@ -151,9 +223,11 @@ abstract final class TableMutationEngine { final colIndex = mod.key; final stagedVal = mod.value; if (colIndex < columns.length) { - final colName = quoteIdentifier(columns[colIndex], dialect); - final literal = formatLiteral(stagedVal, dialect); - setClauses.add('$colName = $literal'); + final colName = columns[colIndex]; + final quotedCol = quoteIdentifier(colName, dialect); + final colType = columnDataTypes?[colName]; + final literal = formatLiteral(stagedVal, dialect, dataTypeName: colType); + setClauses.add('$quotedCol = $literal'); } } @@ -163,6 +237,7 @@ abstract final class TableMutationEngine { primaryKeys: primaryKeys, row: origRow, dialect: dialect, + columnDataTypes: columnDataTypes, ); final sql = 'UPDATE $tableRef SET ${setClauses.join(', ')} WHERE $whereClause'; @@ -183,10 +258,12 @@ abstract final class TableMutationEngine { final values = []; for (var c = 0; c < columns.length; c++) { - final colName = quoteIdentifier(columns[c], dialect); + final colName = columns[c]; + final quotedCol = quoteIdentifier(colName, dialect); final cellVal = c < row.length ? row[c] : 'NULL'; - colNames.add(colName); - values.add(formatLiteral(cellVal, dialect)); + final colType = columnDataTypes?[colName]; + colNames.add(quotedCol); + values.add(formatLiteral(cellVal, dialect, dataTypeName: colType)); } final sql = 'INSERT INTO $tableRef (${colNames.join(', ')}) VALUES (${values.join(', ')})'; @@ -208,6 +285,7 @@ abstract final class TableMutationEngine { primaryKeys: primaryKeys, row: origRow, dialect: dialect, + columnDataTypes: columnDataTypes, ); final sql = 'DELETE FROM $tableRef WHERE $whereClause'; @@ -234,6 +312,7 @@ abstract final class TableMutationEngine { required List primaryKeys, required List row, required SqlDialect dialect, + Map? columnDataTypes, }) { final clauses = []; @@ -246,7 +325,8 @@ abstract final class TableMutationEngine { if (val == 'NULL' || val == 'null') { clauses.add('$colName IS NULL'); } else { - clauses.add('$colName = ${formatLiteral(val, dialect)}'); + final colType = columnDataTypes?[pk]; + clauses.add('$colName = ${formatLiteral(val, dialect, dataTypeName: colType)}'); } } } @@ -255,12 +335,14 @@ abstract final class TableMutationEngine { // Fallback: if no primary keys found or matched, match all columns if (clauses.isEmpty) { for (var c = 0; c < columns.length; c++) { - final colName = quoteIdentifier(columns[c], dialect); + final colName = columns[c]; + final quotedCol = quoteIdentifier(colName, dialect); final val = c < row.length ? row[c] : 'NULL'; if (val == 'NULL' || val == 'null') { - clauses.add('$colName IS NULL'); + clauses.add('$quotedCol IS NULL'); } else { - clauses.add('$colName = ${formatLiteral(val, dialect)}'); + final colType = columnDataTypes?[colName]; + clauses.add('$quotedCol = ${formatLiteral(val, dialect, dataTypeName: colType)}'); } } } diff --git a/lib/features/main_screen/data_grid_staging_buffer.dart b/lib/features/main_screen/data_grid_staging_buffer.dart index 4f04b16..07bb13b 100644 --- a/lib/features/main_screen/data_grid_staging_buffer.dart +++ b/lib/features/main_screen/data_grid_staging_buffer.dart @@ -251,6 +251,7 @@ class DataGridStagingBuffer extends ChangeNotifier { required String tableName, String? schema, List primaryKeys = const [], + Map? columnDataTypes, }) { return TableMutationEngine.generatePlan( dialect: dialect, @@ -262,6 +263,7 @@ class DataGridStagingBuffer extends ChangeNotifier { modifiedCells: _modifiedCells, insertedRows: _insertedRows, deletedRowIndices: _deletedRowIndices, + columnDataTypes: columnDataTypes, ); } diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart index 2e19188..de5c3dc 100644 --- a/test/core/database/table_mutation_engine_test.dart +++ b/test/core/database/table_mutation_engine_test.dart @@ -197,5 +197,61 @@ void main() { 'UPDATE "tags" SET "description" = \'online store\' WHERE "category" = \'sales\' AND "description" = \'retail store\'', ); }); + + test('preserves leading zeros and boolean strings in string columns with columnDataTypes', () { + const stringCols = ['id', 'zip_code', 'is_flag_str']; + const stringRows = [ + ['1', '01234', 'true'], + ]; + + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'addresses', + columns: stringCols, + primaryKeys: ['id'], + originalRows: stringRows, + modifiedCells: { + 0: {1: '00789', 2: 'false'}, + }, + insertedRows: [ + ['2', '04560', 'true'], + ], + deletedRowIndices: {}, + columnDataTypes: { + 'id': 'int', + 'zip_code': 'varchar(10)', + 'is_flag_str': 'text', + }, + ); + + expect(plan.statementCount, 2); + expect( + plan.statements[0].sql, + 'UPDATE "addresses" SET "zip_code" = \'00789\', "is_flag_str" = \'false\' WHERE "id" = 1', + ); + expect( + plan.statements[1].sql, + 'INSERT INTO "addresses" ("id", "zip_code", "is_flag_str") VALUES (2, \'04560\', \'true\')', + ); + }); + + test('preserves leading zeros in fallback heuristic without columnDataTypes', () { + expect( + TableMutationEngine.formatLiteral('01234', SqlDialect.postgres), + '\'01234\'', + ); + expect( + TableMutationEngine.formatLiteral('007', SqlDialect.mysql), + '\'007\'', + ); + expect( + TableMutationEngine.formatLiteral('0', SqlDialect.sqlite), + '0', + ); + expect( + TableMutationEngine.formatLiteral('123', SqlDialect.postgres), + '123', + ); + }); }); } From d399650e56877382e0a57d1f61b82b347edeb8ee Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 05:23:02 +0300 Subject: [PATCH 02/47] fix(dml): differentiate literal 'NULL' string from database NULL (#595) --- lib/core/database/table_mutation_engine.dart | 16 +++++++++- .../main_screen/data_grid_staging_buffer.dart | 32 +++++++++++++++++-- .../database/table_mutation_engine_test.dart | 28 ++++++++++++++++ .../data_grid_staging_buffer_test.dart | 13 ++++++++ 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index ca1099a..6077f55 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -127,14 +127,17 @@ abstract final class TableMutationEngine { lower.contains('number'); } + static const String kNullSentinel = '\u0000__QUERYA_NULL__\u0000'; + /// Formats a cell string value safely as an SQL literal or `NULL`. /// If [dataTypeName] is provided, formats according to the column type semantics. static String formatLiteral( String value, SqlDialect dialect, { String? dataTypeName, + bool isExplicitNull = false, }) { - if (value == 'NULL' || value == 'null') { + if (isExplicitNull || value == kNullSentinel) { return 'NULL'; } @@ -142,11 +145,15 @@ abstract final class TableMutationEngine { if (dataTypeName != null && dataTypeName.isNotEmpty) { if (_isTextType(dataTypeName)) { + // String columns: preserve literal 'NULL' or 'null' as a text string final escaped = value.replaceAll("'", "''"); return "'$escaped'"; } if (_isBoolType(dataTypeName)) { + if (trimmed == 'NULL' || trimmed == 'null') { + return 'NULL'; + } if (trimmed.toLowerCase() == 'true' || trimmed == '1') { return dialect == SqlDialect.sqlite ? '1' : 'TRUE'; } @@ -156,12 +163,19 @@ abstract final class TableMutationEngine { } if (_isNumericType(dataTypeName)) { + if (trimmed == 'NULL' || trimmed == 'null') { + return 'NULL'; + } if (RegExp(r'^-?\d+(\.\d+)?$').hasMatch(trimmed)) { return trimmed; } } } + if (value == 'NULL' || value == 'null') { + return 'NULL'; + } + // Fallback heuristic: // Leading zeros with more digits (e.g. '01234', '007') are preserved as strings if (RegExp(r'^0\d+$').hasMatch(trimmed)) { diff --git a/lib/features/main_screen/data_grid_staging_buffer.dart b/lib/features/main_screen/data_grid_staging_buffer.dart index 07bb13b..bae5c99 100644 --- a/lib/features/main_screen/data_grid_staging_buffer.dart +++ b/lib/features/main_screen/data_grid_staging_buffer.dart @@ -82,7 +82,9 @@ class DataGridStagingBuffer extends ChangeNotifier { if (row < 0 || col < 0) return ''; if (row < _originalRows.length) { final staged = _modifiedCells[row]?[col]; - if (staged != null) return staged; + if (staged != null) { + return staged == TableMutationEngine.kNullSentinel ? 'NULL' : staged; + } if (col < _originalRows[row].length) { return _originalRows[row][col]; } @@ -90,11 +92,32 @@ class DataGridStagingBuffer extends ChangeNotifier { } final insertIdx = row - _originalRows.length; if (insertIdx < _insertedRows.length && col < _insertedRows[insertIdx].length) { - return _insertedRows[insertIdx][col]; + final ins = _insertedRows[insertIdx][col]; + return ins == TableMutationEngine.kNullSentinel ? 'NULL' : ins; } return ''; } + /// True if the specified cell is explicitly null or stores 'NULL'. + bool isCellNull(int row, int col) { + if (row < 0 || col < 0) return false; + if (row < _originalRows.length) { + final staged = _modifiedCells[row]?[col]; + if (staged != null) return staged == TableMutationEngine.kNullSentinel; + if (col < _originalRows[row].length) { + final orig = _originalRows[row][col]; + return orig == 'NULL' || orig == TableMutationEngine.kNullSentinel; + } + return false; + } + final insertIdx = row - _originalRows.length; + if (insertIdx < _insertedRows.length && col < _insertedRows[insertIdx].length) { + final ins = _insertedRows[insertIdx][col]; + return ins == 'NULL' || ins == TableMutationEngine.kNullSentinel; + } + return false; + } + /// Returns original baseline cell value, or null if row is inserted. String? getOriginalCellValue(int row, int col) { if (row >= 0 && row < _originalRows.length && col >= 0 && col < _originalRows[row].length) { @@ -128,6 +151,11 @@ class DataGridStagingBuffer extends ChangeNotifier { return StagedCellStatus.clean; } + /// Explicitly sets the cell to SQL NULL. + void setCellNull(int row, int col) { + setCell(row, col, TableMutationEngine.kNullSentinel); + } + /// Stages an edit for the specified cell. /// If the new value equals original value, clears the modified flag. void setCell(int row, int col, String value) { diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart index de5c3dc..b96b7b7 100644 --- a/test/core/database/table_mutation_engine_test.dart +++ b/test/core/database/table_mutation_engine_test.dart @@ -253,5 +253,33 @@ void main() { '123', ); }); + + test('differentiates literal NULL string from SQL NULL', () { + // For string column: 'NULL' is preserved as string literal ''NULL'' + expect( + TableMutationEngine.formatLiteral( + 'NULL', + SqlDialect.postgres, + dataTypeName: 'varchar', + ), + '\'NULL\'', + ); + + // Explicit SQL NULL via sentinel is emitted as bare NULL + expect( + TableMutationEngine.formatLiteral( + TableMutationEngine.kNullSentinel, + SqlDialect.postgres, + dataTypeName: 'varchar', + ), + 'NULL', + ); + + // Fallback without dataTypeName still handles 'NULL' as bare NULL + expect( + TableMutationEngine.formatLiteral('NULL', SqlDialect.postgres), + 'NULL', + ); + }); }); } diff --git a/test/features/main_screen/data_grid_staging_buffer_test.dart b/test/features/main_screen/data_grid_staging_buffer_test.dart index 09eb68b..48c9285 100644 --- a/test/features/main_screen/data_grid_staging_buffer_test.dart +++ b/test/features/main_screen/data_grid_staging_buffer_test.dart @@ -121,6 +121,19 @@ void main() { expect(eff[3], ['4', 'David', 'david@test.com']); }); + test('setCellNull and isCellNull handle explicit SQL NULL states', () { + expect(buffer.isCellNull(0, 1), isFalse); + + buffer.setCellNull(0, 1); + expect(buffer.isDirty, isTrue); + expect(buffer.isCellNull(0, 1), isTrue); + expect(buffer.getCellValue(0, 1), 'NULL'); + + buffer.setCell(0, 1, 'NULL'); + expect(buffer.isCellNull(0, 1), isFalse); + expect(buffer.getCellValue(0, 1), 'NULL'); + }); + test('generateMutationPlan builds correct DML statements from staged modifications', () { buffer.setCell(0, 1, 'Alice Updated'); buffer.addRow(['4', 'Diana', 'diana@test.com']); From 432caae0da2102b4a52a64c4b3a204e79d4b181e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 05:24:00 +0300 Subject: [PATCH 03/47] feat(dml): add visual duplicate warning in DML Preview dialog for tables without Primary Key (#596) --- lib/core/database/table_mutation_engine.dart | 3 ++ .../main_screen/dml_preview_dialog.dart | 40 +++++++++++++++++++ .../main_screen/dml_preview_dialog_test.dart | 22 ++++++++++ 3 files changed, 65 insertions(+) diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index 6077f55..7f9f305 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -32,12 +32,14 @@ class TableMutationPlan { required this.tableName, this.schema, required this.statements, + this.hasPrimaryKey = true, }); final SqlDialect dialect; final String tableName; final String? schema; final List statements; + final bool hasPrimaryKey; bool get isEmpty => statements.isEmpty; int get statementCount => statements.length; @@ -318,6 +320,7 @@ abstract final class TableMutationEngine { tableName: tableName, schema: schema, statements: statements, + hasPrimaryKey: primaryKeys.isNotEmpty, ); } diff --git a/lib/features/main_screen/dml_preview_dialog.dart b/lib/features/main_screen/dml_preview_dialog.dart index e30b088..3db5185 100644 --- a/lib/features/main_screen/dml_preview_dialog.dart +++ b/lib/features/main_screen/dml_preview_dialog.dart @@ -181,6 +181,46 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { ), ], ), + + // Warning banner if table lacks primary key and performs UPDATE/DELETE + if (!widget.plan.hasPrimaryKey && + widget.plan.statements.any( + (s) => + s.type == MutationType.update || + s.type == MutationType.delete, + )) ...[ + const Gap(10), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: material.BoxDecoration( + color: material.Colors.amber.withValues( + alpha: isDark ? 0.15 : 0.08, + ), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: material.Colors.amber.withValues(alpha: 0.4), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.warning_amber_rounded, + size: 15, + color: material.Colors.amber.shade700, + ), + const Gap(8), + material.Expanded( + child: const Text( + 'No Primary Key detected. WHERE clauses compare all columns (identical duplicate rows will be modified together).', + ).xSmall().muted(), + ), + ], + ), + ), + ], const Gap(14), // SQL Preview code block header diff --git a/test/features/main_screen/dml_preview_dialog_test.dart b/test/features/main_screen/dml_preview_dialog_test.dart index 8212f45..0546d65 100644 --- a/test/features/main_screen/dml_preview_dialog_test.dart +++ b/test/features/main_screen/dml_preview_dialog_test.dart @@ -101,5 +101,27 @@ void main() { expect(result, isTrue); }); + + testWidgets('shows warning banner when plan has no primary key and updates rows', (tester) async { + const noPkPlan = TableMutationPlan( + dialect: SqlDialect.postgres, + tableName: 'tags', + schema: 'public', + hasPrimaryKey: false, + statements: [ + TableMutationStatement( + type: MutationType.update, + sql: 'UPDATE "public"."tags" SET "name" = \'val\' WHERE "name" = \'old\'', + description: 'Update row 1', + ), + ], + ); + + await tester.pumpWidget(buildTestDialog(plan: noPkPlan)); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.textContaining('No Primary Key detected'), findsOneWidget); + }); }); } From ea6cfabc925ecb40f34629fb95743bca50b3c691 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 05:24:42 +0300 Subject: [PATCH 04/47] perf(pool): tune idleDisposeDelay to 4 seconds for rapid tab switching (#597) --- lib/core/database/mysql_connection_pool.dart | 2 +- lib/core/database/postgres_connection_pool.dart | 2 +- lib/core/database/sqlite_connection_pool.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core/database/mysql_connection_pool.dart b/lib/core/database/mysql_connection_pool.dart index 1d4289d..1f7a421 100644 --- a/lib/core/database/mysql_connection_pool.dart +++ b/lib/core/database/mysql_connection_pool.dart @@ -41,7 +41,7 @@ class MysqlConnectionPool { this.maxEntries = defaultMaxEntries, }); - static const Duration defaultIdleDisposeDelay = Duration(seconds: 8); + static const Duration defaultIdleDisposeDelay = Duration(seconds: 4); static const int defaultMaxEntries = 32; final MysqlPoolConnectionFactory createAndConnect; diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart index 2474e72..8ed85b3 100644 --- a/lib/core/database/postgres_connection_pool.dart +++ b/lib/core/database/postgres_connection_pool.dart @@ -50,7 +50,7 @@ class PostgresConnectionPool { this.maxEntries = defaultMaxEntries, }); - static const Duration defaultIdleDisposeDelay = Duration(seconds: 8); + static const Duration defaultIdleDisposeDelay = Duration(seconds: 4); /// Max distinct pool keys `(connection id, database, mode)`. When full, /// least-recently-used **idle** slots (`refs == 0`) are closed first. diff --git a/lib/core/database/sqlite_connection_pool.dart b/lib/core/database/sqlite_connection_pool.dart index c7b2ee3..672e63d 100644 --- a/lib/core/database/sqlite_connection_pool.dart +++ b/lib/core/database/sqlite_connection_pool.dart @@ -53,7 +53,7 @@ class SqliteConnectionPool { this.maxEntries = defaultMaxEntries, }); - static const Duration defaultIdleDisposeDelay = Duration(seconds: 8); + static const Duration defaultIdleDisposeDelay = Duration(seconds: 4); static const int defaultMaxEntries = 32; final SqlitePoolConnectionFactory createAndConnect; From b2a9999bcbb4a14986482aa6aa5fdff27d45600d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 10:57:29 +0300 Subject: [PATCH 05/47] test(docker): expand multi-engine test seeds with comprehensive data types and rich datasets --- docker/clickhouse/init/01_seed.sql | 101 +++++++++- docker/mongo/init/01_seed.js | 174 +++++++++++------ docker/mysql/init/01_shop.sql | 142 +++++++++----- docker/mysql/init/02_all_types.sql | 99 ++++++++++ docker/postgres/init/01_shop.sql | 137 ++++++++++--- docker/postgres/init/02_analytics.sql | 51 ++++- docker/postgres/init/03_all_types.sql | 270 ++++++++++++++++++++++++++ docker/redis/seed.sh | 46 +++-- docker/sqlite/init.sql | 143 +++++++++++--- 9 files changed, 967 insertions(+), 196 deletions(-) create mode 100644 docker/mysql/init/02_all_types.sql create mode 100644 docker/postgres/init/03_all_types.sql diff --git a/docker/clickhouse/init/01_seed.sql b/docker/clickhouse/init/01_seed.sql index 24c06ee..9bf35a3 100644 --- a/docker/clickhouse/init/01_seed.sql +++ b/docker/clickhouse/init/01_seed.sql @@ -9,6 +9,8 @@ CREATE TABLE IF NOT EXISTS querya.customers name String, email String, city LowCardinality(String), + country LowCardinality(String), + is_vip Bool, created_at DateTime ) ENGINE = MergeTree @@ -20,7 +22,10 @@ CREATE TABLE IF NOT EXISTS querya.products sku String, title String, category LowCardinality(String), - price Decimal(10, 2) + price Decimal(10, 2), + cost Decimal(10, 2), + stock UInt32, + tags Array(String) ) ENGINE = MergeTree ORDER BY id; @@ -31,6 +36,7 @@ CREATE TABLE IF NOT EXISTS querya.orders customer_id UInt32, status LowCardinality(String), total Decimal(12, 2), + discount Decimal(5, 2), placed_at DateTime ) ENGINE = MergeTree @@ -49,17 +55,84 @@ ORDER BY (order_id, product_id); CREATE TABLE IF NOT EXISTS querya.events ( event_id UUID, - event_time DateTime, + event_time DateTime64(3), user_id UInt32, event_type LowCardinality(String), path String, country LowCardinality(String), + ip_v4 IPv4, + properties Map(String, String), revenue Decimal(12, 4) ) ENGINE = MergeTree PARTITION BY toYYYYMM(event_time) ORDER BY (event_time, user_id); +-- Comprehensive ClickHouse Data Types Table +CREATE TABLE IF NOT EXISTS querya.all_clickhouse_types +( + id UInt32, + col_int8 Int8, + col_int16 Int16, + col_int32 Int32, + col_int64 Int64, + col_uint8 UInt8, + col_uint16 UInt16, + col_uint32 UInt32, + col_uint64 UInt64, + col_float32 Float32, + col_float64 Float64, + col_decimal Decimal(18, 4), + col_string String, + col_fixed_string FixedString(8), + col_low_card LowCardinality(String), + col_date Date, + col_date32 Date32, + col_datetime DateTime, + col_datetime64 DateTime64(3), + col_uuid UUID, + col_ipv4 IPv4, + col_ipv6 IPv6, + col_enum Enum8('alpha' = 1, 'beta' = 2, 'gamma' = 3), + col_bool Bool, + col_array_str Array(String), + col_array_int Array(Int32), + col_tuple Tuple(title String, count UInt16), + col_map Map(String, String), + col_nullable_str Nullable(String), + col_nullable_int Nullable(Int32) +) +ENGINE = MergeTree +ORDER BY id; + +INSERT INTO querya.all_clickhouse_types VALUES +( + 1, -128, -32768, -2147483648, -9223372036854775808, + 255, 65535, 4294967295, 18446744073709551615, + 3.14159, 2.718281828459045, 1234567890.1234, + 'ClickHouse String with emoji 🚀', 'CLICKHSE', 'Berlin', + '2026-08-26', '2026-08-26', '2026-08-26 10:30:00', '2026-08-26 10:30:00.123', + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '192.168.1.1', '2001:db8::1', + 'alpha', true, + ['one', 'two', 'three'], [10, 20, 30], + ('Tuple Example', 42), + map('env', 'production', 'tier', 'gold'), + 'Non-null string', 100 +), +( + 2, 0, 0, 0, 0, + 0, 0, 0, 0, + 0.0, 0.0, 0.0000, + '', '00000000', 'London', + '1970-01-01', '1970-01-01', '1970-01-01 00:00:00', '1970-01-01 00:00:00.000', + '00000000-0000-0000-0000-000000000000', '0.0.0.0', '::', + 'beta', false, + [], [], + ('', 0), + map(), + NULL, NULL +); + -- 500 customers INSERT INTO querya.customers SELECT @@ -67,6 +140,8 @@ SELECT concat('Customer ', toString(number + 1)) AS name, concat('user', toString(number + 1), '@example.com') AS email, ['Berlin', 'London', 'Madrid', 'Paris', 'Tokyo', 'New York', 'São Paulo', 'Cairo'][number % 8 + 1] AS city, + ['DEU', 'GBR', 'ESP', 'FRA', 'JPN', 'USA', 'BRA', 'EGY'][number % 8 + 1] AS country, + (number % 5 = 0) AS is_vip, now() - toIntervalDay(number % 365) AS created_at FROM numbers(500); @@ -77,7 +152,10 @@ SELECT concat('SKU-', leftPad(toString(number + 1), 4, '0')) AS sku, concat('Product ', toString(number + 1)) AS title, ['Electronics', 'Books', 'Home', 'Sports', 'Fashion'][number % 5 + 1] AS category, - toDecimal64(round(4.99 + (number % 200) * 1.37, 2), 2) AS price + toDecimal64(round(4.99 + (number % 200) * 1.37, 2), 2) AS price, + toDecimal64(round(2.00 + (number % 100) * 0.95, 2), 2) AS cost, + toUInt32(number * 12 % 300) AS stock, + ['popular', 'new_arrival', 'sale'] AS tags FROM numbers(80); -- 2_000 orders @@ -87,6 +165,7 @@ SELECT toUInt32((number % 500) + 1) AS customer_id, ['new', 'paid', 'shipped', 'cancelled', 'refunded'][number % 5 + 1] AS status, toDecimal64(round(9.99 + (number % 150) * 2.41, 2), 2) AS total, + toDecimal64(round((number % 10) * 1.5, 2), 2) AS discount, now() - toIntervalHour(number % (24 * 120)) AS placed_at FROM numbers(2000); @@ -99,14 +178,16 @@ SELECT toDecimal64(round(4.99 + (number % 200) * 1.37, 2), 2) AS unit_price FROM numbers(6000); --- 50_000 analytics events +-- 10_000 events INSERT INTO querya.events SELECT generateUUIDv4() AS event_id, - now() - toIntervalSecond(number % (86400 * 30)) AS event_time, + now64(3) - toIntervalMinute(number % (60 * 24 * 30)) AS event_time, toUInt32((number % 500) + 1) AS user_id, - ['page_view', 'add_to_cart', 'purchase', 'search', 'login'][number % 5 + 1] AS event_type, - concat('/app/', ['home', 'catalog', 'product', 'checkout', 'account'][number % 5 + 1]) AS path, - ['DE', 'GB', 'ES', 'FR', 'JP', 'US', 'BR', 'EG'][number % 8 + 1] AS country, - if(number % 5 = 2, toDecimal64(round((number % 100) * 1.25, 4), 4), toDecimal64(0, 4)) AS revenue -FROM numbers(50000); + ['pageview', 'click', 'scroll', 'purchase', 'add_to_cart'][number % 5 + 1] AS event_type, + ['/home', '/products', '/checkout', '/cart', '/profile'][number % 5 + 1] AS path, + ['DEU', 'USA', 'GBR', 'FRA', 'JPN', 'CAN'][number % 6 + 1] AS country, + toIPv4(concat('192.168.1.', toString(1 + (number % 254)))) AS ip_v4, + map('browser', 'Chrome', 'version', '128.0') AS properties, + toDecimal64(round((number % 50) * 1.25, 4), 4) AS revenue +FROM numbers(10000); diff --git a/docker/mongo/init/01_seed.js b/docker/mongo/init/01_seed.js index b7dd34a..ec75c14 100644 --- a/docker/mongo/init/01_seed.js +++ b/docker/mongo/init/01_seed.js @@ -1,83 +1,131 @@ -// Demo data for Querya manual testing (MongoDB). +// Demo data for Querya MongoDB testing. const appDb = db.getSiblingDB('querya'); appDb.users.drop(); appDb.products.drop(); appDb.orders.drop(); +appDb.types_showcase.drop(); -appDb.users.insertMany([ +// 1. All BSON Types Showcase Collection +appDb.types_showcase.insertMany([ { - name: 'Alice Martin', - email: 'alice@example.com', - role: 'admin', - city: 'Berlin', - tags: ['staff', 'beta'], - active: true, - }, - { - name: 'Bob Smith', - email: 'bob@example.com', - role: 'customer', - city: 'London', - tags: ['beta'], - active: true, + _id: new ObjectId(), + title: 'Rich BSON Document 1', + col_string: 'MongoDB BSON String with emoji 🚀 🍃', + col_int32: NumberInt(2147483647), + col_int64: NumberLong('9223372036854775807'), + col_double: 3.141592653589793, + col_decimal: NumberDecimal('12345678901234.5678'), + col_boolean: true, + col_date: new Date('2026-08-26T10:30:00Z'), + col_array_primitives: [1, 2, 3, 42, 99], + col_array_strings: ['Alpha', 'Beta', 'Gamma'], + col_array_objects: [ + { id: 1, label: 'First' }, + { id: 2, label: 'Second' } + ], + col_object: { + nested_key: 'nested_value', + level_2: { + deep: true, + count: NumberInt(10) + } + }, + col_binary: new BinData(0, '3q2+7w=='), + col_regex: /^querya/i, + col_null: null }, { - name: 'Carla Ruiz', - email: 'carla@example.com', - role: 'customer', - city: 'Madrid', - tags: [], - active: false, - }, + _id: new ObjectId(), + title: 'Boundary & Min Values', + col_string: '', + col_int32: NumberInt(-2147483648), + col_int64: NumberLong('-9223372036854775808'), + col_double: -2.71828, + col_decimal: NumberDecimal('-999999.99'), + col_boolean: false, + col_date: new Date('1970-01-01T00:00:00Z'), + col_array_primitives: [], + col_array_strings: [], + col_array_objects: [], + col_object: {}, + col_binary: new BinData(0, ''), + col_regex: /.+/, + col_null: null + } ]); -appDb.products.insertMany([ - { sku: 'SKU-001', title: 'Wireless Mouse', price: 29.99, stock: 120 }, - { sku: 'SKU-002', title: 'Mechanical Keyboard', price: 89.0, stock: 45 }, - { sku: 'SKU-003', title: 'USB-C Hub', price: 45.5, stock: 80 }, - { sku: 'SKU-004', title: '27" Monitor', price: 329.0, stock: 15 }, -]); +// 2. Generate 100 Users +const users = []; +const cities = ['Berlin', 'London', 'Madrid', 'Paris', 'Tokyo', 'New York', 'San Francisco', 'Sydney', 'Toronto', 'Singapore']; +const countries = ['DEU', 'GBR', 'ESP', 'FRA', 'JPN', 'USA', 'USA', 'AUS', 'CAN', 'SGP']; -appDb.orders.insertMany([ - { - customerEmail: 'alice@example.com', - status: 'paid', - total: 164.49, - placedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), +for (let i = 1; i <= 100; i++) { + users.push({ + userId: i, + name: 'Customer ' + i, + email: 'customer' + i + '@example.org', + city: cities[(i - 1) % cities.length], + country: countries[(i - 1) % countries.length], + role: (i % 5 === 0) ? 'admin' : (i % 3 === 0 ? 'editor' : 'customer'), + active: (i % 15 !== 0), + tags: (i % 2 === 0) ? ['vip', 'tech'] : ['standard'], + metadata: { + loyaltyTier: (i % 5 === 0) ? 'gold' : (i % 3 === 0 ? 'silver' : 'bronze'), + score: i * 15 + }, + createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000) + }); +} +appDb.users.insertMany(users); + +// 3. Generate 50 Products +const products = []; +const categories = ['Peripherals', 'Hardware', 'Audio', 'Furniture', 'Accessories']; +for (let i = 1; i <= 50; i++) { + products.push({ + sku: 'SKU-' + String(i).padStart(4, '0'), + title: 'Product ' + i, + category: categories[(i - 1) % categories.length], + price: NumberDecimal((19.99 + i * 7.45).toFixed(2)), + stock: NumberInt((i * 15) % 250), + tags: ['electronics', 'gadget'], + createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000) + }); +} +appDb.products.insertMany(products); + +// 4. Generate 250 Orders +const orders = []; +const statuses = ['new', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']; +for (let i = 1; i <= 250; i++) { + orders.push({ + orderId: i, + customerEmail: 'customer' + (1 + (i % 100)) + '@example.org', + status: statuses[(i - 1) % statuses.length], + total: NumberDecimal((49.99 + i * 3.15).toFixed(2)), + placedAt: new Date(Date.now() - (250 - i) * 60 * 60 * 1000), lines: [ - { sku: 'SKU-001', qty: 1, unitPrice: 29.99 }, - { sku: 'SKU-003', qty: 1, unitPrice: 45.5 }, - { sku: 'SKU-002', qty: 1, unitPrice: 89.0 }, - ], - }, - { - customerEmail: 'bob@example.com', - status: 'shipped', - total: 404.0, - placedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), - lines: [{ sku: 'SKU-004', qty: 1, unitPrice: 329.0 }], - }, - { - customerEmail: 'carla@example.com', - status: 'new', - total: 29.99, - placedAt: new Date(), - lines: [{ sku: 'SKU-001', qty: 1, unitPrice: 29.99 }], - }, -]); + { sku: 'SKU-0001', qty: 1, unitPrice: NumberDecimal('29.99') }, + { sku: 'SKU-0002', qty: 2, unitPrice: NumberDecimal('45.50') } + ] + }); +} +appDb.orders.insertMany(orders); appDb.users.createIndex({ email: 1 }, { unique: true }); appDb.products.createIndex({ sku: 1 }, { unique: true }); appDb.orders.createIndex({ status: 1, placedAt: -1 }); +// Analytics Database const analyticsDb = db.getSiblingDB('analytics'); analyticsDb.metrics.drop(); -analyticsDb.metrics.insertMany([ - { day: new Date(), orders: 4, revenue: 203.99 }, - { - day: new Date(Date.now() - 24 * 60 * 60 * 1000), - orders: 9, - revenue: 615.0, - }, -]); +const metrics = []; +for (let i = 1; i <= 60; i++) { + metrics.push({ + day: new Date(Date.now() - (60 - i) * 24 * 60 * 60 * 1000), + orders: NumberInt(10 + (i * 3) % 40), + revenue: NumberDecimal((500.00 + i * 35.50).toFixed(2)) + }); +} +analyticsDb.metrics.insertMany(metrics); diff --git a/docker/mysql/init/01_shop.sql b/docker/mysql/init/01_shop.sql index 3cd9845..a5df51b 100644 --- a/docker/mysql/init/01_shop.sql +++ b/docker/mysql/init/01_shop.sql @@ -1,87 +1,137 @@ --- Demo schema for Querya manual testing (MySQL / MariaDB-compatible). +-- Demo schema for Querya manual testing (MySQL 8.4+). USE querya; -CREATE TABLE customers ( +CREATE TABLE IF NOT EXISTS customers ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL, email VARCHAR(160) NOT NULL UNIQUE, city VARCHAR(80), + country VARCHAR(3) DEFAULT 'USA', + is_vip BOOLEAN DEFAULT FALSE, + metadata JSON, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; -CREATE TABLE products ( +CREATE TABLE IF NOT EXISTS products ( id INT AUTO_INCREMENT PRIMARY KEY, sku VARCHAR(32) NOT NULL UNIQUE, title VARCHAR(160) NOT NULL, - price DECIMAL(10, 2) NOT NULL + category VARCHAR(64) NOT NULL DEFAULT 'General', + price DECIMAL(10, 2) NOT NULL, + cost DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + stock INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; -CREATE TABLE orders ( +CREATE TABLE IF NOT EXISTS orders ( id INT AUTO_INCREMENT PRIMARY KEY, customer_id INT NOT NULL, - status VARCHAR(32) NOT NULL DEFAULT 'new', + status ENUM('new', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded') NOT NULL DEFAULT 'new', total DECIMAL(10, 2) NOT NULL DEFAULT 0, + discount DECIMAL(5, 2) NOT NULL DEFAULT 0, + shipping_address JSON, placed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + delivered_at TIMESTAMP NULL, CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) ) ENGINE=InnoDB; -CREATE TABLE order_lines ( +CREATE TABLE IF NOT EXISTS order_lines ( order_id INT NOT NULL, product_id INT NOT NULL, qty INT NOT NULL, unit_price DECIMAL(10, 2) NOT NULL, + discount_applied DECIMAL(5, 2) NOT NULL DEFAULT 0, PRIMARY KEY (order_id, product_id), CONSTRAINT fk_lines_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE, CONSTRAINT fk_lines_product FOREIGN KEY (product_id) REFERENCES products (id) ) ENGINE=InnoDB; -INSERT INTO customers (name, email, city) VALUES - ('Alice Martin', 'alice@example.com', 'Berlin'), - ('Bob Smith', 'bob@example.com', 'London'), - ('Carla Ruiz', 'carla@example.com', 'Madrid'); +-- Stored procedure to generate seed data in MySQL +DELIMITER // +CREATE PROCEDURE SeedShopData() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE cities VARCHAR(500) DEFAULT 'Berlin,London,Madrid,Paris,Tokyo,New York,San Francisco,Sydney,Toronto,Singapore'; + + -- Seed 100 customers + WHILE i <= 100 DO + INSERT INTO customers (name, email, city, country, is_vip, metadata, created_at) + VALUES ( + CONCAT('Customer ', i), + CONCAT('customer', i, '@example.org'), + ELT(1 + (i MOD 10), 'Berlin', 'London', 'Madrid', 'Paris', 'Tokyo', 'New York', 'San Francisco', 'Sydney', 'Toronto', 'Singapore'), + ELT(1 + (i MOD 10), 'DEU', 'GBR', 'ESP', 'FRA', 'JPN', 'USA', 'USA', 'AUS', 'CAN', 'SGP'), + (i MOD 5 = 0), + JSON_OBJECT('loyalty_tier', IF(i MOD 5 = 0, 'gold', IF(i MOD 3 = 0, 'silver', 'bronze')), 'points', i * 42), + DATE_SUB(NOW(), INTERVAL i DAY) + ); + SET i = i + 1; + END WHILE; + + -- Seed 50 products + SET i = 1; + WHILE i <= 50 DO + INSERT INTO products (sku, title, category, price, cost, stock, is_active, created_at) + VALUES ( + CONCAT('SKU-', LPAD(i, 4, '0')), + CONCAT(ELT(1 + (i MOD 10), 'Wireless Mouse', 'Mechanical Keyboard', 'USB-C Hub', '27" 4K Monitor', 'Headphones Pro', 'Desk Chair', 'Webcam 1080p', 'Microphone', 'Laptop Stand', 'Desk Lamp'), ' v', (i DIV 10) + 1), + ELT(1 + (i MOD 5), 'Peripherals', 'Hardware', 'Audio', 'Furniture', 'Accessories'), + ROUND(19.99 + (i * 7.45), 2), + ROUND(10.00 + (i * 4.20), 2), + (i * 15) MOD 250, + (i MOD 15 != 0), + DATE_SUB(NOW(), INTERVAL i DAY) + ); + SET i = i + 1; + END WHILE; -INSERT INTO products (sku, title, price) VALUES - ('SKU-001', 'Wireless Mouse', 29.99), - ('SKU-002', 'Mechanical Keyboard', 89.00), - ('SKU-003', 'USB-C Hub', 45.50), - ('SKU-004', '27 inch Monitor', 329.00); + -- Seed 250 orders + SET i = 1; + WHILE i <= 250 DO + INSERT INTO orders (customer_id, status, total, discount, shipping_address, placed_at, delivered_at) + VALUES ( + 1 + (i MOD 100), + ELT(1 + (i MOD 6), 'new', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'), + ROUND(49.99 + (i * 3.15), 2), + ROUND((i MOD 10) * 1.5, 2), + JSON_OBJECT('street', CONCAT(i, ' Main St'), 'zip', CONCAT('1000', i MOD 90)), + DATE_SUB(NOW(), INTERVAL (250 - i) HOUR), + IF(i MOD 6 IN (3, 4), DATE_SUB(NOW(), INTERVAL (250 - i - 24) HOUR), NULL) + ); + SET i = i + 1; + END WHILE; -INSERT INTO orders (customer_id, status, total, placed_at) VALUES - (1, 'paid', 164.49, NOW() - INTERVAL 2 DAY), - (2, 'shipped', 404.49, NOW() - INTERVAL 1 DAY), - (3, 'new', 29.99, NOW()); + -- Seed 500 order lines + SET i = 1; + WHILE i <= 500 DO + INSERT IGNORE INTO order_lines (order_id, product_id, qty, unit_price, discount_applied) + VALUES ( + 1 + (i MOD 250), + 1 + ((i * 7) MOD 50), + 1 + (i MOD 5), + ROUND(19.99 + ((1 + ((i * 7) MOD 50)) * 7.45), 2), + 0.00 + ); + SET i = i + 1; + END WHILE; +END // +DELIMITER ; -INSERT INTO order_lines (order_id, product_id, qty, unit_price) VALUES - (1, 1, 1, 29.99), - (1, 3, 1, 45.50), - (1, 2, 1, 89.00), - (2, 4, 1, 329.00), - (2, 1, 1, 29.99), - (2, 3, 1, 45.50), - (3, 1, 1, 29.99); +CALL SeedShopData(); +DROP PROCEDURE SeedShopData; -CREATE VIEW customer_spending AS +CREATE OR REPLACE VIEW customer_spending AS SELECT c.id, c.name, + c.email, c.city, + c.country, + c.is_vip, COUNT(o.id) AS order_count, - COALESCE(SUM(o.total), 0) AS lifetime_total + COALESCE(SUM(o.total), 0) AS lifetime_total, + MAX(o.placed_at) AS last_order_date FROM customers c LEFT JOIN orders o ON o.customer_id = c.id -GROUP BY c.id, c.name, c.city; - -CREATE DATABASE IF NOT EXISTS analytics; - -USE analytics; - -CREATE TABLE daily_sales ( - day DATE PRIMARY KEY, - orders INT NOT NULL, - revenue DECIMAL(12, 2) NOT NULL -) ENGINE=InnoDB; - -INSERT INTO daily_sales (day, orders, revenue) VALUES - (CURDATE() - INTERVAL 2 DAY, 12, 842.50), - (CURDATE() - INTERVAL 1 DAY, 9, 615.00), - (CURDATE(), 4, 203.99); +GROUP BY c.id, c.name, c.email, c.city, c.country, c.is_vip; diff --git a/docker/mysql/init/02_all_types.sql b/docker/mysql/init/02_all_types.sql new file mode 100644 index 0000000..f206959 --- /dev/null +++ b/docker/mysql/init/02_all_types.sql @@ -0,0 +1,99 @@ +USE querya; + +-- Comprehensive MySQL Data Types Showcase Table +CREATE TABLE IF NOT EXISTS all_mysql_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + col_tinyint TINYINT, + col_smallint SMALLINT, + col_mediumint MEDIUMINT, + col_int INT, + col_bigint BIGINT, + col_decimal DECIMAL(12, 4), + col_float FLOAT, + col_double DOUBLE, + col_bit BIT(8), + col_boolean BOOLEAN, + col_char CHAR(10), + col_varchar VARCHAR(255), + col_text TEXT, + col_json JSON, + col_date DATE, + col_time TIME, + col_datetime DATETIME, + col_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + col_year YEAR, + col_enum ENUM('alpha', 'beta', 'gamma', 'delta') DEFAULT 'alpha', + col_set SET('read', 'write', 'execute', 'admin') DEFAULT 'read', + col_binary BINARY(16), + col_varbinary VARBINARY(64), + col_blob BLOB, + description VARCHAR(100) +) ENGINE=InnoDB; + +INSERT INTO all_mysql_types ( + col_tinyint, col_smallint, col_mediumint, col_int, col_bigint, + col_decimal, col_float, col_double, col_bit, col_boolean, + col_char, col_varchar, col_text, col_json, + col_date, col_time, col_datetime, col_year, + col_enum, col_set, col_binary, col_varbinary, col_blob, description +) VALUES +( + 127, 32767, 8388607, 2147483647, 9223372036854775807, + 12345678.9012, 3.14159, 2.718281828459045, b'10101010', TRUE, + 'FIXED', 'Variable length string test', 'Long multiline text paragraph with emoji 🚀 🐬', + JSON_OBJECT('name', 'Querya MySQL', 'version', '8.4', 'features', JSON_ARRAY('DataGrid', 'Inspector', 'Calc')), + '2026-08-26', '10:30:00', '2026-08-26 10:30:00', 2026, + 'alpha', 'read,write', + UNHEX('DEADBEEFCAFE0102030405060708090A'), UNHEX('CAFEBABE'), 'Binary BLOB payload', 'Max / Standard row' +), +( + -128, -32768, -8388608, -2147483648, -9223372036854775808, + -12345678.9012, -3.14159, -2.718281828459045, b'00000000', FALSE, + 'MIN', 'Negative boundaries', 'Negative numbers test', + JSON_ARRAY('one', 'two', 'three'), + '1970-01-01', '00:00:00', '1970-01-01 00:00:00', 1970, + 'gamma', 'execute,admin', + UNHEX('00000000000000000000000000000000'), UNHEX('00'), 'Min bytes', 'Min / Boundary row' +), +( + NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, 'All NULL row' +); + +-- Second database: analytics +CREATE DATABASE IF NOT EXISTS analytics; +USE analytics; + +CREATE TABLE IF NOT EXISTS daily_sales ( + day DATE PRIMARY KEY, + orders INT NOT NULL, + gross_revenue DECIMAL(12, 2) NOT NULL, + net_revenue DECIMAL(12, 2) NOT NULL, + refunds DECIMAL(12, 2) NOT NULL DEFAULT 0.00, + new_customers INT NOT NULL DEFAULT 0 +) ENGINE=InnoDB; + +DELIMITER // +CREATE PROCEDURE SeedDailySales() +BEGIN + DECLARE i INT DEFAULT 1; + WHILE i <= 90 DO + INSERT IGNORE INTO daily_sales (day, orders, gross_revenue, net_revenue, refunds, new_customers) + VALUES ( + DATE_SUB(CURDATE(), INTERVAL (90 - i) DAY), + 10 + (i * 3) MOD 45, + ROUND(500.00 + (i * 45.20) + ((i MOD 7) * 120.00), 2), + ROUND(450.00 + (i * 40.00) + ((i MOD 7) * 110.00), 2), + ROUND((i MOD 5) * 25.50, 2), + 2 + (i MOD 12) + ); + SET i = i + 1; + END WHILE; +END // +DELIMITER ; + +CALL SeedDailySales(); +DROP PROCEDURE SeedDailySales; diff --git a/docker/postgres/init/01_shop.sql b/docker/postgres/init/01_shop.sql index bc540a5..0ccfe8b 100644 --- a/docker/postgres/init/01_shop.sql +++ b/docker/postgres/init/01_shop.sql @@ -1,73 +1,130 @@ --- Demo schema for Querya manual testing (PostgreSQL). +-- Demo schema for Querya testing (PostgreSQL). CREATE SCHEMA IF NOT EXISTS shop; -CREATE TABLE shop.customers ( +CREATE TABLE IF NOT EXISTS shop.customers ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, city TEXT, + country VARCHAR(3) DEFAULT 'USA', + is_vip BOOLEAN DEFAULT FALSE, + metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE TABLE shop.products ( +CREATE TABLE IF NOT EXISTS shop.products ( id SERIAL PRIMARY KEY, - sku TEXT NOT NULL UNIQUE, + sku VARCHAR(32) NOT NULL UNIQUE, title TEXT NOT NULL, - price NUMERIC(10, 2) NOT NULL CHECK (price >= 0) + category TEXT NOT NULL DEFAULT 'General', + price NUMERIC(10, 2) NOT NULL CHECK (price >= 0), + cost NUMERIC(10, 2) NOT NULL DEFAULT 0.00, + stock INT NOT NULL DEFAULT 0, + tags TEXT[] DEFAULT '{}', + specs JSONB DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE TABLE shop.orders ( +CREATE TABLE IF NOT EXISTS shop.orders ( id SERIAL PRIMARY KEY, customer_id INT NOT NULL REFERENCES shop.customers (id), status TEXT NOT NULL DEFAULT 'new', total NUMERIC(10, 2) NOT NULL DEFAULT 0, - placed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + discount NUMERIC(5, 2) NOT NULL DEFAULT 0, + shipping_address JSONB DEFAULT '{}', + placed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delivered_at TIMESTAMPTZ ); -CREATE TABLE shop.order_lines ( +CREATE TABLE IF NOT EXISTS shop.order_lines ( order_id INT NOT NULL REFERENCES shop.orders (id) ON DELETE CASCADE, product_id INT NOT NULL REFERENCES shop.products (id), qty INT NOT NULL CHECK (qty > 0), unit_price NUMERIC(10, 2) NOT NULL, + discount_applied NUMERIC(5, 2) NOT NULL DEFAULT 0, PRIMARY KEY (order_id, product_id) ); -INSERT INTO shop.customers (name, email, city) VALUES - ('Alice Martin', 'alice@example.com', 'Berlin'), - ('Bob Smith', 'bob@example.com', 'London'), - ('Carla Ruiz', 'carla@example.com', 'Madrid'); +-- Seed 100 customers +INSERT INTO shop.customers (name, email, city, country, is_vip, metadata, created_at) +SELECT + 'Customer ' || i, + 'customer' || i || '@example.' || (CASE i % 4 WHEN 0 THEN 'com' WHEN 1 THEN 'org' WHEN 2 THEN 'io' ELSE 'net' END), + (ARRAY['Berlin', 'London', 'Madrid', 'Paris', 'Tokyo', 'New York', 'San Francisco', 'Sydney', 'Toronto', 'Singapore'])[1 + (i % 10)], + (ARRAY['DEU', 'GBR', 'ESP', 'FRA', 'JPN', 'USA', 'USA', 'AUS', 'CAN', 'SGP'])[1 + (i % 10)], + (i % 5 = 0), + jsonb_build_object('loyalty_tier', (CASE WHEN i % 5 = 0 THEN 'gold' WHEN i % 3 = 0 THEN 'silver' ELSE 'bronze' END), 'points', i * 42), + NOW() - (i || ' days')::INTERVAL +FROM generate_series(1, 100) AS i; -INSERT INTO shop.products (sku, title, price) VALUES - ('SKU-001', 'Wireless Mouse', 29.99), - ('SKU-002', 'Mechanical Keyboard', 89.00), - ('SKU-003', 'USB-C Hub', 45.50), - ('SKU-004', '27" Monitor', 329.00); +-- Seed 50 products +INSERT INTO shop.products (sku, title, category, price, cost, stock, tags, specs, is_active, created_at) +SELECT + 'SKU-' || LPAD(i::TEXT, 4, '0'), + (ARRAY['Wireless Mouse', 'Mechanical Keyboard', 'USB-C Hub', '27" 4K Monitor', 'Noise-Canceling Headphones', 'Ergonomic Desk Chair', 'Webcam 1080p', 'Microphone Pro', 'Laptop Stand', 'Smart Desk Lamp'])[1 + (i % 10)] || ' v' || (i / 10 + 1), + (ARRAY['Peripherals', 'Hardware', 'Audio', 'Furniture', 'Accessories'])[1 + (i % 5)], + ROUND((19.99 + (i * 7.45))::NUMERIC, 2), + ROUND((10.00 + (i * 4.20))::NUMERIC, 2), + (i * 15) % 250, + ARRAY['bestseller', 'tech', (CASE i % 3 WHEN 0 THEN 'wireless' WHEN 1 THEN 'usb' ELSE 'ergonomic' END)], + jsonb_build_object('weight_g', 150 + i * 10, 'warranty_months', (CASE WHEN i % 2 = 0 THEN 24 ELSE 12 END), 'color', (ARRAY['black', 'white', 'space_gray', 'silver'])[1 + (i % 4)]), + (i % 15 != 0), + NOW() - (i || ' days')::INTERVAL +FROM generate_series(1, 50) AS i; -INSERT INTO shop.orders (customer_id, status, total, placed_at) VALUES - (1, 'paid', 164.49, NOW() - INTERVAL '2 days'), - (2, 'shipped', 404.49, NOW() - INTERVAL '1 day'), - (3, 'new', 29.99, NOW()); +-- Seed 300 orders +INSERT INTO shop.orders (customer_id, status, total, discount, shipping_address, placed_at, delivered_at) +SELECT + 1 + (i % 100), + (ARRAY['new', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'])[1 + (i % 6)], + ROUND((49.99 + (i * 3.15))::NUMERIC, 2), + ROUND(((i % 10) * 1.5)::NUMERIC, 2), + jsonb_build_object('street', i || ' Main St', 'zip', '1000' || (i % 90)), + NOW() - ((300 - i) || ' hours')::INTERVAL, + (CASE WHEN i % 6 IN (3, 4) THEN NOW() - ((300 - i - 24) || ' hours')::INTERVAL ELSE NULL END) +FROM generate_series(1, 300) AS i; -INSERT INTO shop.order_lines (order_id, product_id, qty, unit_price) VALUES - (1, 1, 1, 29.99), - (1, 3, 1, 45.50), - (1, 2, 1, 89.00), - (2, 4, 1, 329.00), - (2, 1, 1, 29.99), - (2, 3, 1, 45.50), - (3, 1, 1, 29.99); +-- Seed 800 order lines +INSERT INTO shop.order_lines (order_id, product_id, qty, unit_price, discount_applied) +SELECT + 1 + (i % 300), + 1 + ((i * 7) % 50), + 1 + (i % 5), + ROUND((19.99 + ((1 + ((i * 7) % 50)) * 7.45))::NUMERIC, 2), + 0.00 +FROM generate_series(1, 800) AS i +ON CONFLICT (order_id, product_id) DO NOTHING; +-- Views CREATE OR REPLACE VIEW shop.customer_spending AS SELECT c.id, c.name, + c.email, c.city, + c.country, + c.is_vip, COUNT(o.id) AS order_count, - COALESCE(SUM(o.total), 0) AS lifetime_total + COALESCE(SUM(o.total), 0) AS lifetime_total, + MAX(o.placed_at) AS last_order_date FROM shop.customers c LEFT JOIN shop.orders o ON o.customer_id = c.id -GROUP BY c.id, c.name, c.city; +GROUP BY c.id, c.name, c.email, c.city, c.country, c.is_vip; +CREATE MATERIALIZED VIEW IF NOT EXISTS shop.monthly_sales_summary AS +SELECT + DATE_TRUNC('month', o.placed_at)::DATE AS sales_month, + COUNT(DISTINCT o.id) AS total_orders, + COUNT(DISTINCT o.customer_id) AS unique_customers, + SUM(o.total) AS gross_revenue, + ROUND(AVG(o.total), 2) AS avg_order_value +FROM shop.orders o +WHERE o.status NOT IN ('cancelled', 'refunded') +GROUP BY DATE_TRUNC('month', o.placed_at)::DATE +ORDER BY sales_month DESC; + +-- Functions CREATE OR REPLACE FUNCTION shop.order_count_for_customer(p_customer_id INT) RETURNS INT LANGUAGE sql @@ -75,3 +132,21 @@ STABLE AS $$ SELECT COUNT(*)::INT FROM shop.orders WHERE customer_id = p_customer_id; $$; + +CREATE OR REPLACE FUNCTION shop.get_customer_tier(p_lifetime_total NUMERIC) +RETURNS TEXT +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + IF p_lifetime_total >= 5000 THEN + RETURN 'PLATINUM'; + ELSIF p_lifetime_total >= 1000 THEN + RETURN 'GOLD'; + ELSIF p_lifetime_total >= 250 THEN + RETURN 'SILVER'; + ELSE + RETURN 'BRONZE'; + END IF; +END; +$$; diff --git a/docker/postgres/init/02_analytics.sql b/docker/postgres/init/02_analytics.sql index 9aa868f..e5c829b 100644 --- a/docker/postgres/init/02_analytics.sql +++ b/docker/postgres/init/02_analytics.sql @@ -1,17 +1,52 @@ --- Second database to exercise PostgreSQL tree / database switching. +-- Second database for multi-DB testing in Querya. CREATE DATABASE analytics; \connect analytics -CREATE SCHEMA metrics; +CREATE SCHEMA IF NOT EXISTS metrics; -CREATE TABLE metrics.daily_sales ( +CREATE TABLE IF NOT EXISTS metrics.daily_sales ( day DATE PRIMARY KEY, orders INT NOT NULL, - revenue NUMERIC(12, 2) NOT NULL + gross_revenue NUMERIC(12, 2) NOT NULL, + net_revenue NUMERIC(12, 2) NOT NULL, + refunds NUMERIC(12, 2) NOT NULL DEFAULT 0.00, + new_customers INT NOT NULL DEFAULT 0 ); -INSERT INTO metrics.daily_sales (day, orders, revenue) VALUES - (CURRENT_DATE - 2, 12, 842.50), - (CURRENT_DATE - 1, 9, 615.00), - (CURRENT_DATE, 4, 203.99); +CREATE TABLE IF NOT EXISTS metrics.user_events ( + event_id BIGSERIAL PRIMARY KEY, + user_id INT NOT NULL, + event_type VARCHAR(64) NOT NULL, + page_url TEXT NOT NULL, + referrer TEXT, + user_agent TEXT, + ip_address INET, + duration_seconds NUMERIC(8, 2), + event_time TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed 90 days of daily sales +INSERT INTO metrics.daily_sales (day, orders, gross_revenue, net_revenue, refunds, new_customers) +SELECT + (CURRENT_DATE - (90 - i)) AS day, + 10 + (i * 3) % 45, + ROUND((500.00 + (i * 45.20) + ((i % 7) * 120.00))::NUMERIC, 2), + ROUND((450.00 + (i * 40.00) + ((i % 7) * 110.00))::NUMERIC, 2), + ROUND(((i % 5) * 25.50)::NUMERIC, 2), + 2 + (i % 12) +FROM generate_series(1, 90) AS i +ON CONFLICT (day) DO NOTHING; + +-- Seed 500 user events +INSERT INTO metrics.user_events (user_id, event_type, page_url, referrer, user_agent, ip_address, duration_seconds, event_time) +SELECT + 1 + (i % 150), + (ARRAY['pageview', 'button_click', 'add_to_cart', 'search', 'checkout_start', 'payment_success'])[1 + (i % 6)], + (ARRAY['/home', '/products', '/cart', '/checkout', '/account', '/search?q=keyboard', '/pricing'])[1 + (i % 7)], + (ARRAY['https://google.com', 'https://github.com', 'https://twitter.com', NULL, 'https://reddit.com'])[1 + (i % 5)], + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)', + ('192.168.1.' || (1 + (i % 254)))::INET, + ROUND((1.5 + (i % 60) * 2.3)::NUMERIC, 2), + NOW() - ((500 - i) * 10 || ' minutes')::INTERVAL +FROM generate_series(1, 500) AS i; diff --git a/docker/postgres/init/03_all_types.sql b/docker/postgres/init/03_all_types.sql new file mode 100644 index 0000000..fffb7d6 --- /dev/null +++ b/docker/postgres/init/03_all_types.sql @@ -0,0 +1,270 @@ +-- Comprehensive PostgreSQL Data Types Showcase Schema +\connect querya + +DROP SCHEMA IF EXISTS types_showcase CASCADE; +CREATE SCHEMA types_showcase; + +-- 1. Custom User Types (Enums, Domains, Composite Types) +CREATE TYPE types_showcase.user_role_enum AS ENUM ('admin', 'editor', 'viewer', 'guest'); +CREATE TYPE types_showcase.order_status_enum AS ENUM ('draft', 'pending', 'processing', 'completed', 'cancelled', 'refunded'); + +CREATE DOMAIN types_showcase.positive_int AS INTEGER CHECK (VALUE > 0); +CREATE DOMAIN types_showcase.valid_email AS TEXT CHECK (VALUE ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'); + +CREATE TYPE types_showcase.geo_coordinate AS ( + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + altitude REAL +); + +CREATE TYPE types_showcase.address_type AS ( + street TEXT, + city TEXT, + postal_code VARCHAR(16), + country VARCHAR(3) +); + +-- 2. Numerics and Booleans +CREATE TABLE types_showcase.all_numerics ( + id SERIAL PRIMARY KEY, + col_smallint SMALLINT, + col_integer INTEGER, + col_bigint BIGINT, + col_numeric NUMERIC(18, 4), + col_decimal DECIMAL(10, 2), + col_real REAL, + col_double DOUBLE PRECISION, + col_money MONEY, + col_boolean BOOLEAN, + description TEXT +); + +INSERT INTO types_showcase.all_numerics + (col_smallint, col_integer, col_bigint, col_numeric, col_decimal, col_real, col_double, col_money, col_boolean, description) +VALUES + (32767, 2147483647, 9223372036854775807, 12345678901234.5678, 12345678.90, 3.14159, 2.718281828459045, '999.99'::MONEY, TRUE, 'Max boundary values'), + (-32768, -2147483648, -9223372036854775808, -12345678901234.5678, -12345678.90, -3.14159, -2.718281828459045, '-999.99'::MONEY, FALSE, 'Min boundary values'), + (0, 0, 0, 0.0000, 0.00, 0.0, 0.0, '0.00'::MONEY, TRUE, 'Zero values'), + (42, 1000, 1000000000, 42.4200, 99.50, 1.23, 4.56789, '1500.50'::MONEY, FALSE, 'Typical sample row'), + (NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'All NULLs'); + +-- 3. Strings and Text +CREATE TABLE types_showcase.all_strings ( + id SERIAL PRIMARY KEY, + col_char CHAR(10), + col_varchar VARCHAR(100), + col_text TEXT, + col_name NAME, + description TEXT +); + +INSERT INTO types_showcase.all_strings (col_char, col_varchar, col_text, col_name, description) +VALUES + ('FIXED', 'Variable length string', 'Long multiline text paragraph with special characters: "quotes", \slashes\, and emoji 🚀 🐘 🔥', 'pg_identifier_name', 'Standard strings'), + ('UTF8', 'Привет, мир! こんにちは世界', 'Русский текст, иероглифы и диакритика: résumé, naïve, über', 'utf8_name', 'International Unicode strings'), + ('EMPTY', '', '', 'empty_test', 'Empty string test'), + ('SQL_DANGER', ''' OR ''1''=''1', 'SELECT * FROM users; DROP TABLE test; --', 'sql_inject', 'SQL escape check'), + (NULL, NULL, NULL, NULL, 'All NULL strings'); + +-- 4. Date, Time and Temporal +CREATE TABLE types_showcase.all_datetime ( + id SERIAL PRIMARY KEY, + col_date DATE, + col_time TIME, + col_timetz TIMETZ, + col_timestamp TIMESTAMP, + col_timestamptz TIMESTAMPTZ, + col_interval INTERVAL, + description TEXT +); + +INSERT INTO types_showcase.all_datetime (col_date, col_time, col_timetz, col_timestamp, col_timestamptz, col_interval, description) +VALUES + ('2026-08-26', '10:30:00', '10:30:00+03:00', '2026-08-26 10:30:00', '2026-08-26 10:30:00+00:00', '1 year 2 months 3 days 4 hours 5 minutes 6 seconds', 'Present day full temporal'), + ('1970-01-01', '00:00:00', '00:00:00+00:00', '1970-01-01 00:00:00', '1970-01-01 00:00:00+00:00', '0 seconds', 'Unix Epoch'), + ('2099-12-31', '23:59:59', '23:59:59-08:00', '2099-12-31 23:59:59', '2099-12-31 23:59:59-08:00', '30 days', 'Future boundary'), + (NULL, NULL, NULL, NULL, NULL, NULL, 'All NULL timestamps'); + +-- 5. JSON, JSONB, and XML +CREATE TABLE types_showcase.all_json_xml ( + id SERIAL PRIMARY KEY, + col_json JSON, + col_jsonb JSONB, + col_xml XML, + description TEXT +); + +INSERT INTO types_showcase.all_json_xml (col_json, col_jsonb, col_xml, description) +VALUES + ( + '{"name": "Querya", "type": "Desktop Client", "version": "0.4.14", "open_source": true, "stats": {"stars": 1200, "contributors": 15}}', + '{"id": 42, "user": {"email": "alice@querya.dev", "roles": ["admin", "developer"]}, "settings": {"theme": "dark", "hz": 120, "telemetry": false}}', + XML '0.4.14LinuxDataGridSandbox', + 'Rich nested JSON, JSONB and XML objects' + ), + ( + '["apple", "banana", "cherry", {"nested": [1, 2, 3]}]', + '{"array": [10, 20, 30], "flags": {"a": true, "b": null}}', + XML '', + 'JSON Arrays and compact XML' + ), + ( + '{}', + '[]', + XML '', + 'Empty JSON structures' + ), + (NULL, NULL, NULL, 'All NULL documents'); + +-- 6. UUID, Identifiers and Network Addresses +CREATE TABLE types_showcase.all_identifiers_and_network ( + id SERIAL PRIMARY KEY, + col_uuid UUID, + col_inet INET, + col_cidr CIDR, + col_macaddr MACADDR, + col_macaddr8 MACADDR8, + col_oid OID, + description TEXT +); + +INSERT INTO types_showcase.all_identifiers_and_network (col_uuid, col_inet, col_cidr, col_macaddr, col_macaddr8, col_oid, description) +VALUES + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '192.168.1.100', '10.0.0.0/16', '08:00:2b:01:02:03', '08:00:2b:01:02:03:04:05', 16384, 'IPv4 & standard MAC'), + ('f47ac10b-58cc-4372-a567-0e02b2c3d479', '2001:0db8:85a3:0000:0000:8a2e:0370:7334', '2001:db8::/32', '00-50-56-C0-00-08', '00-50-56-FF-FE-C0-00-08', 32768, 'IPv6 & Extended MAC'), + (NULL, NULL, NULL, NULL, NULL, NULL, 'All NULL identifiers'); + +-- 7. Binary and Bit Strings +CREATE TABLE types_showcase.all_binary_and_bits ( + id SERIAL PRIMARY KEY, + col_bytea BYTEA, + col_bit BIT(8), + col_varbit BIT VARYING(32), + description TEXT +); + +INSERT INTO types_showcase.all_binary_and_bits (col_bytea, col_bit, col_varbit, description) +VALUES + (E'\\xDEADBEEFCAFE0102030405', B'10101010', B'110010101111', 'Hex binary bytes and bitmasks'), + (E'Hello Querya Binary \\000 Test', B'11110000', B'1', 'ASCII text stored as BYTEA'), + (E'\\x', B'00000000', B'0', 'Zero / Empty bytes'), + (NULL, NULL, NULL, 'All NULL binary'); + +-- 8. Arrays (1D and Multi-dimensional) +CREATE TABLE types_showcase.all_arrays ( + id SERIAL PRIMARY KEY, + col_int_array INT[], + col_text_array TEXT[], + col_uuid_array UUID[], + col_bool_array BOOLEAN[], + col_jsonb_array JSONB[], + col_matrix INT[][], + description TEXT +); + +INSERT INTO types_showcase.all_arrays (col_int_array, col_text_array, col_uuid_array, col_bool_array, col_jsonb_array, col_matrix, description) +VALUES + ( + ARRAY[1, 2, 3, 42, 999], + ARRAY['Alpha', 'Beta', 'Gamma', 'Delta'], + ARRAY['a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::UUID, 'f47ac10b-58cc-4372-a567-0e02b2c3d479'::UUID], + ARRAY[TRUE, FALSE, TRUE, TRUE], + ARRAY['{"key": "a"}'::JSONB, '{"key": "b"}'::JSONB], + ARRAY[[1, 2, 3], [4, 5, 6], [7, 8, 9]], + 'Standard filled arrays and 2D matrix' + ), + ( + '{}', + '{}', + '{}', + '{}', + '{}', + '{}', + 'Empty arrays' + ), + (NULL, NULL, NULL, NULL, NULL, NULL, 'All NULL arrays'); + +-- 9. Ranges and Multiranges +CREATE TABLE types_showcase.all_ranges ( + id SERIAL PRIMARY KEY, + col_int4range INT4RANGE, + col_int8range INT8RANGE, + col_numrange NUMRANGE, + col_tsrange TSRANGE, + col_tstzrange TSTZRANGE, + col_daterange DATERANGE, + col_int4multirange INT4MULTIRANGE, + col_datemultirange DATEMULTIRANGE, + description TEXT +); + +INSERT INTO types_showcase.all_ranges (col_int4range, col_int8range, col_numrange, col_tsrange, col_tstzrange, col_daterange, col_int4multirange, col_datemultirange, description) +VALUES + ( + '[1, 100)', + '[1000000, 9999999]', + '(10.5, 99.9)', + '[2026-01-01 00:00:00, 2026-12-31 23:59:59]', + '[2026-08-01 00:00:00+00, 2026-08-31 23:59:59+00)', + '[2026-01-01, 2026-06-30)', + '{[1, 10), [20, 30], [50, 60)}'::INT4MULTIRANGE, + '{[2026-01-01, 2026-01-31), [2026-03-01, 2026-03-31)}'::DATEMULTIRANGE, + 'Standard bounded ranges & multiranges' + ), + ( + '(10,)', + '[, 1000]', + '(,)', + '(2026-01-01,)', + '(, 2026-12-31+00)', + 'empty', + '{}'::INT4MULTIRANGE, + '{}'::DATEMULTIRANGE, + 'Unbounded & empty ranges' + ), + (NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'All NULL ranges'); + +-- 10. Geometric Types +CREATE TABLE types_showcase.all_geometric ( + id SERIAL PRIMARY KEY, + col_point POINT, + col_line LINE, + col_lseg LSEG, + col_box BOX, + col_path PATH, + col_polygon POLYGON, + col_circle CIRCLE, + description TEXT +); + +INSERT INTO types_showcase.all_geometric (col_point, col_line, col_lseg, col_box, col_path, col_polygon, col_circle, description) +VALUES + ( + POINT(10.5, 20.3), + LINE '{1, -1, 0}', + LSEG '[(0,0), (10,10)]', + BOX '((10,10), (0,0))', + PATH '((0,0), (10,0), (10,10), (0,10))', + POLYGON '((0,0), (5,10), (10,0))', + CIRCLE '<(5,5), 10>', + '2D Geometric coordinates and shapes' + ), + (NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'All NULL geometric'); + +-- 11. Custom Types (Enums, Domains, Composite) +CREATE TABLE types_showcase.all_custom_entities ( + id SERIAL PRIMARY KEY, + user_role types_showcase.user_role_enum NOT NULL DEFAULT 'viewer', + order_status types_showcase.order_status_enum NOT NULL DEFAULT 'pending', + age_positive types_showcase.positive_int, + contact_email types_showcase.valid_email, + location types_showcase.geo_coordinate, + home_address types_showcase.address_type, + notes TEXT +); + +INSERT INTO types_showcase.all_custom_entities (user_role, order_status, age_positive, contact_email, location, home_address, notes) +VALUES + ('admin', 'completed', 35, 'alex@querya.dev', ROW(52.5200, 13.4050, 34.0)::types_showcase.geo_coordinate, ROW('Unter den Linden 1', 'Berlin', '10117', 'DEU')::types_showcase.address_type, 'Admin user with composite fields'), + ('editor', 'processing', 28, 'elena@querya.dev', ROW(40.7128, -74.0060, 10.5)::types_showcase.geo_coordinate, ROW('5th Avenue 100', 'New York', '10001', 'USA')::types_showcase.address_type, 'Editor profile in NYC'), + ('guest', 'draft', 19, 'guest@example.com', NULL, NULL, 'Guest user without address'); diff --git a/docker/redis/seed.sh b/docker/redis/seed.sh index 0066be9..c6b4ded 100755 --- a/docker/redis/seed.sh +++ b/docker/redis/seed.sh @@ -9,22 +9,36 @@ until redis-cli -h "$HOST" -p "$PORT" ping | grep -q PONG; do sleep 1 done -if redis-cli -h "$HOST" -p "$PORT" EXISTS querya:seed:marker | grep -q 1; then - echo "Redis seed marker present — skipping." - exit 0 -fi - -echo "Seeding Redis demo keys..." - -redis-cli -h "$HOST" -p "$PORT" <<'EOF' -SET querya:demo:string "Hello from Querya Docker stack" -SET querya:config:version "1" -HSET querya:user:1 name "Alice Martin" email "alice@example.com" city "Berlin" -HSET querya:user:2 name "Bob Smith" email "bob@example.com" city "London" -RPUSH querya:tasks:open "Review PR" "Write docs" "Test Redis key editor" -SADD querya:tags:popular redis docker mongodb postgresql mysql -ZADD querya:leaderboard 980 "player_alpha" 875 "player_beta" 640 "player_gamma" +echo "Flushing and re-seeding Redis keys..." +redis-cli -h "$HOST" -p "$PORT" FLUSHALL + +redis-cli -h "$HOST" -p "$PORT" <<'REDIS_EOF' +SET querya:app:name "Querya Desktop" +SET querya:app:version "0.4.14" +SET querya:config:theme "dark" +SET querya:config:scale "1.0" +SET querya:config:refresh_rate_hz "120" +SET querya:metrics:uptime_seconds "86400" + +HSET querya:user:1 id "1" name "Alice Martin" email "alice@example.com" city "Berlin" role "admin" is_active "true" +HSET querya:user:2 id "2" name "Bob Smith" email "bob@example.com" city "London" role "editor" is_active "true" +HSET querya:user:3 id "3" name "Carla Ruiz" email "carla@example.com" city "Madrid" role "viewer" is_active "false" +HSET querya:user:4 id "4" name "Daisuke Sato" email "daisuke@example.com" city "Tokyo" role "customer" is_active "true" + +RPUSH querya:queue:tasks "Task 1: Generate monthly analytics" "Task 2: Sync marketplace plugins" "Task 3: Run CI regression tests" "Task 4: Clear expired sessions" +RPUSH querya:logs:recent "[INFO] 2026-08-26 10:00:00 - Server started" "[INFO] 2026-08-26 10:05:00 - Connection pool initialized" "[WARN] 2026-08-26 10:15:00 - High memory watermark reached" + +SADD querya:tags:all "postgresql" "mysql" "sqlite" "redis" "mongodb" "clickhouse" "rust" "flutter" "datagrid" "sdui" +SADD querya:features:enabled "virtual_grid" "in_place_editing" "ast_filter" "quick_calc" "fluid_sidebar" "sandbox" + +ZADD querya:leaderboard:points 15200 "alice_martin" 12400 "bob_smith" 9800 "carla_ruiz" 7500 "daisuke_sato" 4200 "elena_popova" +ZADD querya:metrics:cpu_usage 12.5 "host_01" 34.8 "host_02" 78.2 "host_03" 5.1 "host_04" + +XADD querya:stream:events * event_type "user_login" user_id "1" ip "192.168.1.42" +XADD querya:stream:events * event_type "query_executed" db "postgresql" duration_ms "12" +XADD querya:stream:events * event_type "export_csv" rows "5000" duration_ms "45" + SET querya:seed:marker "1" -EOF +REDIS_EOF echo "Redis seed complete." diff --git a/docker/sqlite/init.sql b/docker/sqlite/init.sql index 172eea4..ca5de5f 100644 --- a/docker/sqlite/init.sql +++ b/docker/sqlite/init.sql @@ -1,41 +1,140 @@ --- Querya SQLite Test Database Initialization --- This script runs once via Docker to seed the local querya.db file. +-- Querya SQLite Comprehensive Test Database Initialization +-- This script runs via Docker / init scripts to seed querya.db. -CREATE TABLE IF NOT EXISTS users ( +DROP TABLE IF EXISTS order_lines; +DROP TABLE IF EXISTS orders; +DROP TABLE IF EXISTS products; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS all_sqlite_types; +DROP VIEW IF EXISTS customer_spending; + +CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, + username TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE, + full_name TEXT NOT NULL, + city TEXT DEFAULT 'Berlin', + is_active INTEGER DEFAULT 1, + metadata TEXT DEFAULT '{}', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -CREATE TABLE IF NOT EXISTS products ( +CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + category TEXT DEFAULT 'Peripherals', price REAL NOT NULL, - stock INTEGER DEFAULT 0 + stock INTEGER DEFAULT 0, + is_available INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -CREATE TABLE IF NOT EXISTS orders ( +CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, total REAL NOT NULL, + discount REAL DEFAULT 0.0, status TEXT DEFAULT 'pending', + shipping_address TEXT DEFAULT '{}', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ); --- Seed Data -INSERT OR IGNORE INTO users (id, username, email) VALUES -(1, 'alice_smith', 'alice@example.com'), -(2, 'bob_jones', 'bob@example.com'), -(3, 'charlie_brown', 'charlie@example.com'); - -INSERT OR IGNORE INTO products (id, name, price, stock) VALUES -(1, 'Laptop Pro', 1299.99, 50), -(2, 'Wireless Mouse', 49.99, 200), -(3, 'Mechanical Keyboard', 149.50, 75); - -INSERT OR IGNORE INTO orders (id, user_id, total, status) VALUES -(1, 1, 1299.99, 'completed'), -(2, 2, 49.99, 'shipped'), -(3, 1, 149.50, 'pending'); +CREATE TABLE all_sqlite_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + col_integer INTEGER, + col_real REAL, + col_text TEXT, + col_blob BLOB, + col_numeric NUMERIC, + col_boolean INTEGER, + col_datetime DATETIME, + col_json TEXT, + description TEXT +); + +-- Seed all_sqlite_types +INSERT INTO all_sqlite_types (col_integer, col_real, col_text, col_blob, col_numeric, col_boolean, col_datetime, col_json, description) +VALUES +( + 9223372036854775807, 3.141592653589793, 'Unicode text with emoji 🚀 🗄️', + X'DEADBEEFCAFE0102030405', 12345678.90, 1, + '2026-08-26 10:30:00', + '{"app": "Querya", "engine": "SQLite FFI", "features": ["VirtualGrid", "ASTFilter", "QuickCalc"]}', + 'Max boundary and rich row' +), +( + -9223372036854775808, -2.71828, 'Negative boundaries', + X'00000000', -12345678.90, 0, + '1970-01-01 00:00:00', + '["item1", "item2", 42]', + 'Min boundary row' +), +( + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + 'All NULL row' +); + +-- Seed 50 users +WITH RECURSIVE cnt(x) AS ( + SELECT 1 + UNION ALL + SELECT x+1 FROM cnt WHERE x < 50 +) +INSERT INTO users (username, email, full_name, city, is_active, metadata, created_at) +SELECT + 'user_' || x, + 'user' || x || '@example.com', + 'User Name ' || x, + CASE (x % 5) WHEN 0 THEN 'Berlin' WHEN 1 THEN 'London' WHEN 2 THEN 'Paris' WHEN 3 THEN 'Tokyo' ELSE 'New York' END, + CASE WHEN (x % 5 = 0) THEN 0 ELSE 1 END, + '{"tier": "' || (CASE WHEN x % 3 = 0 THEN 'gold' ELSE 'standard' END) || '", "points": ' || (x * 100) || '}', + DATETIME('now', '-' || x || ' days') +FROM cnt; + +-- Seed 30 products +WITH RECURSIVE cnt(x) AS ( + SELECT 1 + UNION ALL + SELECT x+1 FROM cnt WHERE x < 30 +) +INSERT INTO products (sku, name, category, price, stock, is_available, created_at) +SELECT + 'SKU-' || PRINTF('%04d', x), + 'Product ' || x, + CASE (x % 4) WHEN 0 THEN 'Hardware' WHEN 1 THEN 'Peripherals' WHEN 2 THEN 'Audio' ELSE 'Furniture' END, + ROUND(19.99 + (x * 5.75), 2), + (x * 10) % 150, + 1, + DATETIME('now', '-' || x || ' days') +FROM cnt; + +-- Seed 150 orders +WITH RECURSIVE cnt(x) AS ( + SELECT 1 + UNION ALL + SELECT x+1 FROM cnt WHERE x < 150 +) +INSERT INTO orders (user_id, total, discount, status, shipping_address, created_at) +SELECT + 1 + (x % 50), + ROUND(29.99 + (x * 4.25), 2), + ROUND((x % 10) * 1.5, 2), + CASE (x % 5) WHEN 0 THEN 'completed' WHEN 1 THEN 'shipped' WHEN 2 THEN 'processing' WHEN 3 THEN 'cancelled' ELSE 'pending' END, + '{"street": "' || x || ' High St", "zip": "100' || (x % 90) || '"}', + DATETIME('now', '-' || x || ' hours') +FROM cnt; + +CREATE VIEW customer_spending AS +SELECT + u.id AS user_id, + u.username, + u.email, + u.city, + COUNT(o.id) AS order_count, + COALESCE(SUM(o.total), 0.0) AS total_spent, + MAX(o.created_at) AS last_order_date +FROM users u +LEFT JOIN orders o ON o.user_id = u.id +GROUP BY u.id, u.username, u.email, u.city; From f1cd7bc2586567f3274ea7e37c237813604a7d91 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 10:59:14 +0300 Subject: [PATCH 06/47] perf(grid): optimize sortResultGridRows with precomputed sort keys (#602) --- .../main_screen/result_grid_view.dart | 103 +++++++++++++----- .../main_screen/results_tab_test.dart | 52 +++++++++ 2 files changed, 125 insertions(+), 30 deletions(-) diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index 12acce1..b478fbd 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -287,47 +287,90 @@ enum ResultGridSortOrder { descending, } +enum _SortKeyType { nullOrEmpty, numeric, dateTime, string } + +class _SortKey implements Comparable<_SortKey> { + final _SortKeyType type; + final num? numVal; + final DateTime? dtVal; + final String strLower; + final String strRaw; + + _SortKey._({ + required this.type, + this.numVal, + this.dtVal, + this.strLower = '', + this.strRaw = '', + }); + + factory _SortKey.parse(String val) { + if (val == 'NULL' || val.isEmpty) { + return _SortKey._(type: _SortKeyType.nullOrEmpty); + } + final n = num.tryParse(val); + if (n != null) { + return _SortKey._(type: _SortKeyType.numeric, numVal: n, strRaw: val); + } + final dt = DateTime.tryParse(val); + if (dt != null) { + return _SortKey._(type: _SortKeyType.dateTime, dtVal: dt, strRaw: val); + } + return _SortKey._( + type: _SortKeyType.string, + strLower: val.toLowerCase(), + strRaw: val, + ); + } + + @override + int compareTo(_SortKey other) { + if (type == _SortKeyType.nullOrEmpty && other.type == _SortKeyType.nullOrEmpty) { + return 0; + } + if (type == _SortKeyType.nullOrEmpty) return 1; + if (other.type == _SortKeyType.nullOrEmpty) return -1; + + if (type == _SortKeyType.numeric && other.type == _SortKeyType.numeric) { + return numVal!.compareTo(other.numVal!); + } + if (type == _SortKeyType.dateTime && other.type == _SortKeyType.dateTime) { + return dtVal!.compareTo(other.dtVal!); + } + + final aLower = type == _SortKeyType.string ? strLower : strRaw.toLowerCase(); + final bLower = other.type == _SortKeyType.string ? other.strLower : other.strRaw.toLowerCase(); + final cmp = aLower.compareTo(bLower); + if (cmp != 0) return cmp; + + return strRaw.compareTo(other.strRaw); + } +} + /// Sorts rows by the specified column index with natural numeric / temporal / lexicographic comparison. +/// Uses Schwartzian transform (Decorate-Sort-Undecorate) to precompute sort keys in O(N) time. List> sortResultGridRows({ required List> rows, required int columnIndex, required ResultGridSortOrder order, }) { if (rows.isEmpty || columnIndex < 0) return rows; - final sorted = List>.from(rows); - - sorted.sort((a, b) { - final valA = columnIndex < a.length ? a[columnIndex] : ''; - final valB = columnIndex < b.length ? b[columnIndex] : ''; - - final isNullA = valA == 'NULL' || valA.isEmpty; - final isNullB = valB == 'NULL' || valB.isEmpty; - if (isNullA && isNullB) return 0; - if (isNullA) return 1; - if (isNullB) return -1; - - final numA = num.tryParse(valA); - final numB = num.tryParse(valB); - int cmp; - if (numA != null && numB != null) { - cmp = numA.compareTo(numB); - } else { - final dtA = DateTime.tryParse(valA); - final dtB = DateTime.tryParse(valB); - if (dtA != null && dtB != null) { - cmp = dtA.compareTo(dtB); - } else { - cmp = valA.toLowerCase().compareTo(valB.toLowerCase()); - if (cmp == 0) { - cmp = valA.compareTo(valB); - } - } - } + final n = rows.length; + + final keys = List<_SortKey>.generate(n, (i) { + final row = rows[i]; + final val = columnIndex < row.length ? row[columnIndex] : ''; + return _SortKey.parse(val); + }, growable: false); + + final indices = List.generate(n, (i) => i, growable: false); + indices.sort((a, b) { + final cmp = keys[a].compareTo(keys[b]); return order == ResultGridSortOrder.ascending ? cmp : -cmp; }); - return sorted; + return List>.generate(n, (i) => rows[indices[i]], growable: false); } /// Virtualized read-only or interactive grid for SQL query results (rows + columns). diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index e25eeeb..ee8b06e 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -99,6 +99,58 @@ void main() { expect(sorted.map((r) => r[0]).toList(), ['Apple', 'banana', 'cherry']); }); + test('sorts temporally for ISO-8601 date strings', () { + final rows = [ + ['2026-12-31 23:59:59'], + ['2025-01-01 00:00:00'], + ['2026-08-26 10:30:00'], + ]; + final sorted = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.ascending, + ); + expect(sorted.map((r) => r[0]).toList(), [ + '2025-01-01 00:00:00', + '2026-08-26 10:30:00', + '2026-12-31 23:59:59', + ]); + }); + + test('handles case-insensitive sorting with exact tie-breaking', () { + final rows = [ + ['alpha'], + ['Alpha'], + ['BETA'], + ['beta'], + ]; + final sorted = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.ascending, + ); + expect(sorted.map((r) => r[0]).toList(), ['Alpha', 'alpha', 'BETA', 'beta']); + }); + + test('handles large 5000-row dataset efficiently with Schwartzian transform', () { + final rows = List>.generate( + 5000, + (i) => ['${(5000 - i) * 3}', 'user_$i'], + ); + final stopwatch = Stopwatch()..start(); + final sorted = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.ascending, + ); + stopwatch.stop(); + + expect(sorted.first[0], '3'); + expect(sorted.last[0], '15000'); + // Performance expectation: 5000 rows should easily sort within 50ms with precomputed keys + expect(stopwatch.elapsedMilliseconds, lessThan(300)); + }); + test('handles NULL and empty values gracefully', () { final rows = [ ['100'], From 1142ed11d23483eadbfb5e509cd03d3e7c497122 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 11:00:22 +0300 Subject: [PATCH 07/47] perf(calc): optimize GridSelectionCalcEngine median calculation with QuickSelect (#603) --- .../grid_selection_calc_engine.dart | 77 +++++++++++++++++-- .../main_screen/data_grid_engines_test.dart | 15 ++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/lib/features/main_screen/grid_selection_calc_engine.dart b/lib/features/main_screen/grid_selection_calc_engine.dart index 54b698d..c7a111f 100644 --- a/lib/features/main_screen/grid_selection_calc_engine.dart +++ b/lib/features/main_screen/grid_selection_calc_engine.dart @@ -132,15 +132,25 @@ abstract final class GridSelectionCalcEngine { final numericCount = numericList.length; final avg = numericCount > 0 ? sum / numericCount : null; - // Calculate median + // Calculate median using QuickSelect (O(N)) for large datasets (> 500 elements) or fast sort (<= 500) double? median; if (numericCount > 0) { - numericList.sort(); final mid = numericCount ~/ 2; - if (numericCount.isOdd) { - median = numericList[mid]; + if (numericCount <= 500) { + numericList.sort(); + if (numericCount.isOdd) { + median = numericList[mid]; + } else { + median = (numericList[mid - 1] + numericList[mid]) / 2.0; + } } else { - median = (numericList[mid - 1] + numericList[mid]) / 2.0; + if (numericCount.isOdd) { + median = _quickSelect(numericList, 0, numericCount - 1, mid); + } else { + final m1 = _quickSelect(numericList, 0, numericCount - 1, mid - 1); + final m2 = _quickSelect(numericList, mid, numericCount - 1, mid); + median = (m1 + m2) / 2.0; + } } } @@ -168,6 +178,63 @@ abstract final class GridSelectionCalcEngine { ); } + /// Linear-time QuickSelect algorithm to find the k-th smallest element. + static double _quickSelect(List list, int left, int right, int k) { + while (left < right) { + if (right - left < 10) { + // Insertion sort for small sub-arrays + for (var i = left + 1; i <= right; i++) { + final temp = list[i]; + var j = i - 1; + while (j >= left && list[j] > temp) { + list[j + 1] = list[j]; + j--; + } + list[j + 1] = temp; + } + return list[k]; + } + + final pivotIndex = _partition(list, left, right); + if (pivotIndex == k) { + return list[k]; + } else if (pivotIndex > k) { + right = pivotIndex - 1; + } else { + left = pivotIndex + 1; + } + } + return list[left]; + } + + static int _partition(List list, int left, int right) { + // Median-of-three pivot selection for optimal partitioning + final mid = left + ((right - left) >> 1); + if (list[left] > list[mid]) _swap(list, left, mid); + if (list[left] > list[right]) _swap(list, left, right); + if (list[mid] > list[right]) _swap(list, mid, right); + + final pivotValue = list[mid]; + _swap(list, mid, right - 1); + var i = left; + var j = right - 1; + + while (true) { + while (list[++i] < pivotValue) {} + while (list[--j] > pivotValue) {} + if (i >= j) break; + _swap(list, i, j); + } + _swap(list, i, right - 1); + return i; + } + + static void _swap(List list, int i, int j) { + final temp = list[i]; + list[i] = list[j]; + list[j] = temp; + } + /// Formats a numeric stat cleanly for UI display. static String formatNum(double? val) { if (val == null) return '-'; diff --git a/test/features/main_screen/data_grid_engines_test.dart b/test/features/main_screen/data_grid_engines_test.dart index 07b8f8c..c6c5cf7 100644 --- a/test/features/main_screen/data_grid_engines_test.dart +++ b/test/features/main_screen/data_grid_engines_test.dart @@ -194,6 +194,21 @@ void main() { expect(stats.min, equals(10.0)); expect(stats.max, equals(50.5)); }); + + test('computes correct QuickSelect median for large odd and even selections (> 500)', () { + // Odd length > 500 (1001 items) + final oddData = List.generate(1001, (i) => '${(i * 3) % 1000}'); + final oddStats = GridSelectionCalcEngine.compute(oddData); + final oddParsed = oddData.map(double.parse).toList()..sort(); + expect(oddStats.median, equals(oddParsed[500])); + + // Even length > 500 (1000 items) + final evenData = List.generate(1000, (i) => '${(i * 7) % 2000}'); + final evenStats = GridSelectionCalcEngine.compute(evenData); + final evenParsed = evenData.map(double.parse).toList()..sort(); + final expectedEvenMedian = (evenParsed[499] + evenParsed[500]) / 2.0; + expect(evenStats.median, equals(expectedEvenMedian)); + }); }); group('GridGroupingsEngine', () { From b8fd55e9ceffb16dc3a2cee9b54a05d0ace73ff2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 11:02:32 +0300 Subject: [PATCH 08/47] fix(storage): enable WAL mode and busy_timeout in LocalDb for concurrent lock resilience (#611) --- lib/core/storage/local_db.dart | 13 ++ .../storage/local_db_concurrency_test.dart | 123 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 test/core/storage/local_db_concurrency_test.dart diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 337a412..2ce7806 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -57,12 +57,25 @@ class LocalDb { onUpgrade: _onUpgrade, onOpen: (db) async { await db.execute('PRAGMA foreign_keys = ON'); + await db.execute('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA busy_timeout = 5000'); + await db.execute('PRAGMA synchronous = NORMAL'); }, ), ); return _db!; } + /// Queries an active PRAGMA setting from the database for verification and diagnostic purposes. + Future getPragma(String pragmaName) async { + final db = await _open(); + final res = await db.rawQuery('PRAGMA $pragmaName'); + if (res.isNotEmpty && res.first.values.isNotEmpty) { + return res.first.values.first.toString(); + } + return ''; + } + Future _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE folders ( diff --git a/test/core/storage/local_db_concurrency_test.dart b/test/core/storage/local_db_concurrency_test.dart new file mode 100644 index 0000000..b40b5f6 --- /dev/null +++ b/test/core/storage/local_db_concurrency_test.dart @@ -0,0 +1,123 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; + + @override + Future getApplicationCachePath() async => _root; + + @override + Future getLibraryPath() async => _root; + + @override + Future getExternalStoragePath() async => _root; + + @override + Future?> getExternalCachePaths() async => [_root]; + + @override + Future?> getExternalStoragePaths({StorageDirectory? type}) async => + [_root]; + + @override + Future getDownloadsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_local_db_concurrency_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('LocalDb concurrency and PRAGMA settings', () { + test('enables WAL journal mode, busy_timeout, and normal synchronous', () async { + final foreignKeys = await LocalDb.instance.getPragma('foreign_keys'); + expect(foreignKeys, equals('1')); + + final journalMode = await LocalDb.instance.getPragma('journal_mode'); + expect(journalMode.toLowerCase(), equals('wal')); + + final busyTimeout = await LocalDb.instance.getPragma('busy_timeout'); + expect(busyTimeout, equals('5000')); + + final synchronous = await LocalDb.instance.getPragma('synchronous'); + // In SQLite, synchronous = NORMAL corresponds to integer 1 + expect(synchronous, equals('1')); + }); + + test('handles concurrent reads and writes gracefully without lock errors', () async { + // Create folder and connection + await LocalDb.instance.addFolder('Concurrency Test Folder'); + final folders = await LocalDb.instance.getFolders(); + expect(folders, contains('Concurrency Test Folder')); + + final connId = await LocalDb.instance.addConnection( + ConnectionRow( + type: 'postgresql', + name: 'Concurrency Test Postgres', + host: 'localhost', + port: 5432, + username: 'querya', + createdAt: DateTime.now().toIso8601String(), + ), + ); + + // Fire 20 parallel inserts and reads concurrently + final futures = >[]; + for (var i = 0; i < 20; i++) { + futures.add( + LocalDb.instance.recordSqlQueryHistory( + connectionId: connId, + databaseName: 'test_db', + sqlText: 'SELECT * FROM items WHERE id = $i;', + ), + ); + futures.add( + LocalDb.instance.listSqlQueryHistory( + connectionId: connId, + databaseName: 'test_db', + ).then((_) {}), + ); + } + + await expectLater(Future.wait(futures), completes); + + final history = await LocalDb.instance.listSqlQueryHistory( + connectionId: connId, + databaseName: 'test_db', + limit: 100, + ); + expect(history.length, equals(20)); + + // Cleanup + await LocalDb.instance.removeConnection(connId); + await LocalDb.instance.removeFolder('Concurrency Test Folder'); + }); + }); +} From 191584c83d911447f3ec33b6887b250748132472 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 11:04:21 +0300 Subject: [PATCH 09/47] fix(theme): expand typography monospace fallback stack with Cascadia Code and Consolas (#613) --- .../editor/highlighter_theme_from_querya.dart | 1 + lib/core/editor/querya_code_editor.dart | 1 + lib/core/theme/querya_editor_theme.dart | 9 ++++ lib/core/theme/querya_typography.dart | 26 ++++++++++- test/core/theme/querya_typography_test.dart | 44 +++++++++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 test/core/theme/querya_typography_test.dart diff --git a/lib/core/editor/highlighter_theme_from_querya.dart b/lib/core/editor/highlighter_theme_from_querya.dart index 53a854f..60fdc9d 100644 --- a/lib/core/editor/highlighter_theme_from_querya.dart +++ b/lib/core/editor/highlighter_theme_from_querya.dart @@ -12,6 +12,7 @@ HighlighterTheme highlighterThemeFromQueryaEditor( final wrapper = TextStyle( color: editor.foreground, fontFamily: editor.fontFamily, + fontFamilyFallback: editor.fontFamilyFallback, fontSize: editor.fontSize, ); diff --git a/lib/core/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart index 7e9cb9f..48218ad 100644 --- a/lib/core/editor/querya_code_editor.dart +++ b/lib/core/editor/querya_code_editor.dart @@ -270,6 +270,7 @@ class _QueryaCodeEditorState extends State { final size = widget.fontSize ?? editor.fontSize; return material.TextStyle( fontFamily: editor.fontFamily, + fontFamilyFallback: editor.fontFamilyFallback, fontSize: size, color: editor.foreground, height: widget.language == QueryaCodeLanguage.json ? 1.5 : null, diff --git a/lib/core/theme/querya_editor_theme.dart b/lib/core/theme/querya_editor_theme.dart index af71671..176a99f 100644 --- a/lib/core/theme/querya_editor_theme.dart +++ b/lib/core/theme/querya_editor_theme.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:flutter/foundation.dart'; import 'querya_colors.dart'; import 'querya_typography.dart'; @@ -21,6 +22,7 @@ class QueryaEditorTheme { required this.type, this.widgetBorder, this.fontFamily = QueryaTypography.mono, + this.fontFamilyFallback = QueryaTypography.monoFontFamilyFallback, this.fontSize = 13, }); @@ -41,6 +43,7 @@ class QueryaEditorTheme { final Color function; final Color type; final String fontFamily; + final List? fontFamilyFallback; final double fontSize; /// Aligned with dark workbench; VS Code Dark+–like token hues. @@ -93,6 +96,7 @@ class QueryaEditorTheme { Color? function, Color? type, String? fontFamily, + List? fontFamilyFallback, double? fontSize, }) { return QueryaEditorTheme( @@ -112,6 +116,7 @@ class QueryaEditorTheme { function: function ?? this.function, type: type ?? this.type, fontFamily: fontFamily ?? this.fontFamily, + fontFamilyFallback: fontFamilyFallback ?? this.fontFamilyFallback, fontSize: fontSize ?? this.fontSize, ); } @@ -138,6 +143,8 @@ class QueryaEditorTheme { function: c(a.function, b.function), type: c(a.type, b.type), fontFamily: t < 0.5 ? a.fontFamily : b.fontFamily, + fontFamilyFallback: + t < 0.5 ? a.fontFamilyFallback : b.fontFamilyFallback, fontSize: a.fontSize + (b.fontSize - a.fontSize) * t, ); } @@ -161,6 +168,7 @@ class QueryaEditorTheme { function == other.function && type == other.type && fontFamily == other.fontFamily && + listEquals(fontFamilyFallback, other.fontFamilyFallback) && fontSize == other.fontSize; @override @@ -180,6 +188,7 @@ class QueryaEditorTheme { function, type, fontFamily, + fontFamilyFallback, fontSize, ); } diff --git a/lib/core/theme/querya_typography.dart b/lib/core/theme/querya_typography.dart index 4b5faf3..a0fb456 100644 --- a/lib/core/theme/querya_typography.dart +++ b/lib/core/theme/querya_typography.dart @@ -1,6 +1,28 @@ -/// Monospace stack for SQL editors (bundled font can replace this later). +/// Monospace font stack for SQL editors, cell inspectors, and code preview dialogs. abstract class QueryaTypography { QueryaTypography._(); - static const String mono = 'monospace'; + /// Primary monospace font family identifier. + static const String mono = 'Cascadia Code'; + + /// Cross-platform prioritized monospace font fallback list. + /// + /// Prioritizes modern programming fonts across operating systems: + /// - Windows 11 / Terminal: Cascadia Code + /// - Windows 10 / legacy: Consolas, Courier New + /// - macOS: Menlo, SF Mono, Monaco + /// - Linux / BSD: Fira Code, Ubuntu Mono, DejaVu Sans Mono + /// - Generic system fallback: monospace + static const List monoFontFamilyFallback = [ + 'Cascadia Code', + 'Consolas', + 'Menlo', + 'SF Mono', + 'Monaco', + 'Fira Code', + 'Ubuntu Mono', + 'DejaVu Sans Mono', + 'Courier New', + 'monospace', + ]; } diff --git a/test/core/theme/querya_typography_test.dart b/test/core/theme/querya_typography_test.dart new file mode 100644 index 0000000..8e43d56 --- /dev/null +++ b/test/core/theme/querya_typography_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; + +void main() { + group('QueryaTypography font stack', () { + test('defines modern primary monospace and cross-platform fallback stack', () { + expect(QueryaTypography.mono, equals('Cascadia Code')); + + const stack = QueryaTypography.monoFontFamilyFallback; + expect(stack, contains('Cascadia Code')); + expect(stack, contains('Consolas')); + expect(stack, contains('Menlo')); + expect(stack, contains('SF Mono')); + expect(stack, contains('Fira Code')); + expect(stack, contains('Ubuntu Mono')); + expect(stack, contains('DejaVu Sans Mono')); + expect(stack, contains('monospace')); + + // Ensure fallback ends with generic 'monospace' + expect(stack.last, equals('monospace')); + }); + + test('QueryaEditorTheme wires monospace typography stack by default', () { + const theme = QueryaEditorTheme.darkDefault; + expect(theme.fontFamily, equals(QueryaTypography.mono)); + expect(theme.fontFamilyFallback, equals(QueryaTypography.monoFontFamilyFallback)); + }); + + test('QueryaEditorTheme copyWith and lerp preserve or override fontFamilyFallback', () { + const base = QueryaEditorTheme.darkDefault; + final custom = base.copyWith( + fontFamily: 'Fira Code', + fontFamilyFallback: ['Fira Code', 'monospace'], + ); + expect(custom.fontFamily, equals('Fira Code')); + expect(custom.fontFamilyFallback, equals(['Fira Code', 'monospace'])); + + final lerped = QueryaEditorTheme.lerp(base, custom, 0.7); + expect(lerped.fontFamily, equals('Fira Code')); + expect(lerped.fontFamilyFallback, equals(['Fira Code', 'monospace'])); + }); + }); +} From 0c4586fe7347e71a7a3b3ccfd0cba01a296d53e7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 11:05:17 +0300 Subject: [PATCH 10/47] fix(theme): improve accent color contrast in light default theme (#609) --- lib/core/theme/querya_colors.dart | 5 +- lib/core/theme/querya_workbench_theme.dart | 6 +- test/core/theme/color_contrast_test.dart | 79 +++++++++++++++------- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/lib/core/theme/querya_colors.dart b/lib/core/theme/querya_colors.dart index af0707d..f691b7e 100644 --- a/lib/core/theme/querya_colors.dart +++ b/lib/core/theme/querya_colors.dart @@ -13,9 +13,12 @@ abstract class QueryaColors { /// Elevated surface (cards, mock window chrome). static const Color surface = Color(0xFF0C0C0C); - /// Brand accent (CTAs, tree icons, focus ring). + /// Brand accent (CTAs, tree icons, focus ring on dark surfaces). static const Color accentCyan = Color(0xFF22D3EE); + /// High-contrast brand accent for light surfaces (WCAG AA 4.5:1+ compliant against white/light gray). + static const Color accentCyanLight = Color(0xFF0E7490); + /// Text / icons on filled primary buttons. static const Color onAccent = Color(0xFF0A0A0A); diff --git a/lib/core/theme/querya_workbench_theme.dart b/lib/core/theme/querya_workbench_theme.dart index 9926185..b436dbd 100644 --- a/lib/core/theme/querya_workbench_theme.dart +++ b/lib/core/theme/querya_workbench_theme.dart @@ -51,15 +51,15 @@ class QueryaWorkbenchTheme { gitUntracked: Color(0xFF2EB88A), ); - /// Built-in light preset (slate-like canvas, cyan brand accent). + /// Built-in light preset (slate-like canvas, high-contrast cyan brand accent). static const QueryaWorkbenchTheme lightDefault = QueryaWorkbenchTheme( canvas: Color(0xFFFAFAFA), surface: Color(0xFFFFFFFF), sidebarBackground: Color(0xFFF4F4F5), editorBackground: Color(0xFFFFFFFF), borderSubtle: Color(0xFFE4E4E7), - accent: QueryaColors.accentCyan, - onAccent: QueryaColors.onAccent, + accent: QueryaColors.accentCyanLight, + onAccent: Color(0xFFFFFFFF), mutedForeground: Color(0xFF64748B), destructive: Color(0xFFDC2626), success: Color(0xFF16A34A), diff --git a/test/core/theme/color_contrast_test.dart b/test/core/theme/color_contrast_test.dart index f89a4e2..20b4cc7 100644 --- a/test/core/theme/color_contrast_test.dart +++ b/test/core/theme/color_contrast_test.dart @@ -1,33 +1,62 @@ +import 'dart:math' as math; import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/theme/color_contrast.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; + +/// Computes the relative luminance of a color according to WCAG 2.1 specification. +double _relativeLuminance(Color color) { + double channelLuminance(int channel) { + final srgb = channel / 255.0; + return srgb <= 0.04045 ? srgb / 12.92 : math.pow((srgb + 0.055) / 1.055, 2.4).toDouble(); + } + + final r = channelLuminance(color.red); + final g = channelLuminance(color.green); + final b = channelLuminance(color.blue); + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/// Calculates the contrast ratio between two colors (ranging from 1.0 to 21.0). +double _contrastRatio(Color c1, Color c2) { + final l1 = _relativeLuminance(c1); + final l2 = _relativeLuminance(c2); + final lighter = math.max(l1, l2); + final darker = math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} void main() { - test('legibleSecondaryLabel keeps readable candidate', () { - const candidate = Color(0xFF94A3B8); - const background = Color(0xFF0C0C0C); - const fallback = Color(0xFFF8FAFC); - expect( - legibleSecondaryLabel( - candidate: candidate, - background: background, - fallback: fallback, - ), - candidate, - ); - }); + group('WCAG 2.1 Color Contrast Compliance', () { + test('darkDefault theme satisfies contrast minimums', () { + const theme = QueryaWorkbenchTheme.darkDefault; + + // Accent against canvas / surface + final accentOnCanvas = _contrastRatio(theme.accent, theme.canvas); + final accentOnSurface = _contrastRatio(theme.accent, theme.surface); + + expect(accentOnCanvas, greaterThanOrEqualTo(4.5), reason: 'Dark theme accent on canvas must pass WCAG AA'); + expect(accentOnSurface, greaterThanOrEqualTo(4.5), reason: 'Dark theme accent on surface must pass WCAG AA'); + + // On-accent text against accent button background + final onAccentRatio = _contrastRatio(theme.onAccent, theme.accent); + expect(onAccentRatio, greaterThanOrEqualTo(4.5), reason: 'OnAccent on accent button must pass WCAG AA'); + }); + + test('lightDefault theme satisfies contrast minimums (WCAG AA 4.5:1+)', () { + const theme = QueryaWorkbenchTheme.lightDefault; + + // Accent against canvas / surface + final accentOnCanvas = _contrastRatio(theme.accent, theme.canvas); + final accentOnSurface = _contrastRatio(theme.accent, theme.surface); + + expect(accentOnCanvas, greaterThanOrEqualTo(4.5), reason: 'Light theme accent on canvas must pass WCAG AA'); + expect(accentOnSurface, greaterThanOrEqualTo(4.5), reason: 'Light theme accent on surface must pass WCAG AA'); - test('legibleSecondaryLabel softens low-contrast candidate', () { - const candidate = Color(0xFF4A3F7A); - const background = Color(0xFF14102A); - const fallback = Color(0xFFE8F4FF); - final out = legibleSecondaryLabel( - candidate: candidate, - background: background, - fallback: fallback, - ); - expect(out, fallback.withValues(alpha: 0.72)); - expect(contrastRatio(out, background), greaterThan(4.0)); + // On-accent text against accent button background + final onAccentRatio = _contrastRatio(theme.onAccent, theme.accent); + expect(onAccentRatio, greaterThanOrEqualTo(4.5), reason: 'Light theme onAccent text must pass WCAG AA'); + }); }); } From fb30da252427eb8b91899035abaa4104925b6666 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 11:05:48 +0300 Subject: [PATCH 11/47] fix(linux): suppress benign GDK cursor theme and synthetic device log spam (#614) --- linux/runner/main.cc | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/linux/runner/main.cc b/linux/runner/main.cc index e7c5c54..ec20dd3 100644 --- a/linux/runner/main.cc +++ b/linux/runner/main.cc @@ -1,6 +1,29 @@ #include "my_application.h" +#include +#include + +static GLogWriterOutput suppress_benign_gdk_logs(GLogLevelFlags log_level, + const GLogField* fields, + gsize n_fields, + gpointer user_data) { + for (gsize i = 0; i < n_fields; i++) { + if (fields[i].key != nullptr && strcmp(fields[i].key, "MESSAGE") == 0) { + const char* message = static_cast(fields[i].value); + if (message != nullptr) { + if (strstr(message, "Unable to load") && strstr(message, "cursor theme")) { + return G_LOG_WRITER_HANDLED; + } + if (strstr(message, "gdk_device_get_source") && strstr(message, "GDK_IS_DEVICE")) { + return G_LOG_WRITER_HANDLED; + } + } + } + } + return g_log_writer_default(log_level, fields, n_fields, user_data); +} int main(int argc, char** argv) { + g_log_set_writer_func(suppress_benign_gdk_logs, nullptr, nullptr); g_autoptr(MyApplication) app = my_application_new(); return g_application_run(G_APPLICATION(app), argc, argv); } From 17a2790a59852702961520d22583751bd109c947 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 11:17:10 +0300 Subject: [PATCH 12/47] fix(theme): sync _lightColorScheme primary and ring with accentCyanLight (#609) --- lib/core/theme/querya_theme.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart index 13ababe..0215fee 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -76,8 +76,8 @@ class QueryaTheme { cardForeground: Color(0xFF0F172A), popover: Color(0xFFFFFFFF), popoverForeground: Color(0xFF0F172A), - primary: QueryaColors.accentCyan, - primaryForeground: QueryaColors.onAccent, + primary: QueryaColors.accentCyanLight, + primaryForeground: Color(0xFFFFFFFF), secondary: Color(0xFFF4F4F5), secondaryForeground: Color(0xFF0F172A), muted: Color(0xFFF4F4F5), @@ -88,7 +88,7 @@ class QueryaTheme { destructiveForeground: Color(0xFFF8FAFC), border: Color(0xFFE4E4E7), input: Color(0xFFE4E4E7), - ring: QueryaColors.accentCyan, + ring: QueryaColors.accentCyanLight, chart1: Color(0xFF2662D9), chart2: Color(0xFF2EB88A), chart3: Color(0xFFE88C30), From bcc2cda6e3b3cd6b710d0f5938a4ed7884563b18 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 11:49:35 +0300 Subject: [PATCH 13/47] feat(a11y): add explicit FocusTraversalGroup to multi-section connection dialogs (#610) --- lib/core/sdui/sdui_form_builder.dart | 31 +- .../extension_connection_form.dart | 171 +++---- .../connections/new_connection_dialog.dart | 290 +++++------ .../new_connection_url_dialog.dart | 233 ++++----- .../connections/sqlite_connection_form.dart | 403 ++++++++-------- .../mongodb/mongodb_connection_form.dart | 147 +++--- lib/features/mysql/mysql_connection_form.dart | 159 ++++--- .../postgresql_connection_form.dart | 449 ++++++++++-------- lib/features/redis/redis_connection_form.dart | 125 ++--- .../widgets/ssl_certificate_fields.dart | 61 +-- ...onnection_dialog_focus_traversal_test.dart | 91 ++++ 11 files changed, 1180 insertions(+), 980 deletions(-) create mode 100644 test/features/connections/connection_dialog_focus_traversal_test.dart diff --git a/lib/core/sdui/sdui_form_builder.dart b/lib/core/sdui/sdui_form_builder.dart index 5da85b5..9a20bdb 100644 --- a/lib/core/sdui/sdui_form_builder.dart +++ b/lib/core/sdui/sdui_form_builder.dart @@ -145,21 +145,24 @@ class SduiFormBuilderState extends material.State { @override material.Widget build(material.BuildContext context) { - return material.Form( - key: _formKey, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - if (widget.schema.title != null) ...[ - Text(widget.schema.title!).large().semiBold(), - const Gap(12), - ], - for (var i = 0; i < widget.schema.fields.length; i++) ...[ - if (i > 0) const Gap(12), - _buildField(widget.schema.fields[i]), + return material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Form( + key: _formKey, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (widget.schema.title != null) ...[ + Text(widget.schema.title!).large().semiBold(), + const Gap(12), + ], + for (var i = 0; i < widget.schema.fields.length; i++) ...[ + if (i > 0) const Gap(12), + _buildField(widget.schema.fields[i]), + ], ], - ], + ), ), ); } diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index bdeb803..2f63e33 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -200,10 +200,12 @@ class _ExtensionConnectionFormContentState minWidth: 440, ), borderColor: theme.muted, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), child: material.Column( @@ -218,96 +220,103 @@ class _ExtensionConnectionFormContentState ), ), material.Flexible( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Connection name').small().muted(), - const material.SizedBox(height: 4), - TextField( - controller: _nameController, - placeholder: const Text('My ClickHouse'), - ), - const material.SizedBox(height: 16), - if (_loading) - const material.Padding( - padding: material.EdgeInsets.all(24), - child: material.Center( - child: material.CircularProgressIndicator(), - ), - ) - else if (_loadError != null) - Text(_loadError!).muted().small() - else if (_schema != null) - SduiFormBuilder( - key: _formKey, - schema: _schema!, - initialValues: _initialValues, - keepExistingSecrets: _isEditing, + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Connection name').small().muted(), + const material.SizedBox(height: 4), + TextField( + controller: _nameController, + placeholder: const Text('My ClickHouse'), ), - if (_testMessage != null) ...[ - const material.SizedBox(height: 12), - material.SelectableText( - _testMessage!, - style: material.TextStyle( - fontSize: 12, - color: _testSucceeded - ? material.Colors.green - : theme.destructive, + const material.SizedBox(height: 16), + if (_loading) + const material.Padding( + padding: material.EdgeInsets.all(24), + child: material.Center( + child: material.CircularProgressIndicator(), + ), + ) + else if (_loadError != null) + Text(_loadError!).muted().small() + else if (_schema != null) + SduiFormBuilder( + key: _formKey, + schema: _schema!, + initialValues: _initialValues, + keepExistingSecrets: _isEditing, ), - ), + if (_testMessage != null) ...[ + const material.SizedBox(height: 12), + material.SelectableText( + _testMessage!, + style: material.TextStyle( + fontSize: 12, + color: _testSucceeded + ? material.Colors.green + : theme.destructive, + ), + ), + ], ], - ], + ), ), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3), + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), + ), ), ), - ), - child: material.Row( - children: [ - OutlineButton( - onPressed: - _schema == null || _testing ? null : _testConnection, - leading: _testing - ? const material.SizedBox( - width: 14, - height: 14, - child: material.CircularProgressIndicator( - strokeWidth: 2, + child: material.Row( + children: [ + OutlineButton( + onPressed: + _schema == null || _testing ? null : _testConnection, + leading: _testing + ? const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const material.Icon( + material.Icons.bolt_rounded, + size: 16, ), - ) - : const material.Icon( - material.Icons.bolt_rounded, - size: 16, - ), - child: const Text('Test Connection'), - ), - const Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const material.SizedBox(width: 12), - PrimaryButton( - onPressed: _schema == null ? null : _save, - child: const Text('Save'), - ), - ], + child: const Text('Test Connection'), + ), + const Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _schema == null ? null : _save, + child: const Text('Save'), + ), + ], + ), ), ), ], ), + ), ); } } diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 0671338..c908e3b 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -119,155 +119,167 @@ class _NewConnectionDialogContentState minHeight: math.min(320.0, dialogH), ), borderColor: theme.muted, - child: material.SizedBox( - height: dialogH, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: - material.EdgeInsets.fromLTRB(headerPadH, 20, headerPadH, 8), - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Select your database').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Create new database connection. Find your database driver in the list below.', - ).muted().small(), - const material.SizedBox(height: 12), - material.Container( - decoration: material.BoxDecoration( - color: theme.muted.withValues(alpha: 0.2), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: theme.border.withValues(alpha: 0.4)), - ), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - child: material.Row( - children: [ - material.Icon( - material.Icons.search_rounded, - size: 20, - color: theme.mutedForeground, + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SizedBox( + height: dialogH, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Padding( + padding: + material.EdgeInsets.fromLTRB(headerPadH, 20, headerPadH, 8), + child: Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Select your database').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Create new database connection. Find your database driver in the list below.', + ).muted().small(), + const material.SizedBox(height: 12), + material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: theme.border.withValues(alpha: 0.4)), ), - const material.SizedBox(width: 10), - material.Expanded( - child: TextField( - controller: _searchController, - placeholder: const Text('Search...'), - onChanged: (v) => setState(() { - _searchQuery = v; - if (_selected != null && - !_filteredTypes.any( - (t) => _sameChoice(t, _selected))) { - _selected = null; - } - }), - ), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + material.Icons.search_rounded, + size: 20, + color: theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _searchController, + placeholder: const Text('Search...'), + onChanged: (v) => setState(() { + _searchQuery = v; + if (_selected != null && + !_filteredTypes.any( + (t) => _sameChoice(t, _selected))) { + _selected = null; + } + }), + ), + ), + ], ), - ], - ), + ), + const material.SizedBox(height: 12), + _FilterDropdowns( + stackVertically: stackFilters, + category: _category, + selected: _selected, + filteredTypes: _filteredTypes, + onCategoryChanged: (category) { + setState(() { + _category = category; + if (_selected != null && + !_categoryTypes + .any((t) => _sameChoice(t, _selected))) { + _selected = null; + } + }); + }, + onTypeChanged: (type) => setState(() => _selected = type), + ), + ], ), - const material.SizedBox(height: 12), - _FilterDropdowns( - stackVertically: stackFilters, - category: _category, - selected: _selected, - filteredTypes: _filteredTypes, - onCategoryChanged: (category) { - setState(() { - _category = category; - if (_selected != null && - !_categoryTypes - .any((t) => _sameChoice(t, _selected))) { - _selected = null; - } - }); + ), + ), + material.Expanded( + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.LayoutBuilder( + builder: (context, constraints) { + const spacing = 12.0; + final gridPad = dialogMaxW < 420 ? 12.0 : 16.0; + final innerW = + math.max(0.0, constraints.maxWidth - gridPad * 2); + final crossAxisCount = + WindowLayout.dbTypeGridCrossAxisCount(innerW); + final cardHeight = + WindowLayout.dbTypeCardHeight(context, crossAxisCount); + final cardWidth = crossAxisCount > 0 + ? (innerW - spacing * (crossAxisCount - 1)) / + crossAxisCount + : innerW; + final aspect = + cardHeight > 0 ? cardWidth / cardHeight : 1.0; + return material.Padding( + padding: material.EdgeInsets.all(gridPad), + child: _filteredTypes.isEmpty + ? material.Center( + child: + const Text('No databases match your search.') + .muted() + .small(), + ) + : material.GridView.count( + crossAxisCount: crossAxisCount, + mainAxisSpacing: spacing, + crossAxisSpacing: spacing, + childAspectRatio: aspect.clamp(0.4, 4.0), + shrinkWrap: true, + physics: const material.ClampingScrollPhysics(), + children: [ + for (final t in _filteredTypes) + _DbTypeCard( + choice: t, + theme: theme, + selected: _sameChoice(_selected, t), + onTap: () => setState(() => _selected = t), + ), + ], + ), + ); }, - onTypeChanged: (type) => setState(() => _selected = type), ), - ], - ), - ), - material.Expanded( - child: material.LayoutBuilder( - builder: (context, constraints) { - const spacing = 12.0; - final gridPad = dialogMaxW < 420 ? 12.0 : 16.0; - final innerW = - math.max(0.0, constraints.maxWidth - gridPad * 2); - final crossAxisCount = - WindowLayout.dbTypeGridCrossAxisCount(innerW); - final cardHeight = - WindowLayout.dbTypeCardHeight(context, crossAxisCount); - final cardWidth = crossAxisCount > 0 - ? (innerW - spacing * (crossAxisCount - 1)) / - crossAxisCount - : innerW; - final aspect = - cardHeight > 0 ? cardWidth / cardHeight : 1.0; - return material.Padding( - padding: material.EdgeInsets.all(gridPad), - child: _filteredTypes.isEmpty - ? material.Center( - child: - const Text('No databases match your search.') - .muted() - .small(), - ) - : material.GridView.count( - crossAxisCount: crossAxisCount, - mainAxisSpacing: spacing, - crossAxisSpacing: spacing, - childAspectRatio: aspect.clamp(0.4, 4.0), - shrinkWrap: true, - physics: const material.ClampingScrollPhysics(), - children: [ - for (final t in _filteredTypes) - _DbTypeCard( - choice: t, - theme: theme, - selected: _sameChoice(_selected, t), - onTap: () => setState(() => _selected = t), - ), - ], - ), - ); - }, - ), - ), - material.Container( - padding: material.EdgeInsets.symmetric( - horizontal: headerPadH, - vertical: 14, - ), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3)), ), ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: material.EdgeInsets.symmetric( + horizontal: headerPadH, + vertical: 14, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), + ), ), - const material.SizedBox(width: 12), - PrimaryButton( - onPressed: _selected == null - ? null - : () => material.Navigator.of(context).pop(_selected), - child: const Text('Next'), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _selected == null + ? null + : () => material.Navigator.of(context).pop(_selected), + child: const Text('Next'), + ), + ], ), - ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart index 89a9527..077897e 100644 --- a/lib/features/connections/new_connection_url_dialog.dart +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -89,131 +89,140 @@ class _NewConnectionUrlDialogContentState minWidth: 420, ), borderColor: theme.muted, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('New connection from URL').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Create a connection by pasting a database URI (e.g. postgresql://user:pass@host:5432/db).', - ).muted().small(), - const material.SizedBox(height: 16), - material.Container( - decoration: material.BoxDecoration( - color: theme.muted.withValues(alpha: 0.2), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: isError - ? theme.destructive.withValues(alpha: 0.8) - : isSuccess - ? theme.primary.withValues(alpha: 0.8) - : theme.border.withValues(alpha: 0.4), - ), - ), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - child: material.Row( - children: [ - material.Icon( - isSuccess - ? material.Icons.check_circle_outline_rounded - : isError - ? material.Icons.error_outline_rounded - : material.Icons.link_rounded, - size: 20, - color: isError - ? theme.destructive - : isSuccess - ? theme.primary - : theme.mutedForeground, - ), - const material.SizedBox(width: 10), - material.Expanded( - child: TextField( - controller: _urlController, - placeholder: - const Text('database://user:pass@host:port/db'), - onSubmitted: (_) => _validateAndSubmit(), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('New connection from URL').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Create a connection by pasting a database URI (e.g. postgresql://user:pass@host:5432/db).', + ).muted().small(), + const material.SizedBox(height: 16), + material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: isError + ? theme.destructive.withValues(alpha: 0.8) + : isSuccess + ? theme.primary.withValues(alpha: 0.8) + : theme.border.withValues(alpha: 0.4), ), ), - ], - ), - ), - if (isError) ...[ - const material.SizedBox(height: 8), - Text( - _validationError!, - style: material.TextStyle(color: theme.destructive), - ).small(), - ] else if (isSuccess) ...[ - material.Container( - margin: const material.EdgeInsets.only(top: 10), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - decoration: material.BoxDecoration( - color: theme.primary.withValues(alpha: 0.1), - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: theme.primary.withValues(alpha: 0.3), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + isSuccess + ? material.Icons.check_circle_outline_rounded + : isError + ? material.Icons.error_outline_rounded + : material.Icons.link_rounded, + size: 20, + color: isError + ? theme.destructive + : isSuccess + ? theme.primary + : theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _urlController, + placeholder: + const Text('database://user:pass@host:port/db'), + onSubmitted: (_) => _validateAndSubmit(), + ), + ), + ], ), ), - child: material.Row( - children: [ - material.Icon( - material.Icons.task_alt_rounded, - size: 16, - color: theme.primary, + if (isError) ...[ + const material.SizedBox(height: 8), + Text( + _validationError!, + style: material.TextStyle(color: theme.destructive), + ).small(), + ] else if (isSuccess) ...[ + material.Container( + margin: const material.EdgeInsets.only(top: 10), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: theme.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: theme.primary.withValues(alpha: 0.3), + ), ), - const material.SizedBox(width: 8), - material.Expanded( - child: Text( - '${_parsedRow!.type.toUpperCase()} • ${_parsedRow!.name}', - style: material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w500, + child: material.Row( + children: [ + material.Icon( + material.Icons.task_alt_rounded, + size: 16, color: theme.primary, ), - overflow: material.TextOverflow.ellipsis, - ), + const material.SizedBox(width: 8), + material.Expanded( + child: Text( + '${_parsedRow!.type.toUpperCase()} • ${_parsedRow!.name}', + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: theme.primary, + ), + overflow: material.TextOverflow.ellipsis, + ), + ), + ], ), - ], - ), - ), - ], - ], - ), - ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3)), + ), + ], + ], + ), ), ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), + ), ), - const material.SizedBox(width: 12), - PrimaryButton( - onPressed: _validateAndSubmit, - child: const Text('Create'), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _validateAndSubmit, + child: const Text('Create'), + ), + ], ), - ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index 6f919c4..9f9d4c9 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -195,233 +195,242 @@ class _SqliteConnectionFormContentState maxHeight: dialogH, ), borderColor: theme.muted, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Header - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - Text( - _isEditing - ? 'Edit SQLite Connection' - : 'New SQLite Connection', - ).large().semiBold(), - const Gap(6), - const Text('Connect to a local SQLite database file.') - .muted() - .small(), - ], - ), - ), - // Form body - material.ConstrainedBox( - constraints: material.BoxConstraints( - maxHeight: scrollH, - ), - child: material.SingleChildScrollView( - physics: const material.ClampingScrollPhysics(), - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 12), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - // Connection Name - const Text('Connection name').small().semiBold(), - const Gap(8), - TextField( - controller: _nameController, - placeholder: const Text('e.g. Local Cache'), - ), - if (_nameTouched && _nameController.text.trim().isEmpty) ...[ - const Gap(4), - Text( - 'Connection name is required.', - style: material.TextStyle( - color: theme.destructive, - fontSize: 11, - ), - ), - ], - const Gap(16), - // Database File Path - const Text('Database file path').small().semiBold(), - const Gap(8), - material.Row( + Text( + _isEditing + ? 'Edit SQLite Connection' + : 'New SQLite Connection', + ).large().semiBold(), + const Gap(6), + const Text('Connect to a local SQLite database file.') + .muted() + .small(), + ], + ), + ), + // Form body + material.ConstrainedBox( + constraints: material.BoxConstraints( + maxHeight: scrollH, + ), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + physics: const material.ClampingScrollPhysics(), + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - material.Expanded( - child: TextField( - controller: _pathController, - placeholder: const Text('/path/to/database.db'), - ), - ), - const Gap(10), - OutlineButton( - onPressed: _pickFile, - child: const Text('Browse…'), + // Connection Name + const Text('Connection name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('e.g. Local Cache'), ), - ], - ), - if (_pathTouched && _pathController.text.trim().isEmpty) ...[ - const Gap(4), - Text( - 'Database file path is required.', - style: material.TextStyle( - color: theme.destructive, - fontSize: 11, + if (_nameTouched && _nameController.text.trim().isEmpty) ...[ + const Gap(4), + Text( + 'Connection name is required.', + style: material.TextStyle( + color: theme.destructive, + fontSize: 11, + ), + ), + ], + const Gap(16), + // Database File Path + const Text('Database file path').small().semiBold(), + const Gap(8), + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _pathController, + placeholder: const Text('/path/to/database.db'), + ), + ), + const Gap(10), + OutlineButton( + onPressed: _pickFile, + child: const Text('Browse…'), + ), + ], ), - ), - ], - const Gap(16), - // Read-only toggle - material.Row( - children: [ - material.Checkbox( - value: _readOnly, - onChanged: (v) => - setState(() => _readOnly = v ?? false), + if (_pathTouched && _pathController.text.trim().isEmpty) ...[ + const Gap(4), + Text( + 'Database file path is required.', + style: material.TextStyle( + color: theme.destructive, + fontSize: 11, + ), + ), + ], + const Gap(16), + // Read-only toggle + material.Row( + children: [ + material.Checkbox( + value: _readOnly, + onChanged: (v) => + setState(() => _readOnly = v ?? false), + ), + const Gap(8), + const Text('Read-only mode').small(), + ], ), - const Gap(8), - const Text('Read-only mode').small(), ], ), - ], + ), ), ), - ), - const material.Divider(height: 1), - // Test result banner - if (_testResult != null) - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 8, 16, 8), - child: material.Material( - color: material.Colors.transparent, - child: material.InkWell( - onTap: _dismissResult, - borderRadius: material.BorderRadius.circular(8), - child: material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - decoration: material.BoxDecoration( - color: _testResult == 'success' - ? theme.primary.withValues(alpha: 0.12) - : theme.destructive.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( + const material.Divider(height: 1), + // Test result banner + if (_testResult != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 8, 16, 8), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: _dismissResult, + borderRadius: material.BorderRadius.circular(8), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: material.BoxDecoration( color: _testResult == 'success' - ? theme.primary.withValues(alpha: 0.35) - : theme.destructive.withValues(alpha: 0.35), - width: 1, - ), - ), - child: material.Row( - children: [ - material.Icon( - _testResult == 'success' - ? material.Icons.check_circle_outline - : material.Icons.info_outline_rounded, - size: 18, + ? theme.primary.withValues(alpha: 0.12) + : theme.destructive.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( color: _testResult == 'success' - ? theme.primary - : theme.destructive, + ? theme.primary.withValues(alpha: 0.35) + : theme.destructive.withValues(alpha: 0.35), + width: 1, ), - const Gap(10), - material.Expanded( - child: Text( + ), + child: material.Row( + children: [ + material.Icon( _testResult == 'success' - ? 'Connection successful!' - : _testResult!.startsWith('error:') - ? _testResult!.substring(7) - : 'Connection failed', - style: material.TextStyle( - fontSize: 13, - color: theme.foreground, - ), - ).small(), - ), - material.IconButton( - icon: material.Icon( - material.Icons.close, + ? material.Icons.check_circle_outline + : material.Icons.info_outline_rounded, size: 18, - color: theme.mutedForeground, + color: _testResult == 'success' + ? theme.primary + : theme.destructive, + ), + const Gap(10), + material.Expanded( + child: Text( + _testResult == 'success' + ? 'Connection successful!' + : _testResult!.startsWith('error:') + ? _testResult!.substring(7) + : 'Connection failed', + style: material.TextStyle( + fontSize: 13, + color: theme.foreground, + ), + ).small(), ), - onPressed: _dismissResult, - style: material.IconButton.styleFrom( - minimumSize: const material.Size(28, 28), - padding: material.EdgeInsets.zero, + material.IconButton( + icon: material.Icon( + material.Icons.close, + size: 18, + color: theme.mutedForeground, + ), + onPressed: _dismissResult, + style: material.IconButton.styleFrom( + minimumSize: const material.Size(28, 28), + padding: material.EdgeInsets.zero, + ), ), - ), - ], + ], + ), ), ), ), ), - ), - // Footer - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - child: ValueListenableBuilder( - valueListenable: _formValidNotifier.listenable, - builder: (context, formValid, _) { - return material.Wrap( - spacing: 12, - runSpacing: 8, - alignment: material.WrapAlignment.spaceBetween, - children: [ - OutlineButton( - onPressed: - formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, + // Footer + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, color: formValid ? theme.primary : theme.mutedForeground, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: formValid - ? theme.primary - : theme.mutedForeground, - ), - ), - ), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - GhostButton( - onPressed: () => - material.Navigator.of(context).pop(), - child: const Text('Cancel'), + ), ), - const Gap(12), - PrimaryButton( - onPressed: formValid ? _save : null, - child: const Text('Save'), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => + material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], ), ], - ), - ], - ); - }, + ); + }, + ), + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 88bf852..179028a 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -338,9 +338,11 @@ class _MongoConnectionFormContentState maxHeight: WindowLayout.connectionFormMongoMaxHeight, ), borderColor: theme.muted, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: Column( @@ -370,25 +372,27 @@ class _MongoConnectionFormContentState ), const material.Divider(height: 1), material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Connection string toggle - material.Row( - children: [ - material.Checkbox( - value: _useConnectionString, - onChanged: (v) { - setState(() => _useConnectionString = v ?? false); - _formValidNotifier.seed(); - }, - ), - const Gap(8), - const Text('Use connection string').small(), - ], - ), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Connection string toggle + material.Row( + children: [ + material.Checkbox( + value: _useConnectionString, + onChanged: (v) { + setState(() => _useConnectionString = v ?? false); + _formValidNotifier.seed(); + }, + ), + const Gap(8), + const Text('Use connection string').small(), + ], + ), const Gap(16), if (_useConnectionString) ...[ const Text('Connection String').small().semiBold(), @@ -578,6 +582,7 @@ class _MongoConnectionFormContentState ), ), ), + ), const material.Divider(height: 1), if (_testResult != null) material.Padding( @@ -645,60 +650,64 @@ class _MongoConnectionFormContentState ), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - child: ValueListenableBuilder( - valueListenable: _formValidNotifier.listenable, - builder: (context, formValid, _) { - return material.Row( - children: [ - OutlineButton( - onPressed: - formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Row( + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: formValid - ? theme.primary - : theme.mutedForeground, - ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: formValid - ? theme.primary - : theme.mutedForeground, + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), ), ), - ), - const material.Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: formValid ? _save : null, - child: const Text('Save'), - ), - ], - ); - }, + const material.Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], + ); + }, + ), ), ), ], ), + ), ); } } diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 47f573f..8cb7cfd 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -303,9 +303,11 @@ class _MysqlConnectionFormContentState maxHeight: WindowLayout.connectionFormMaxHeight, ), borderColor: theme.muted, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: material.Column( @@ -345,14 +347,16 @@ class _MysqlConnectionFormContentState ), const material.Divider(height: 1), material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Connection Name').small().semiBold(), - const Gap(8), - TextField( + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Connection Name').small().semiBold(), + const Gap(8), + TextField( controller: _nameController, placeholder: const Text('My MySQL Server'), ), @@ -472,16 +476,17 @@ class _MysqlConnectionFormContentState const Text('Use SSL/TLS').small(), ], ), - if (_useSSL) ...[ - const Gap(16), - SslCertificateFields( - rootCertController: _sslRootCertController, - clientCertController: _sslCertController, - clientKeyController: _sslKeyController, - onChanged: _syncUriSslParams, - ), + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], ], - ], + ), ), ), ), @@ -552,68 +557,72 @@ class _MysqlConnectionFormContentState ), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - child: ValueListenableBuilder( - valueListenable: _formValidNotifier.listenable, - builder: (context, formValid, _) { - return material.Wrap( - spacing: 12, - runSpacing: 8, - alignment: material.WrapAlignment.spaceBetween, - children: [ - OutlineButton( - onPressed: - formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: formValid - ? theme.primary - : theme.mutedForeground, - ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: formValid - ? theme.primary - : theme.mutedForeground, + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), ), ), - ), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - GhostButton( - onPressed: () => - material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: formValid ? _save : null, - child: const Text('Save'), - ), - ], - ), - ], - ); - }, + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => + material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], + ), + ], + ); + }, + ), ), ), ], ), + ), ); } } diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 4442e66..2d35191 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -420,9 +420,11 @@ class _PostgresConnectionFormContentState maxHeight: WindowLayout.connectionFormMaxHeight, ), borderColor: theme.muted, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ // Header material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), @@ -464,168 +466,199 @@ class _PostgresConnectionFormContentState const material.Divider(height: 1), // Form body material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Connection Name - const Text('Connection Name').small().semiBold(), - const Gap(8), - TextField( - controller: _nameController, - placeholder: const Text('My PostgreSQL Server'), - ), - const Gap(16), - const Text('Connection URI (optional)').small().semiBold(), - const Gap(4), - const Text( - 'If set, overrides Host / Port / Database below.', - ).muted().small(), - const Gap(4), - const Text( - 'Supported query params include sslmode (disable, require, ' - 'verify-ca, verify-full), connect_timeout and query_timeout ' - '(seconds). If sslmode is omitted, Use SSL/TLS below applies.', - ).muted().small(), - const Gap(8), - TextField( - controller: _connectionStringController, - placeholder: const Text( - 'postgresql://user:pass@host:5432/dbname?sslmode=require', + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Connection Name + const Text('Connection Name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('My PostgreSQL Server'), ), - ), - const Gap(16), - // Host + Port - material.Row( - children: [ - material.Expanded( - flex: 3, + const Gap(16), + const Text('Connection URI (optional)').small().semiBold(), + const Gap(4), + const Text( + 'If set, overrides Host / Port / Database below.', + ).muted().small(), + const Gap(4), + const Text( + 'Supported query params include sslmode (disable, require, ' + 'verify-ca, verify-full), connect_timeout and query_timeout ' + '(seconds). If sslmode is omitted, Use SSL/TLS below applies.', + ).muted().small(), + const Gap(8), + TextField( + controller: _connectionStringController, + placeholder: const Text( + 'postgresql://user:pass@host:5432/dbname?sslmode=require', + ), + ), + const Gap(16), + // Host and Port Row + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + flex: 3, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + children: [ + const Text('Host').small().semiBold(), + const Gap(8), + TextField( + controller: _hostController, + placeholder: const Text('localhost'), + ), + ], + ), + ), + const Gap(16), + material.Expanded( + flex: 2, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + children: [ + const Text('Port').small().semiBold(), + const Gap(8), + TextField( + controller: _portController, + placeholder: const Text('5432'), + ), + ], + ), + ), + ], + ), + const Gap(16), + // Database + const Text('Database').small().semiBold(), + const Gap(8), + TextField( + controller: _databaseController, + placeholder: const Text('postgres'), + ), + const Gap(16), + // Username and Password Row + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + children: [ + const Text('Username').small().semiBold(), + const Gap(8), + TextField( + controller: _usernameController, + placeholder: const Text('postgres'), + ), + ], + ), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + children: [ + const Text('Password').small().semiBold(), + const Gap(8), + material.Stack( + children: [ + TextField( + controller: _passwordController, + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), + obscureText: !_showPassword, + ), + material.Positioned( + right: 8, + top: 0, + bottom: 0, + child: material.Center( + child: material.IconButton( + icon: material.Icon( + _showPassword + ? material.Icons.visibility_off + : material.Icons.visibility, + size: 20, + ), + onPressed: () => setState( + () => _showPassword = !_showPassword), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + const Gap(16), + // SSL/TLS Toggle + material.Row( + children: [ + material.Checkbox( + value: _useSSL, + onChanged: (v) => + setState(() => _useSSL = v ?? false), + ), + const Gap(8), + const Text('Use SSL/TLS').small(), + ], + ), + if (_useSSL) ...[ + const Gap(16), + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, children: [ - const Text('Host').small().semiBold(), + const Text('SSL Certificates (optional)') + .small() + .semiBold(), + const Gap(4), + const Text( + 'Root CA, client certificate, and client key are ' + 'appended to the connection URI.', + ).muted().small(), const Gap(8), - TextField( - controller: _hostController, - placeholder: const Text('localhost'), + _buildSslFileField( + label: 'Root CA / SSL Root Certificate', + controller: _sslRootCertController, ), - ], - ), - ), - const Gap(12), - material.Expanded( - flex: 1, - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Port').small().semiBold(), const Gap(8), - TextField( - controller: _portController, - placeholder: const Text('5432'), + _buildSslFileField( + label: 'SSL Client Certificate', + controller: _sslCertController, ), - ], - ), - ), - ], - ), - const Gap(16), - // Database - const Text('Database').small().semiBold(), - const Gap(8), - TextField( - controller: _databaseController, - placeholder: const Text('postgres'), - ), - const Gap(16), - // Username - const Text('Username').small().semiBold(), - const Gap(8), - TextField( - controller: _usernameController, - placeholder: const Text('postgres'), - ), - const Gap(16), - // Password - const Text('Password').small().semiBold(), - const Gap(8), - material.Stack( - children: [ - TextField( - controller: _passwordController, - placeholder: Text( - _isEditing - ? 'Leave blank to keep existing' - : 'Password', - ), - obscureText: !_showPassword, - ), - material.Positioned( - right: 8, - top: 0, - bottom: 0, - child: material.Center( - child: material.IconButton( - icon: material.Icon( - _showPassword - ? material.Icons.visibility_off - : material.Icons.visibility, - size: 20, + const Gap(8), + _buildSslFileField( + label: 'SSL Client Key', + controller: _sslKeyController, ), - onPressed: () => setState( - () => _showPassword = !_showPassword), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(), - ), + ], ), ), ], - ), - const Gap(16), - // SSL toggle - material.Row( - children: [ - material.Checkbox( - value: _useSSL, - onChanged: (v) => - setState(() => _useSSL = v ?? false), - ), - const Gap(8), - const Text('Use SSL/TLS').small(), - ], - ), - if (_useSSL) ...[ - const Gap(16), - const Text('SSL Certificates (optional)') - .small() - .semiBold(), - const Gap(4), - const Text( - 'Root CA, client certificate, and client key are ' - 'appended to the connection URI.', - ).muted().small(), - const Gap(8), - _buildSslFileField( - label: 'Root CA / SSL Root Certificate', - controller: _sslRootCertController, - ), - const Gap(8), - _buildSslFileField( - label: 'SSL Client Certificate', - controller: _sslCertController, - ), - const Gap(8), - _buildSslFileField( - label: 'SSL Client Key', - controller: _sslKeyController, - ), ], - ], + ), ), ), ), @@ -698,68 +731,72 @@ class _PostgresConnectionFormContentState ), ), // Footer — Wrap avoids overflow on narrow viewports (e.g. in tests) - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - child: ValueListenableBuilder( - valueListenable: _formValidNotifier.listenable, - builder: (context, formValid, _) { - return material.Wrap( - spacing: 12, - runSpacing: 8, - alignment: material.WrapAlignment.spaceBetween, - children: [ - OutlineButton( - onPressed: - formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: formValid - ? theme.primary - : theme.mutedForeground, - ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: formValid - ? theme.primary - : theme.mutedForeground, + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), ), ), - ), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - GhostButton( - onPressed: () => - material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: formValid ? _save : null, - child: const Text('Save'), - ), - ], - ), - ], - ); - }, + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => + material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], + ), + ], + ); + }, + ), ), ), ], ), + ), ); } } diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index f006c45..4f98d43 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -277,9 +277,11 @@ class _RedisConnectionFormContentState maxHeight: WindowLayout.connectionFormMaxHeight, ), borderColor: theme.muted, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: material.Column( @@ -304,14 +306,16 @@ class _RedisConnectionFormContentState ), const material.Divider(height: 1), material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Connection Name').small().semiBold(), - const Gap(8), - TextField( + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Connection Name').small().semiBold(), + const Gap(8), + TextField( controller: _nameController, placeholder: const Text('My Redis Server'), ), @@ -435,6 +439,7 @@ class _RedisConnectionFormContentState ), ), ), + ), const material.Divider(height: 1), if (_testResult != null) material.Padding( @@ -502,60 +507,64 @@ class _RedisConnectionFormContentState ), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - child: ValueListenableBuilder( - valueListenable: _formValidNotifier.listenable, - builder: (context, formValid, _) { - return material.Row( - children: [ - OutlineButton( - onPressed: - formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Row( + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: formValid - ? theme.primary - : theme.mutedForeground, - ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: formValid - ? theme.primary - : theme.mutedForeground, + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), ), ), - ), - const material.Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: formValid ? _save : null, - child: const Text('Save'), - ), - ], - ); - }, + const material.Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], + ); + }, + ), ), ), ], ), + ), ); } } diff --git a/lib/shared/widgets/ssl_certificate_fields.dart b/lib/shared/widgets/ssl_certificate_fields.dart index 7b21480..cfadd96 100644 --- a/lib/shared/widgets/ssl_certificate_fields.dart +++ b/lib/shared/widgets/ssl_certificate_fields.dart @@ -19,35 +19,38 @@ class SslCertificateFields extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - return material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('SSL Certificates (optional)').small().semiBold(), - const Gap(4), - const Text( - 'Root CA, client certificate, and client key are appended to the ' - 'connection URI.', - ).muted().small(), - const Gap(8), - _SslFileField( - label: 'Root CA / SSL Root Certificate', - controller: rootCertController, - onChanged: onChanged, - ), - const Gap(8), - _SslFileField( - label: 'SSL Client Certificate', - controller: clientCertController, - onChanged: onChanged, - ), - const Gap(8), - _SslFileField( - label: 'SSL Client Key', - controller: clientKeyController, - onChanged: onChanged, - ), - ], + return material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('SSL Certificates (optional)').small().semiBold(), + const Gap(4), + const Text( + 'Root CA, client certificate, and client key are appended to the ' + 'connection URI.', + ).muted().small(), + const Gap(8), + _SslFileField( + label: 'Root CA / SSL Root Certificate', + controller: rootCertController, + onChanged: onChanged, + ), + const Gap(8), + _SslFileField( + label: 'SSL Client Certificate', + controller: clientCertController, + onChanged: onChanged, + ), + const Gap(8), + _SslFileField( + label: 'SSL Client Key', + controller: clientKeyController, + onChanged: onChanged, + ), + ], + ), ); } } diff --git a/test/features/connections/connection_dialog_focus_traversal_test.dart b/test/features/connections/connection_dialog_focus_traversal_test.dart new file mode 100644 index 0000000..fcc340e --- /dev/null +++ b/test/features/connections/connection_dialog_focus_traversal_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; +import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; +import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; +import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('Connection Dialogs Focus Traversal', () { + testWidgets('NewConnectionUrlDialog contains FocusTraversalGroups with WidgetOrderTraversalPolicy', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showNewConnectionUrlDialog(context), + child: const material.Text('Open URL Dialog'), + ), + ), + ), + ); + + await tester.tap(find.text('Open URL Dialog')); + await tester.pumpAndSettle(); + + final widgetOrderGroups = tester + .widgetList( + find.byType(material.FocusTraversalGroup), + ) + .where((g) => g.policy is material.WidgetOrderTraversalPolicy) + .toList(); + + expect(widgetOrderGroups.length, greaterThanOrEqualTo(2)); + }); + + testWidgets('showPostgresConnectionForm contains FocusTraversalGroups with WidgetOrderTraversalPolicy', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showPostgresConnectionForm(context), + child: const material.Text('Open Postgres Form'), + ), + ), + ), + ); + + await tester.tap(find.text('Open Postgres Form')); + await tester.pumpAndSettle(); + + final widgetOrderGroups = tester + .widgetList( + find.byType(material.FocusTraversalGroup), + ) + .where((g) => g.policy is material.WidgetOrderTraversalPolicy) + .toList(); + + expect(widgetOrderGroups.length, greaterThanOrEqualTo(3)); + }); + + testWidgets('SslCertificateFields is wrapped in FocusTraversalGroup with WidgetOrderTraversalPolicy', (tester) async { + final rootCertCtrl = TextEditingController(); + final clientCertCtrl = TextEditingController(); + final clientKeyCtrl = TextEditingController(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SslCertificateFields( + rootCertController: rootCertCtrl, + clientCertController: clientCertCtrl, + clientKeyController: clientKeyCtrl, + ), + ), + ), + ); + + final traversalGroup = find.descendant( + of: find.byType(SslCertificateFields), + matching: find.byType(material.FocusTraversalGroup), + ); + expect(traversalGroup, findsOneWidget); + + final groupWidget = tester.widget(traversalGroup); + expect(groupWidget.policy, isA()); + }); + }); +} From c4904417b55380daf61b4e011ba1b2e81da582a9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 11:54:23 +0300 Subject: [PATCH 14/47] sec(editor): add confirmation modal for destructive DDL and DML operations: DROP, TRUNCATE (#608) --- .../database/destructive_sql_detector.dart | 422 ++++++++++++++++++ lib/core/storage/app_settings.dart | 18 + .../main_screen/destructive_query_dialog.dart | 307 +++++++++++++ lib/features/mysql/mysql_sql_workspace.dart | 18 + .../postgresql/postgres_sql_workspace.dart | 19 + lib/features/settings/preferences_dialog.dart | 22 + lib/features/sqlite/sqlite_sql_workspace.dart | 18 + .../destructive_sql_detector_test.dart | 135 ++++++ .../destructive_query_dialog_test.dart | 119 +++++ 9 files changed, 1078 insertions(+) create mode 100644 lib/core/database/destructive_sql_detector.dart create mode 100644 lib/features/main_screen/destructive_query_dialog.dart create mode 100644 test/core/database/destructive_sql_detector_test.dart create mode 100644 test/features/main_screen/destructive_query_dialog_test.dart diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart new file mode 100644 index 0000000..2548693 --- /dev/null +++ b/lib/core/database/destructive_sql_detector.dart @@ -0,0 +1,422 @@ +/// Categorization of destructive SQL operations that can alter or destroy schema/data. +enum DestructiveSqlType { + dropDatabase, + dropSchema, + dropTable, + dropView, + dropMaterializedView, + truncateTable, + unconditionalDelete; + + String get label => switch (this) { + DestructiveSqlType.dropDatabase => 'DROP DATABASE', + DestructiveSqlType.dropSchema => 'DROP SCHEMA', + DestructiveSqlType.dropTable => 'DROP TABLE', + DestructiveSqlType.dropView => 'DROP VIEW', + DestructiveSqlType.dropMaterializedView => 'DROP MATERIALIZED VIEW', + DestructiveSqlType.truncateTable => 'TRUNCATE TABLE', + DestructiveSqlType.unconditionalDelete => 'UNCONDITIONAL DELETE', + }; + + String get riskLevel => switch (this) { + DestructiveSqlType.dropDatabase => 'CRITICAL', + DestructiveSqlType.dropSchema => 'HIGH', + DestructiveSqlType.dropTable => 'HIGH', + DestructiveSqlType.truncateTable => 'HIGH', + DestructiveSqlType.unconditionalDelete => 'HIGH', + DestructiveSqlType.dropMaterializedView => 'MEDIUM', + DestructiveSqlType.dropView => 'MEDIUM', + }; +} + +/// Represents a single detected destructive operation within an SQL script. +class DestructiveSqlOperation { + const DestructiveSqlOperation({ + required this.type, + required this.targetName, + required this.rawStatement, + }); + + final DestructiveSqlType type; + final String targetName; + final String rawStatement; + + String get description => switch (type) { + DestructiveSqlType.dropDatabase => + 'Permanently drops database "$targetName" and all contained schemas, tables, and records.', + DestructiveSqlType.dropSchema => + 'Permanently drops schema "$targetName" and all contained tables.', + DestructiveSqlType.dropTable => + 'Permanently drops table structure and all data in "$targetName".', + DestructiveSqlType.dropView => + 'Drops view "$targetName".', + DestructiveSqlType.dropMaterializedView => + 'Drops materialized view "$targetName".', + DestructiveSqlType.truncateTable => + 'Quickly deletes all rows from table "$targetName" without transaction rollbacks in some engines.', + DestructiveSqlType.unconditionalDelete => + 'Deletes all rows from table "$targetName" (no WHERE clause detected).', + }; +} + +/// Result of inspecting SQL text for destructive operations. +class DestructiveSqlInspectionResult { + const DestructiveSqlInspectionResult({ + required this.operations, + }); + + final List operations; + + bool get isDestructive => operations.isNotEmpty; + + /// Returns highest risk level present ('CRITICAL', 'HIGH', 'MEDIUM', or 'NONE'). + String get maxRiskLevel { + if (operations.isEmpty) return 'NONE'; + if (operations.any((o) => o.type.riskLevel == 'CRITICAL')) return 'CRITICAL'; + if (operations.any((o) => o.type.riskLevel == 'HIGH')) return 'HIGH'; + return 'MEDIUM'; + } +} + +/// Heuristic analyzer and sanitizer for detecting destructive SQL queries +/// before executing them in the SQL workspace. +abstract final class DestructiveSqlDetector { + static final _dropDatabaseRegex = RegExp( + r'^\s*DROP\s+DATABASE\s+(?:IF\s+EXISTS\s+)?(?:["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _dropSchemaRegex = RegExp( + r'^\s*DROP\s+SCHEMA\s+(?:IF\s+EXISTS\s+)?(?:["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _dropTableRegex = RegExp( + r'^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _dropMatViewRegex = RegExp( + r'^\s*DROP\s+MATERIALIZED\s+VIEW\s+(?:IF\s+EXISTS\s+)?(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _dropViewRegex = RegExp( + r'^\s*DROP\s+VIEW\s+(?:IF\s+EXISTS\s+)?(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _truncateRegex = RegExp( + r'^\s*TRUNCATE\s+(?:TABLE\s+)?(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + static final _deleteRegex = RegExp( + r'^\s*DELETE\s+FROM\s+(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + /// Strips comments and string literals to prevent false positives when keywords + /// appear inside strings or comments. + static String stripCommentsAndStrings(String sql) { + final buffer = StringBuffer(); + final len = sql.length; + var i = 0; + + while (i < len) { + // 1. Line comment: -- + if (i + 1 < len && sql[i] == '-' && sql[i + 1] == '-') { + i += 2; + while (i < len && sql[i] != '\n' && sql[i] != '\r') { + i++; + } + buffer.write(' '); + continue; + } + + // 2. Block comment: /* ... */ + if (i + 1 < len && sql[i] == '/' && sql[i + 1] == '*') { + i += 2; + while (i + 1 < len && !(sql[i] == '*' && sql[i + 1] == '/')) { + i++; + } + if (i + 1 < len) { + i += 2; // skip */ + } else { + i = len; + } + buffer.write(' '); + continue; + } + + // 3. Dollar quotes in PostgreSQL: $$ or $tag$ + if (sql[i] == '\$') { + final match = RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + if (match != null) { + final tag = match.group(0)!; + i += tag.length; + final closeIdx = sql.indexOf(tag, i); + if (closeIdx != -1) { + i = closeIdx + tag.length; + } else { + i = len; + } + buffer.write("''"); + continue; + } + } + + // 4. Standard string literal: '...' (supporting '' escaping) + if (sql[i] == "'") { + i++; + while (i < len) { + if (sql[i] == "'") { + if (i + 1 < len && sql[i + 1] == "'") { + i += 2; // escaped quote + } else { + i++; // closing quote + break; + } + } else if (sql[i] == '\\' && i + 1 < len) { + i += 2; // escaped char + } else { + i++; + } + } + buffer.write("''"); + continue; + } + + buffer.write(sql[i]); + i++; + } + + return buffer.toString(); + } + + /// Splits an SQL query into individual statements on `;`, taking into account + /// comments and string literals. + static List splitStatements(String sql) { + final statements = []; + final current = StringBuffer(); + final len = sql.length; + var i = 0; + + while (i < len) { + // Line comment + if (i + 1 < len && sql[i] == '-' && sql[i + 1] == '-') { + while (i < len && sql[i] != '\n' && sql[i] != '\r') { + current.write(sql[i]); + i++; + } + continue; + } + + // Block comment + if (i + 1 < len && sql[i] == '/' && sql[i + 1] == '*') { + current.write('/*'); + i += 2; + while (i + 1 < len && !(sql[i] == '*' && sql[i + 1] == '/')) { + current.write(sql[i]); + i++; + } + if (i + 1 < len) { + current.write('*/'); + i += 2; + } else { + i = len; + } + continue; + } + + // Dollar quotes + if (sql[i] == '\$') { + final match = RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + if (match != null) { + final tag = match.group(0)!; + current.write(tag); + i += tag.length; + final closeIdx = sql.indexOf(tag, i); + if (closeIdx != -1) { + current.write(sql.substring(i, closeIdx + tag.length)); + i = closeIdx + tag.length; + } else { + current.write(sql.substring(i)); + i = len; + } + continue; + } + } + + // String literal + if (sql[i] == "'") { + current.write("'"); + i++; + while (i < len) { + if (sql[i] == "'") { + current.write("'"); + if (i + 1 < len && sql[i + 1] == "'") { + current.write("'"); + i += 2; + } else { + i++; + break; + } + } else if (sql[i] == '\\' && i + 1 < len) { + current.write(sql[i]); + current.write(sql[i + 1]); + i += 2; + } else { + current.write(sql[i]); + i++; + } + } + continue; + } + + // Statement delimiter + if (sql[i] == ';') { + final stmt = current.toString().trim(); + if (stmt.isNotEmpty) { + statements.add(stmt); + } + current.clear(); + i++; + continue; + } + + current.write(sql[i]); + i++; + } + + final remaining = current.toString().trim(); + if (remaining.isNotEmpty) { + statements.add(remaining); + } + + return statements; + } + + /// Inspects [sql] and returns any detected destructive operations. + static DestructiveSqlInspectionResult inspect(String sql) { + final statements = splitStatements(sql); + final operations = []; + + for (final rawStmt in statements) { + final sanitized = stripCommentsAndStrings(rawStmt).trim(); + if (sanitized.isEmpty) continue; + + // 1. DROP DATABASE + final dropDbMatch = _dropDatabaseRegex.firstMatch(sanitized); + if (dropDbMatch != null) { + final target = dropDbMatch.group(1) ?? 'database'; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.dropDatabase, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 2. DROP SCHEMA + final dropSchemaMatch = _dropSchemaRegex.firstMatch(sanitized); + if (dropSchemaMatch != null) { + final target = dropSchemaMatch.group(1) ?? 'schema'; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.dropSchema, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 3. DROP MATERIALIZED VIEW + final dropMatViewMatch = _dropMatViewRegex.firstMatch(sanitized); + if (dropMatViewMatch != null) { + final schema = dropMatViewMatch.group(1); + final view = dropMatViewMatch.group(2) ?? 'view'; + final target = (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.dropMaterializedView, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 4. DROP VIEW + final dropViewMatch = _dropViewRegex.firstMatch(sanitized); + if (dropViewMatch != null) { + final schema = dropViewMatch.group(1); + final view = dropViewMatch.group(2) ?? 'view'; + final target = (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.dropView, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 5. DROP TABLE + final dropTableMatch = _dropTableRegex.firstMatch(sanitized); + if (dropTableMatch != null) { + final schema = dropTableMatch.group(1); + final table = dropTableMatch.group(2) ?? 'table'; + final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.dropTable, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 6. TRUNCATE + final truncateMatch = _truncateRegex.firstMatch(sanitized); + if (truncateMatch != null) { + final schema = truncateMatch.group(1); + final table = truncateMatch.group(2) ?? 'table'; + final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.truncateTable, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + continue; + } + + // 7. DELETE FROM table without WHERE + final deleteMatch = _deleteRegex.firstMatch(sanitized); + if (deleteMatch != null) { + final hasWhere = RegExp(r'\bWHERE\b', caseSensitive: false).hasMatch(sanitized); + if (!hasWhere) { + final schema = deleteMatch.group(1); + final table = deleteMatch.group(2) ?? 'table'; + final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.unconditionalDelete, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); + } + } + } + + return DestructiveSqlInspectionResult(operations: operations); + } +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 356b572..053241e 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -129,6 +129,7 @@ abstract final class AppSettingsKeys { static const checkForUpdatesOnStartup = 'check_for_updates_on_startup'; static const updateDismissedVersion = 'update_dismissed_version'; static const hasCompletedWelcomeTour = 'has_completed_welcome_tour'; + static const confirmDestructiveOperations = 'confirm_destructive_operations'; } /// Bumps [listenable] when any preference is persisted (theme, legacy listeners). @@ -238,6 +239,23 @@ class AppSettings { SqlWorkspaceSettingsRevision.bump(); } + /// Whether the SQL editor prompts for confirmation before executing DROP / TRUNCATE. + Future getConfirmDestructiveOperations() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.confirmDestructiveOperations, + ); + if (v == null || v.isEmpty) return true; + return v.toLowerCase() == 'true' || v == '1'; + } + + Future setConfirmDestructiveOperations(bool enable) async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.confirmDestructiveOperations, + enable.toString(), + ); + SqlWorkspaceSettingsRevision.bump(); + } + /// Global interface scale for typography and compact controls. Future getUiScale() async { final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.uiScale); diff --git a/lib/features/main_screen/destructive_query_dialog.dart b/lib/features/main_screen/destructive_query_dialog.dart new file mode 100644 index 0000000..30d59a3 --- /dev/null +++ b/lib/features/main_screen/destructive_query_dialog.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/shared/widgets/app_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Opens a confirmation dialog when destructive SQL statements (DROP, TRUNCATE, etc.) +/// are detected before execution. +/// +/// Returns `true` if the user confirmed execution, or `false`/`null` if cancelled. +Future showDestructiveQueryDialog({ + required material.BuildContext context, + required DestructiveSqlInspectionResult result, + required String sql, + String? connectionName, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _DestructiveQueryDialog( + result: result, + sql: sql, + connectionName: connectionName, + ), + ); +} + +class _DestructiveQueryDialog extends material.StatefulWidget { + const _DestructiveQueryDialog({ + required this.result, + required this.sql, + this.connectionName, + }); + + final DestructiveSqlInspectionResult result; + final String sql; + final String? connectionName; + + @override + material.State<_DestructiveQueryDialog> createState() => + _DestructiveQueryDialogState(); +} + +class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialog> { + bool _acknowledged = false; + bool _copied = false; + + Future _copySql() async { + await Clipboard.setData(ClipboardData(text: widget.sql)); + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _copied = false); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final isCritical = widget.result.maxRiskLevel == 'CRITICAL'; + + return material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide( + color: cs.destructive.withValues(alpha: isDark ? 0.6 : 0.4), + width: 1.5, + ), + ), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 540, + maxWidth: 680, + minHeight: 440, + maxHeight: 580, + ), + child: material.SizedBox( + height: 540, + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: cs.destructive.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(8), + ), + child: material.Icon( + material.Icons.warning_amber_rounded, + size: 24, + color: cs.destructive, + ), + ), + const Gap(12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text( + isCritical + ? 'Critical Destructive Operation' + : 'Destructive Operation Detected', + ).semiBold().large(), + const Gap(2), + if (widget.connectionName != null) + Text( + 'Target connection: ${widget.connectionName}', + ).muted().small() + else + const Text( + 'This statement will permanently alter or delete database objects.', + ).muted().small(), + ], + ), + ), + ], + ), + const Gap(16), + + // Detected operations list + material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: cs.destructive.withValues( + alpha: isDark ? 0.12 : 0.06, + ), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.destructive.withValues( + alpha: isDark ? 0.35 : 0.25, + ), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + for (final op in widget.result.operations) ...[ + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.destructive, + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + op.type.label, + style: const TextStyle( + color: material.Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const Gap(8), + material.Expanded( + child: Text( + op.description, + style: material.TextStyle( + fontSize: 12, + color: cs.foreground, + fontWeight: material.FontWeight.w500, + ), + ), + ), + ], + ), + if (op != widget.result.operations.last) const Gap(8), + ], + ], + ), + ), + const Gap(14), + + // SQL Script Preview Header + material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + children: [ + const Text('QUERY PREVIEW').semiBold().xSmall().muted(), + material.InkWell( + onTap: _copySql, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + _copied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: 13, + color: _copied + ? material.Colors.green + : cs.mutedForeground, + ), + const Gap(4), + Text(_copied ? 'Copied' : 'Copy SQL').xSmall(), + ], + ), + ), + ), + ], + ), + const Gap(6), + + // SQL Code block container + material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: isDark + ? const material.Color(0xFF141416) + : const material.Color(0xFFF4F4F6), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.6), + ), + ), + child: material.SingleChildScrollView( + child: material.SelectableText( + widget.sql, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.45, + color: isDark + ? const material.Color(0xFFE2E8F0) + : const material.Color(0xFF1E293B), + ), + ), + ), + ), + ), + const Gap(14), + + // Confirmation Checkbox + material.Row( + children: [ + material.Checkbox( + value: _acknowledged, + onChanged: (v) => setState(() => _acknowledged = v ?? false), + ), + const Gap(8), + material.Expanded( + child: material.GestureDetector( + onTap: () => setState(() => _acknowledged = !_acknowledged), + child: const Text( + 'I understand that this query cannot be undone and may result in permanent data loss.', + ).small(), + ), + ), + ], + ), + const Gap(16), + + // Action buttons + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.end, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: _acknowledged + ? () => material.Navigator.of(context).pop(true) + : null, + leading: const material.Icon( + material.Icons.delete_forever_rounded, + size: 16, + ), + child: const Text('Execute Destructive Statement'), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 62c9ce2..6107e38 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -6,6 +6,7 @@ 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/destructive_sql_detector.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/database/sql_table_target_extractor.dart'; @@ -16,6 +17,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -167,6 +169,22 @@ class _MysqlSqlWorkspaceState extends material.State { } if (userSql.isEmpty) return; + final confirmDestructive = + await AppSettings.instance.getConfirmDestructiveOperations(); + if (confirmDestructive) { + final inspection = DestructiveSqlDetector.inspect(userSql); + if (inspection.isDestructive) { + if (!mounted) return; + final confirmed = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: userSql, + connectionName: widget.connectionRow.name, + ); + if (confirmed != true) return; + } + } + setState(() { _running = true; _error = null; diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index b87f287..c889142 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -7,6 +7,7 @@ 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:postgres/postgres.dart' as pg; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; 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'; @@ -20,6 +21,7 @@ import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -313,6 +315,23 @@ class _PostgresSqlWorkspaceState extends material.State { userSql = _sqlController.text.trim(); } if (userSql.isEmpty) return; + + final confirmDestructive = + await AppSettings.instance.getConfirmDestructiveOperations(); + if (confirmDestructive) { + final inspection = DestructiveSqlDetector.inspect(userSql); + if (inspection.isDestructive) { + if (!mounted) return; + final confirmed = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: userSql, + connectionName: widget.connectionRow.name, + ); + if (confirmed != true) return; + } + } + var sql = injectSqlLimit(userSql, _resultMaxRows); setState(() { diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 6d2554c..6155346 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -32,6 +32,7 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogContent> { bool _loading = true; bool _checkUpdatesOnStartup = true; + bool _confirmDestructive = true; int? _pgTimeout; int? _mysqlTimeout; int _maxRows = kDefaultSqlResultMaxRows; @@ -46,6 +47,8 @@ class _PreferencesDialogContentState Future _load() async { final startup = await AppSettings.instance.getCheckForUpdatesOnStartup(); + final destructive = + await AppSettings.instance.getConfirmDestructiveOperations(); final pg = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); final my = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); @@ -54,6 +57,7 @@ class _PreferencesDialogContentState if (!mounted) return; setState(() { _checkUpdatesOnStartup = startup; + _confirmDestructive = destructive; _pgTimeout = pg; _mysqlTimeout = my; _maxRows = rows; @@ -68,6 +72,11 @@ class _PreferencesDialogContentState await AppSettings.instance.setCheckForUpdatesOnStartup(enabled); } + Future _setConfirmDestructive(bool enabled) async { + setState(() => _confirmDestructive = enabled); + await AppSettings.instance.setConfirmDestructiveOperations(enabled); + } + Future _setPg(int? v) async { setState(() => _pgTimeout = v); await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(v); @@ -267,6 +276,19 @@ class _PreferencesDialogContentState ], ), ), + const material.SizedBox(height: 12), + PreferencesCheckboxRow( + value: _confirmDestructive, + title: const Text( + 'Confirm destructive SQL operations', + ).small(), + subtitle: const Text( + 'Prompts before executing DROP, TRUNCATE, or unconditional DELETE queries.', + ).muted().xSmall(), + onChanged: (v) { + unawaited(_setConfirmDestructive(v)); + }, + ), const material.SizedBox(height: 16), const PreferencesHint( 'Preferences are stored locally in SQLite (non-secret keys only).', diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 04534f0..0f6e2dd 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -5,6 +5,7 @@ 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/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; @@ -15,6 +16,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -143,6 +145,22 @@ class _SqliteSqlWorkspaceState extends material.State { } if (userSql.isEmpty) return; + final confirmDestructive = + await AppSettings.instance.getConfirmDestructiveOperations(); + if (confirmDestructive) { + final inspection = DestructiveSqlDetector.inspect(userSql); + if (inspection.isDestructive) { + if (!mounted) return; + final confirmed = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: userSql, + connectionName: widget.connectionRow.name, + ); + if (confirmed != true) return; + } + } + setState(() { _running = true; _error = null; diff --git a/test/core/database/destructive_sql_detector_test.dart b/test/core/database/destructive_sql_detector_test.dart new file mode 100644 index 0000000..71b5b5a --- /dev/null +++ b/test/core/database/destructive_sql_detector_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; + +void main() { + group('DestructiveSqlDetector', () { + test('detects DROP DATABASE', () { + final res = DestructiveSqlDetector.inspect('DROP DATABASE production;'); + expect(res.isDestructive, isTrue); + expect(res.operations.length, 1); + expect(res.operations.first.type, DestructiveSqlType.dropDatabase); + expect(res.operations.first.targetName, 'production'); + expect(res.maxRiskLevel, 'CRITICAL'); + }); + + test('detects DROP DATABASE IF EXISTS with backticks', () { + final res = DestructiveSqlDetector.inspect('DROP DATABASE IF EXISTS `analytics_db`'); + expect(res.isDestructive, isTrue); + expect(res.operations.first.type, DestructiveSqlType.dropDatabase); + expect(res.operations.first.targetName, 'analytics_db'); + }); + + test('detects DROP SCHEMA', () { + final res = DestructiveSqlDetector.inspect('DROP SCHEMA public CASCADE;'); + expect(res.isDestructive, isTrue); + expect(res.operations.first.type, DestructiveSqlType.dropSchema); + expect(res.operations.first.targetName, 'public'); + }); + + test('detects DROP TABLE', () { + final res = DestructiveSqlDetector.inspect('DROP TABLE users;'); + expect(res.isDestructive, isTrue); + expect(res.operations.first.type, DestructiveSqlType.dropTable); + expect(res.operations.first.targetName, 'users'); + }); + + test('detects DROP TABLE with schema and quotes', () { + final res = DestructiveSqlDetector.inspect('DROP TABLE IF EXISTS "public"."orders";'); + expect(res.isDestructive, isTrue); + expect(res.operations.first.type, DestructiveSqlType.dropTable); + expect(res.operations.first.targetName, 'public.orders'); + }); + + test('detects DROP VIEW and DROP MATERIALIZED VIEW', () { + final viewRes = DestructiveSqlDetector.inspect('DROP VIEW monthly_report;'); + expect(viewRes.isDestructive, isTrue); + expect(viewRes.operations.first.type, DestructiveSqlType.dropView); + expect(viewRes.operations.first.targetName, 'monthly_report'); + + final matRes = DestructiveSqlDetector.inspect('DROP MATERIALIZED VIEW public.active_users;'); + expect(matRes.isDestructive, isTrue); + expect(matRes.operations.first.type, DestructiveSqlType.dropMaterializedView); + expect(matRes.operations.first.targetName, 'public.active_users'); + }); + + test('detects TRUNCATE and TRUNCATE TABLE', () { + final t1 = DestructiveSqlDetector.inspect('TRUNCATE TABLE session_logs;'); + expect(t1.isDestructive, isTrue); + expect(t1.operations.first.type, DestructiveSqlType.truncateTable); + expect(t1.operations.first.targetName, 'session_logs'); + + final t2 = DestructiveSqlDetector.inspect('TRUNCATE analytics.events;'); + expect(t2.isDestructive, isTrue); + expect(t2.operations.first.type, DestructiveSqlType.truncateTable); + expect(t2.operations.first.targetName, 'analytics.events'); + }); + + test('detects unconditional DELETE FROM', () { + final res = DestructiveSqlDetector.inspect('DELETE FROM users;'); + expect(res.isDestructive, isTrue); + expect(res.operations.first.type, DestructiveSqlType.unconditionalDelete); + expect(res.operations.first.targetName, 'users'); + }); + + test('does NOT mark DELETE with WHERE clause as unconditionalDelete', () { + final res = DestructiveSqlDetector.inspect('DELETE FROM users WHERE id = 123;'); + expect(res.isDestructive, isFalse); + }); + + test('handles multi-statement scripts containing destructive actions', () { + const sql = ''' + SELECT * FROM users WHERE active = true; + INSERT INTO audit_log VALUES (1, 'checking'); + DROP TABLE temp_import_data; + SELECT 1; + '''; + final res = DestructiveSqlDetector.inspect(sql); + expect(res.isDestructive, isTrue); + expect(res.operations.length, 1); + expect(res.operations.first.type, DestructiveSqlType.dropTable); + expect(res.operations.first.targetName, 'temp_import_data'); + }); + + test('ignores destructive keywords inside single-quoted strings', () { + final res = DestructiveSqlDetector.inspect("INSERT INTO logs (msg) VALUES ('DROP TABLE users;');"); + expect(res.isDestructive, isFalse); + }); + + test('ignores destructive keywords inside dollar-quoted strings', () { + final res = DestructiveSqlDetector.inspect(r''' + CREATE OR REPLACE FUNCTION clean_data() RETURNS void AS $$ + BEGIN + -- Some logic + END; + $$ LANGUAGE plpgsql; + '''); + expect(res.isDestructive, isFalse); + }); + + test('ignores destructive keywords inside line comments', () { + final res = DestructiveSqlDetector.inspect(''' + -- DROP TABLE users; + SELECT * FROM users; + '''); + expect(res.isDestructive, isFalse); + }); + + test('ignores destructive keywords inside block comments', () { + final res = DestructiveSqlDetector.inspect(''' + /* + * TRUNCATE TABLE orders; + * DROP DATABASE prod; + */ + SELECT count(*) FROM orders; + '''); + expect(res.isDestructive, isFalse); + }); + + test('returns non-destructive for regular queries', () { + expect(DestructiveSqlDetector.inspect('SELECT * FROM users').isDestructive, isFalse); + expect(DestructiveSqlDetector.inspect('CREATE TABLE items (id INT);').isDestructive, isFalse); + expect(DestructiveSqlDetector.inspect('ALTER TABLE users ADD COLUMN age INT;').isDestructive, isFalse); + expect(DestructiveSqlDetector.inspect('UPDATE users SET age = 20 WHERE id = 1;').isDestructive, isFalse); + }); + }); +} diff --git a/test/features/main_screen/destructive_query_dialog_test.dart b/test/features/main_screen/destructive_query_dialog_test.dart new file mode 100644 index 0000000..52e93f3 --- /dev/null +++ b/test/features/main_screen/destructive_query_dialog_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('DestructiveQueryDialog', () { + testWidgets('renders warning, detected operations, and disables confirm until acknowledged', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + bool? result; + + final inspection = DestructiveSqlDetector.inspect('DROP TABLE legacy_users;'); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: 'DROP TABLE legacy_users;', + connectionName: 'Production PostgreSQL', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Destructive Operation Detected'), findsOneWidget); + expect(find.text('Target connection: Production PostgreSQL'), findsOneWidget); + expect(find.text('DROP TABLE'), findsOneWidget); + expect(find.text('DROP TABLE legacy_users;'), findsOneWidget); + expect(find.text('Execute Destructive Statement'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + + // Confirm button is disabled when checkbox is unchecked + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(result, isNull); + + // Check acknowledgment checkbox + await tester.tap(find.byType(material.Checkbox)); + await tester.pumpAndSettle(); + + // Now clicking confirm returns true and dismisses dialog + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(result, isTrue); + }); + + testWidgets('shows Critical header for DROP DATABASE', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + + final inspection = DestructiveSqlDetector.inspect('DROP DATABASE customer_records;'); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showDestructiveQueryDialog( + context: context, + result: inspection, + sql: 'DROP DATABASE customer_records;', + ), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Critical Destructive Operation'), findsOneWidget); + expect(find.text('DROP DATABASE'), findsOneWidget); + }); + + testWidgets('Cancel button dismisses dialog with false', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + bool? result; + + final inspection = DestructiveSqlDetector.inspect('TRUNCATE logs;'); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: 'TRUNCATE logs;', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + }); + }); +} From 248280356e8c8ddea820f0ff373166b841c12f73 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 11:57:37 +0300 Subject: [PATCH 15/47] sec(storage): implement in-memory secret scrubbing for database passwords and credentials (#607) --- lib/core/database/mongodb_connection.dart | 60 +++++-- lib/core/database/mysql_connection.dart | 55 +++++-- lib/core/database/postgres_connection.dart | 50 ++++-- lib/core/database/redis_connection.dart | 45 ++++-- lib/core/storage/local_db.dart | 17 ++ test/core/storage/secret_scrubbing_test.dart | 157 +++++++++++++++++++ 6 files changed, 332 insertions(+), 52 deletions(-) create mode 100644 test/core/storage/secret_scrubbing_test.dart diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index a6d7812..e648292 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; /// MongoDB connection configuration and state. @@ -10,42 +11,54 @@ class MongoConnection { required this.host, this.port = 27017, this.username, - this.password, + String? password, this.database, this.authSource, this.useSSL = false, this.replicaSet, - this.connectionString, - }); + String? connectionString, + }) : _password = password, + _connectionString = connectionString; final int id; final String name; final String host; final int port; final String? username; - final String? password; + String? _password; final String? database; final String? authSource; final bool useSSL; final String? replicaSet; - final String? connectionString; + String? _connectionString; + + String? get password => _password; + String? get connectionString => _connectionString; Db? _db; bool _isConnected = false; + /// Scrubs sensitive in-memory credentials once the network handshake completes. + void scrubCredentials() { + _password = null; + _connectionString = null; + } + /// Builds MongoDB connection URI from configuration. - String buildConnectionUri() { - if (connectionString != null && connectionString!.isNotEmpty) { - return connectionString!; + String buildConnectionUri({String? pass, String? connStr}) { + final effectiveConnStr = connStr ?? _connectionString; + if (effectiveConnStr != null && effectiveConnStr.isNotEmpty) { + return effectiveConnStr; } final buffer = StringBuffer('mongodb://'); // Add authentication if provided + final effectivePass = pass ?? _password; if (username != null && username!.isNotEmpty) { buffer.write(Uri.encodeComponent(username!)); - if (password != null && password!.isNotEmpty) { - buffer.write(':${Uri.encodeComponent(password!)}'); + if (effectivePass != null && effectivePass.isNotEmpty) { + buffer.write(':${Uri.encodeComponent(effectivePass)}'); } buffer.write('@'); } @@ -86,8 +99,8 @@ class MongoConnection { /// exists, the method automatically adds `authSource=` (defaults /// to `admin`) so that authentication succeeds on databases other than the /// one the user was created in. - String buildUriForDatabase(String databaseName) { - final baseUri = buildConnectionUri(); + String buildUriForDatabase(String databaseName, {String? pass, String? connStr}) { + final baseUri = buildConnectionUri(pass: pass, connStr: connStr); final uri = Uri.parse(baseUri); // Determine the authSource that should be used. @@ -120,11 +133,28 @@ class MongoConnection { return; } + var effectivePassword = _password; + var effectiveConnectionString = _connectionString; + + if ((effectivePassword == null || effectivePassword.isEmpty) && + (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + id > 0) { + try { + final secrets = await ConnectionSecretsStore.readForConnection(id); + effectivePassword = secrets.password; + effectiveConnectionString = secrets.connectionString; + } catch (_) {} + } + try { - final uri = await _effectiveMongoUri(); + final uri = await _effectiveMongoUri( + pass: effectivePassword, + connStr: effectiveConnectionString, + ); _db = await Db.create(uri); await _db!.open(); _isConnected = true; + scrubCredentials(); } catch (e) { _isConnected = false; _db = null; @@ -132,8 +162,8 @@ class MongoConnection { } } - Future _effectiveMongoUri() async { - final base = buildConnectionUri(); + Future _effectiveMongoUri({String? pass, String? connStr}) async { + final base = buildConnectionUri(pass: pass, connStr: connStr); final parsed = Uri.parse(base); final paths = extractSslCertificatePaths(parsed); final params = Map.from(parsed.queryParameters); diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 0eb63f6..d974bf0 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; @@ -38,11 +39,12 @@ class MysqlConnection { required this.host, this.port = 3306, this.username, - this.password, + String? password, this.database, this.useSSL = true, - this.connectionString, - }); + String? connectionString, + }) : _password = password, + _connectionString = connectionString; factory MysqlConnection.fromConnectionRow( ConnectionRow row, { @@ -66,20 +68,27 @@ class MysqlConnection { final String host; final int port; final String? username; - final String? password; + String? _password; final String? database; final bool useSSL; - final String? connectionString; + String? _connectionString; + + String? get password => _password; + String? get connectionString => _connectionString; MySQLConnection? _conn; bool _isConnected = false; bool get isConnected => _isConnected && _conn != null; - bool get _usesConnectionString => - connectionString != null && connectionString!.trim().isNotEmpty; - + /// Scrubs sensitive in-memory credentials once the network handshake completes. + void scrubCredentials() { + _password = null; + _connectionString = null; + } + bool _usesConnectionString(String? connStr) => + connStr != null && connStr.trim().isNotEmpty; /// MySQL identifier quoting (backticks). static String quoteIdentifier(String id) { @@ -88,17 +97,31 @@ class MysqlConnection { Future connect({int connectTimeoutMs = 10000}) async { if (_isConnected && _conn != null) return; + + var effectivePassword = _password; + var effectiveConnectionString = _connectionString; + + if ((effectivePassword == null || effectivePassword.isEmpty) && + (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + id > 0) { + try { + final secrets = await ConnectionSecretsStore.readForConnection(id); + effectivePassword = secrets.password; + effectiveConnectionString = secrets.connectionString; + } catch (_) {} + } + try { final user = username ?? ''; - final pass = password ?? ''; - if (_usesConnectionString) { + final pass = effectivePassword ?? ''; + if (_usesConnectionString(effectiveConnectionString)) { final dbName = database; final uriStr = dbName != null && dbName.isNotEmpty ? replaceDatabaseInMysqlConnectionString( - connectionString!.trim(), + effectiveConnectionString!.trim(), dbName, ) - : connectionString!.trim(); + : effectiveConnectionString!.trim(); final parsed = _parseMysqlUri(uriStr, fallbackSsl: useSSL); final sslPaths = extractSslCertificatePathsFromString(uriStr); final securityContext = buildSecurityContext(sslPaths); @@ -113,21 +136,21 @@ class MysqlConnection { ); await _conn!.connect(timeoutMs: connectTimeoutMs); } else { - final securityContext = buildSecurityContext( - extractSslCertificatePathsFromString(connectionString), - ); + final sslPaths = extractSslCertificatePathsFromString(effectiveConnectionString); + final securityContext = buildSecurityContext(sslPaths); _conn = await MySQLConnection.createConnection( host: host, port: port, userName: user, password: pass, - secure: useSSL, + secure: useSSL || sslPaths.hasAny, databaseName: database, securityContext: securityContext, ); await _conn!.connect(timeoutMs: connectTimeoutMs); } _isConnected = true; + scrubCredentials(); } catch (e) { _isConnected = false; _conn = null; diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index bd80717..3a79f06 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -3,6 +3,7 @@ import 'dart:io' show SecurityContext; import 'package:flutter/foundation.dart'; import 'package:postgres/postgres.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; // ignore: implementation_imports @@ -44,14 +45,15 @@ class PostgresConnection { required this.host, this.port = 5432, this.username, - this.password, + String? password, this.database, this.useSSL = false, - this.connectionString, + String? connectionString, this.sslRootCert, this.sslCert, this.sslKey, - }); + }) : _password = password, + _connectionString = connectionString; /// Builds a connection from a saved [ConnectionRow] (host/port or URI). factory PostgresConnection.fromConnectionRow( @@ -91,29 +93,38 @@ class PostgresConnection { final String host; final int port; final String? username; - final String? password; + String? _password; final String? database; final bool useSSL; - final String? connectionString; + String? _connectionString; final String? sslRootCert; final String? sslCert; final String? sslKey; + String? get password => _password; + String? get connectionString => _connectionString; + Connection? _conn; bool _isConnected = false; bool get isConnected => _isConnected && _conn != null; - bool get _usesConnectionString => - connectionString != null && connectionString!.trim().isNotEmpty; + /// Scrubs sensitive in-memory credentials once the network handshake completes. + void scrubCredentials() { + _password = null; + _connectionString = null; + } - Endpoint _buildEndpoint() { + bool _usesConnectionString(String? connStr) => + connStr != null && connStr.trim().isNotEmpty; + + Endpoint _buildEndpoint({String? pass}) { return Endpoint( host: host, port: port, database: database ?? 'postgres', username: username, - password: password, + password: pass ?? _password, ); } @@ -148,14 +159,28 @@ class PostgresConnection { /// form checkbox still applies; otherwise libpq-style URLs drive TLS mode. Future connect() async { if (_isConnected && _conn != null) return; + + var effectivePassword = _password; + var effectiveConnectionString = _connectionString; + + if ((effectivePassword == null || effectivePassword.isEmpty) && + (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + id > 0) { + try { + final secrets = await ConnectionSecretsStore.readForConnection(id); + effectivePassword = secrets.password; + effectiveConnectionString = secrets.connectionString; + } catch (_) {} + } + try { - if (_usesConnectionString) { + if (_usesConnectionString(effectiveConnectionString)) { // Pool passes target catalog via [database]; URI alone would always open // the DB embedded in the string — every tree branch then queried the // same database (duplicate tables under finance / logistics, etc.). final dbName = database ?? 'postgres'; final uriForOpen = replaceDatabaseInConnectionString( - connectionString!.trim(), + effectiveConnectionString!.trim(), dbName, ); final parsed = parseConnectionString(uriForOpen); @@ -176,11 +201,12 @@ class PostgresConnection { ); } else { _conn = await Connection.open( - _buildEndpoint(), + _buildEndpoint(pass: effectivePassword), settings: _buildSettings(), ); } _isConnected = true; + scrubCredentials(); } catch (e, st) { _isConnected = false; _conn = null; diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index f7f8fbe..da4064b 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:redis/redis.dart' as redis; @@ -13,10 +14,11 @@ class RedisConnection { required this.host, this.port = 6379, this.username, - this.password, + String? password, this.useSSL = false, - this.connectionString, - }); + String? connectionString, + }) : _password = password, + _connectionString = connectionString; factory RedisConnection.fromConnectionRow(ConnectionRow row) { final uriText = row.connectionString?.trim(); @@ -62,9 +64,12 @@ class RedisConnection { final String host; final int port; final String? username; - final String? password; + String? _password; final bool useSSL; - final String? connectionString; + String? _connectionString; + + String? get password => _password; + String? get connectionString => _connectionString; redis.RedisConnection? _conn; redis.Command? _command; @@ -72,10 +77,31 @@ class RedisConnection { bool get isConnected => _isConnected && _command != null; + /// Scrubs sensitive in-memory credentials once the network handshake completes. + void scrubCredentials() { + _password = null; + _connectionString = null; + } + Future connect() async { if (_isConnected && _command != null) return; + + var effectivePassword = _password; + var effectiveConnectionString = _connectionString; + + if ((effectivePassword == null || effectivePassword.isEmpty) && + (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + id > 0) { + try { + final secrets = await ConnectionSecretsStore.readForConnection(id); + effectivePassword = secrets.password; + effectiveConnectionString = secrets.connectionString; + } catch (_) {} + } + _conn = redis.RedisConnection(); - final sslPaths = extractSslCertificatePathsFromString(connectionString); + final sslPaths = + extractSslCertificatePathsFromString(effectiveConnectionString); final secure = useSSL || sslPaths.hasAny; if (secure) { final context = buildSecurityContext(sslPaths); @@ -88,11 +114,11 @@ class RedisConnection { } else { _command = await _conn!.connect(host, port); } - if (password != null && password!.isNotEmpty) { + if (effectivePassword != null && effectivePassword.isNotEmpty) { if (username != null && username!.trim().isNotEmpty) { - await _command!.send_object(['AUTH', username!.trim(), password!]); + await _command!.send_object(['AUTH', username!.trim(), effectivePassword]); } else { - await _command!.send_object(['AUTH', password]); + await _command!.send_object(['AUTH', effectivePassword]); } } final result = await _command!.send_object(['PING']); @@ -103,6 +129,7 @@ class RedisConnection { throw RedisConnectionException('PING failed'); } _isConnected = true; + scrubCredentials(); } Future disconnect() async { diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 2ce7806..c611c62 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -709,4 +709,21 @@ class ConnectionRow { createdAt: createdAt ?? this.createdAt, ); } + + /// Whether in-memory password credentials are set. + bool get hasPassword => password != null && password!.isNotEmpty; + + /// Whether in-memory connection URI credentials are set. + bool get hasConnectionString => + connectionString != null && connectionString!.isNotEmpty; + + /// Whether any in-memory secret credentials are held. + bool get hasSecrets => hasPassword || hasConnectionString; + + /// Returns a clean copy of this [ConnectionRow] with all secret credentials + /// ([password] and [connectionString]) scrubbed to null. + ConnectionRow withoutSecrets() => copyWith( + clearPassword: true, + clearConnectionString: true, + ); } diff --git a/test/core/storage/secret_scrubbing_test.dart b/test/core/storage/secret_scrubbing_test.dart new file mode 100644 index 0000000..65c3681 --- /dev/null +++ b/test/core/storage/secret_scrubbing_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +void main() { + group('ConnectionRow secret scrubbing', () { + test('withoutSecrets clears password and connectionString while keeping all metadata', () { + const row = ConnectionRow( + id: 42, + type: 'postgresql', + name: 'Production DB', + host: 'db.example.com', + port: 5432, + username: 'admin', + password: 'super_secret_password_123', + databaseName: 'customers', + authSource: 'admin', + useSSL: true, + connectionString: 'postgresql://admin:super_secret_password_123@db.example.com/customers', + folderId: 3, + sortOrder: 1, + createdAt: '2026-08-26T12:00:00Z', + ); + + expect(row.hasPassword, isTrue); + expect(row.hasConnectionString, isTrue); + expect(row.hasSecrets, isTrue); + + final scrubbed = row.withoutSecrets(); + + expect(scrubbed.id, 42); + expect(scrubbed.type, 'postgresql'); + expect(scrubbed.name, 'Production DB'); + expect(scrubbed.host, 'db.example.com'); + expect(scrubbed.port, 5432); + expect(scrubbed.username, 'admin'); + expect(scrubbed.databaseName, 'customers'); + expect(scrubbed.authSource, 'admin'); + expect(scrubbed.useSSL, isTrue); + expect(scrubbed.folderId, 3); + expect(scrubbed.sortOrder, 1); + expect(scrubbed.createdAt, '2026-08-26T12:00:00Z'); + + // Secrets must be null + expect(scrubbed.password, isNull); + expect(scrubbed.connectionString, isNull); + expect(scrubbed.hasPassword, isFalse); + expect(scrubbed.hasConnectionString, isFalse); + expect(scrubbed.hasSecrets, isFalse); + }); + + test('toPersistenceMap does not include plaintext secrets for SQLite storage', () { + const row = ConnectionRow( + id: 1, + type: 'mysql', + name: 'App MySQL', + host: '127.0.0.1', + port: 3306, + username: 'root', + password: 'secret_root_password', + databaseName: 'app', + connectionString: 'mysql://root:secret_root_password@127.0.0.1:3306/app', + createdAt: '2026-08-26T12:00:00Z', + ); + + final map = row.toPersistenceMap(); + expect(map['password'], isNull); + expect(map['connection_string'], isNull); + expect(map['name'], 'App MySQL'); + expect(map['username'], 'root'); + }); + }); + + group('Database driver connection secret scrubbing', () { + test('PostgresConnection.scrubCredentials zeroes password and connectionString', () { + final conn = PostgresConnection( + id: 10, + name: 'PG Connection', + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'secret_pg_password', + connectionString: 'postgresql://postgres:secret_pg_password@localhost/postgres', + ); + + expect(conn.password, 'secret_pg_password'); + expect(conn.connectionString, contains('secret_pg_password')); + + conn.scrubCredentials(); + + expect(conn.password, isNull); + expect(conn.connectionString, isNull); + }); + + test('MysqlConnection.scrubCredentials zeroes password and connectionString', () { + final conn = MysqlConnection( + id: 11, + name: 'MySQL Connection', + host: 'localhost', + port: 3306, + username: 'user', + password: 'secret_mysql_password', + connectionString: 'mysql://user:secret_mysql_password@localhost/app', + ); + + expect(conn.password, 'secret_mysql_password'); + expect(conn.connectionString, contains('secret_mysql_password')); + + conn.scrubCredentials(); + + expect(conn.password, isNull); + expect(conn.connectionString, isNull); + }); + + test('RedisConnection.scrubCredentials zeroes password and connectionString', () { + final conn = RedisConnection( + id: 12, + name: 'Redis Connection', + host: 'localhost', + port: 6379, + password: 'secret_redis_auth', + connectionString: 'redis://:secret_redis_auth@localhost:6379', + ); + + expect(conn.password, 'secret_redis_auth'); + expect(conn.connectionString, contains('secret_redis_auth')); + + conn.scrubCredentials(); + + expect(conn.password, isNull); + expect(conn.connectionString, isNull); + }); + + test('MongoConnection.scrubCredentials zeroes password and connectionString', () { + final conn = MongoConnection( + id: 13, + name: 'Mongo Connection', + host: 'localhost', + port: 27017, + username: 'mongo_user', + password: 'secret_mongo_password', + connectionString: 'mongodb://mongo_user:secret_mongo_password@localhost:27017/admin', + ); + + expect(conn.password, 'secret_mongo_password'); + expect(conn.connectionString, contains('secret_mongo_password')); + + conn.scrubCredentials(); + + expect(conn.password, isNull); + expect(conn.connectionString, isNull); + }); + }); +} From 4292923c0db2dcd52b750edcee9a9f8a2502c175 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 12:00:16 +0300 Subject: [PATCH 16/47] feat(onboarding): expand welcome tour guide with 6 interactive steps and shortcuts matrix (#606) --- .../onboarding/welcome_tour_dialog.dart | 588 ++++++++++++------ .../onboarding/welcome_tour_dialog_test.dart | 48 +- 2 files changed, 417 insertions(+), 219 deletions(-) diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index e020216..a15536d 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -32,6 +32,75 @@ Future showWelcomeTourDialog( ); } +/// Visual keyboard keycap badge adapting to OS conventions (Cmd vs Ctrl). +class KbdBadge extends StatelessWidget { + const KbdBadge( + this.keys, { + super.key, + this.fontSize = 11, + }); + + final List keys; + final double fontSize; + + @override + Widget build(BuildContext context) { + final wb = context.workbench; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + for (var i = 0; i < keys.length; i++) ...[ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: isDark + ? const material.Color(0xFF1E2024) + : const material.Color(0xFFF1F5F9), + borderRadius: material.BorderRadius.circular(4), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.8), + ), + boxShadow: [ + material.BoxShadow( + color: material.Colors.black.withValues(alpha: isDark ? 0.3 : 0.08), + offset: const material.Offset(0, 1), + blurRadius: 1, + ), + ], + ), + child: Text( + keys[i], + style: TextStyle( + fontSize: fontSize, + fontWeight: material.FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.foreground, + ), + ), + ), + if (i < keys.length - 1) + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 3), + child: Text( + '+', + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: wb.mutedForeground, + ), + ), + ), + ], + ], + ); + } +} + class WelcomeTourDialog extends StatefulWidget { const WelcomeTourDialog({ super.key, @@ -60,7 +129,7 @@ class WelcomeTourDialog extends StatefulWidget { class _WelcomeTourDialogState extends State { int _currentStep = 0; - static const int _totalSteps = 4; + static const int _totalSteps = 6; @override void initState() { @@ -148,8 +217,8 @@ class _WelcomeTourDialogState extends State { child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, - maxWidth: 580, - minWidth: 420, + maxWidth: 640, + minWidth: 460, ), child: material.AnimatedSize( duration: const Duration(milliseconds: 240), @@ -162,154 +231,154 @@ class _WelcomeTourDialogState extends State { crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ // Header row - material.Row( - children: [ - material.Container( - padding: const material.EdgeInsets.all(8), - decoration: material.BoxDecoration( - color: wb.accent.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(10), - border: material.Border.all( - color: wb.accent.withValues(alpha: 0.3), + material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: wb.accent.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: wb.accent.withValues(alpha: 0.3), + ), + ), + child: material.Icon( + material.Icons.auto_awesome_rounded, + size: 20, + color: wb.accent, ), ), - child: material.Icon( - material.Icons.auto_awesome_rounded, - size: 20, - color: wb.accent, - ), - ), - const material.SizedBox(width: 12), - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Welcome to Querya').semiBold().large(), - Text('Step ${_currentStep + 1} of $_totalSteps') - .xSmall() - .muted(), - ], + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Welcome to Querya').semiBold().large(), + Text('Step ${_currentStep + 1} of $_totalSteps') + .xSmall() + .muted(), + ], + ), ), - ), - if (widget.onGoHome != null) ...[ - GhostButton( - key: const Key('welcome_tour_home_button'), - density: ButtonDensity.compact, - onPressed: _goHomeAndClose, - leading: const material.Icon( - material.Icons.home_rounded, - size: 16, + if (widget.onGoHome != null) ...[ + GhostButton( + key: const Key('welcome_tour_home_button'), + density: ButtonDensity.compact, + onPressed: _goHomeAndClose, + leading: const material.Icon( + material.Icons.home_rounded, + size: 16, + ), + child: const Text('Home'), ), - child: const Text('Home'), + const material.SizedBox(width: 4), + ], + material.IconButton( + icon: const material.Icon( + material.Icons.close_rounded, + size: 18, + ), + tooltip: 'Close', + splashRadius: 16, + onPressed: _finish, ), - const material.SizedBox(width: 4), ], - material.IconButton( - icon: const material.Icon( - material.Icons.close_rounded, - size: 18, - ), - tooltip: 'Close', - splashRadius: 16, - onPressed: _finish, + ), + const material.SizedBox(height: 18), + + // Animated Slide body + material.AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return material.FadeTransition( + opacity: animation, + child: material.SlideTransition( + position: material.Tween( + begin: const material.Offset(0.04, 0), + end: material.Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: _buildSlideContent( + step: _currentStep, + cmdCtrl: cmdCtrl, + cs: cs, + wb: wb, ), - ], - ), - const material.SizedBox(height: 18), - - // Animated Slide body - material.AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) { - return material.FadeTransition( - opacity: animation, - child: material.SlideTransition( - position: material.Tween( - begin: const material.Offset(0.04, 0), - end: material.Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: _buildSlideContent( - step: _currentStep, - cmdCtrl: cmdCtrl, - cs: cs, - wb: wb, ), - ), - const material.SizedBox(height: 20), - - // Footer controls & Step dots - material.Row( - children: [ - // Step indicator dots (clickable) - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: List.generate(_totalSteps, (index) { - final active = index == _currentStep; - return material.GestureDetector( - onTap: () => _goToStep(index), - behavior: material.HitTestBehavior.opaque, - child: material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 3, - vertical: 6, - ), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - width: active ? 22 : 6, - height: 6, - decoration: material.BoxDecoration( - color: active - ? wb.accent - : wb.mutedForeground.withValues(alpha: 0.3), - borderRadius: material.BorderRadius.circular(3), + const material.SizedBox(height: 20), + + // Footer controls & Step dots + material.Row( + children: [ + // Step indicator dots (clickable) + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: List.generate(_totalSteps, (index) { + final active = index == _currentStep; + return material.GestureDetector( + onTap: () => _goToStep(index), + behavior: material.HitTestBehavior.opaque, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 3, + vertical: 6, + ), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + width: active ? 22 : 6, + height: 6, + decoration: material.BoxDecoration( + color: active + ? wb.accent + : wb.mutedForeground.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(3), + ), ), ), - ), - ); - }), - ), - const material.Spacer(), - - // Action buttons - if (_currentStep > 0) - OutlineButton( - key: const Key('welcome_tour_prev_button'), - onPressed: _previous, - child: const Text('Back'), - ) - else - GhostButton( - key: const Key('welcome_tour_skip_button'), - onPressed: _finish, - child: const Text('Skip'), + ); + }), ), - const material.SizedBox(width: 8), - - PrimaryButton( - key: const Key('welcome_tour_next_button'), - onPressed: _next, - child: Text( - _currentStep == _totalSteps - 1 - ? 'Get Started' - : 'Next', + const material.Spacer(), + + // Action buttons + if (_currentStep > 0) + OutlineButton( + key: const Key('welcome_tour_prev_button'), + onPressed: _previous, + child: const Text('Back'), + ) + else + GhostButton( + key: const Key('welcome_tour_skip_button'), + onPressed: _finish, + child: const Text('Skip'), + ), + const material.SizedBox(width: 8), + + PrimaryButton( + key: const Key('welcome_tour_next_button'), + onPressed: _next, + child: Text( + _currentStep == _totalSteps - 1 + ? 'Get Started' + : 'Next', + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), - ), - ); -} + ); + } Widget _buildSlideContent({ required int step, @@ -327,7 +396,7 @@ class _WelcomeTourDialogState extends State { description: 'Connect directly to PostgreSQL, MySQL, SQLite, Redis, MongoDB or custom extension drivers. Paste a connection URL or use our visual form with real-time field validation.', tipText: 'Press $cmdCtrl+N anytime to create a new connection.', - shortcutBadge: '$cmdCtrl+N', + shortcutBadge: KbdBadge([cmdCtrl, 'N']), wb: wb, ); case 1: @@ -340,59 +409,56 @@ class _WelcomeTourDialogState extends State { 'Browse database schemas, tables, and views with full keyboard navigation. Right-click any object for instant SELECT queries and DDL copy.', tipText: 'Collapse the sidebar with $cmdCtrl+B for an expansive full-screen workspace.', - shortcutBadge: '$cmdCtrl+B', + shortcutBadge: KbdBadge([cmdCtrl, 'B']), wb: wb, ); case 2: return _TourSlideView( key: const ValueKey(2), + icon: material.Icons.code_rounded, + iconColor: const material.Color(0xFF3B82F6), // Blue + title: 'Pro SQL Editor & Workspaces', + description: + 'Execute scripts with $cmdCtrl+Enter or F5. Open multiple query tabs, format SQL with $cmdCtrl+Shift+F, and access persistent query history.', + tipText: + 'Use $cmdCtrl+T or $cmdCtrl+Shift+N to spawn a fresh SQL editor tab.', + shortcutBadge: KbdBadge(['F5', 'or', '$cmdCtrl+Enter']), + wb: wb, + ); + case 3: + return _TourSlideView( + key: const ValueKey(3), icon: material.Icons.table_chart_rounded, iconColor: const material.Color(0xFF10B981), // Emerald - title: 'Interactive SQL & 2D Grid', + title: 'Interactive 2D Grid & DML Staging', description: - 'Run queries instantly with $cmdCtrl+Enter. Sort results by clicking headers, drag column borders to resize, and Shift+Click to copy cell ranges in TSV format.', + 'Edit cells in-place, select ranges with Shift+Click, and copy TSV data with $cmdCtrl+C. Staged changes are safely previewed with DML inspection before committing.', tipText: 'Copied cell ranges paste directly into Google Sheets and Excel.', - shortcutBadge: '$cmdCtrl+Enter', + shortcutBadge: KbdBadge([cmdCtrl, 'C']), wb: wb, ); - case 3: - default: + case 4: return _TourSlideView( - key: const ValueKey(3), - icon: material.Icons.shield_rounded, + key: const ValueKey(4), + icon: material.Icons.filter_alt_rounded, iconColor: const material.Color(0xFFF59E0B), // Amber - title: 'Zero-Trust Security & Playground', + title: 'Compound Filter Bar & Quick Calc', description: - 'Database credentials stay protected inside your operating system secure keychain. Try our 1-click Demo Playground to explore Querya instantly without server setup.', + 'Filter grid results with live autocomplete expressions. Toggle Groupings & Pivot panels with $cmdCtrl+G and inspect instant stats (Sum, Avg, Median) in the status bar.', tipText: - 'Toggle Read-Only mode in the title bar for risk-free production exploration.', - shortcutBadge: 'Protected', - actionButtons: [ - if (widget.onLaunchDemo != null) - OutlineButton( - key: const Key('welcome_tour_demo_button'), - onPressed: _launchDemoAndClose, - leading: const material.Icon( - material.Icons.play_arrow_rounded, - size: 18, - ), - child: const Text('Try Demo Playground Now'), - ), - if (widget.onGoHome != null) ...[ - const material.SizedBox(height: 8), - GhostButton( - key: const Key('welcome_tour_slide_home_button'), - onPressed: _goHomeAndClose, - leading: const material.Icon( - material.Icons.home_rounded, - size: 16, - ), - child: const Text('Return to Start Screen'), - ), - ], - ], + 'Press $cmdCtrl+F to focus the compound filter bar on any active grid.', + shortcutBadge: KbdBadge([cmdCtrl, 'F']), + wb: wb, + ); + case 5: + default: + return _HotkeysMatrixSlideView( + key: const ValueKey(5), + cmdCtrl: cmdCtrl, wb: wb, + onLaunchDemo: widget.onLaunchDemo != null ? _launchDemoAndClose : null, + onGoHome: widget.onGoHome != null ? _goHomeAndClose : null, ); } } @@ -407,7 +473,6 @@ class _TourSlideView extends StatelessWidget { required this.description, required this.tipText, required this.shortcutBadge, - this.actionButtons, required this.wb, }); @@ -416,8 +481,7 @@ class _TourSlideView extends StatelessWidget { final String title; final String description; final String tipText; - final String shortcutBadge; - final List? actionButtons; + final Widget shortcutBadge; final QueryaWorkbenchTheme wb; @override @@ -468,27 +532,7 @@ class _TourSlideView extends StatelessWidget { children: [ Text(title).semiBold().base(), const material.SizedBox(height: 4), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: material.BoxDecoration( - color: wb.surface, - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: wb.borderSubtle.withValues(alpha: 0.6), - ), - ), - child: Text( - shortcutBadge, - style: TextStyle( - fontSize: 11, - fontWeight: material.FontWeight.w600, - color: wb.accent, - ), - ), - ), + shortcutBadge, ], ), ), @@ -552,11 +596,153 @@ class _TourSlideView extends StatelessWidget { ], ), ), + ], + ); + } +} - if (actionButtons != null && actionButtons!.isNotEmpty) ...[ - const material.SizedBox(height: 14), - ...actionButtons!, - ], +class _HotkeysMatrixSlideView extends StatelessWidget { + const _HotkeysMatrixSlideView({ + super.key, + required this.cmdCtrl, + required this.wb, + this.onLaunchDemo, + this.onGoHome, + }); + + final String cmdCtrl; + final QueryaWorkbenchTheme wb; + final material.VoidCallback? onLaunchDemo; + final material.VoidCallback? onGoHome; + + @override + Widget build(BuildContext context) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + // Header banner + material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: wb.canvas, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.5), + ), + ), + child: material.Row( + children: [ + material.Container( + width: 36, + height: 36, + decoration: material.BoxDecoration( + color: const material.Color(0xFFEC4899).withValues(alpha: 0.15), + shape: material.BoxShape.circle, + ), + child: const material.Icon( + material.Icons.keyboard_rounded, + size: 20, + color: material.Color(0xFFEC4899), + ), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Keyboard Shortcuts Cheat Sheet').semiBold().base(), + const Text('Master Querya like a pro with instant key bindings.') + .muted() + .xSmall(), + ], + ), + ), + ], + ), + ), + const material.SizedBox(height: 12), + + // Hotkeys Matrix Table + material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: wb.surface, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.6), + ), + ), + child: material.Column( + children: [ + _hotkeyRow(context, 'Run Query / Script', [cmdCtrl, 'Enter'], wb), + const material.Divider(height: 10), + _hotkeyRow(context, 'New Connection', [cmdCtrl, 'N'], wb), + const material.Divider(height: 10), + _hotkeyRow(context, 'Toggle Sidebar', [cmdCtrl, 'B'], wb), + const material.Divider(height: 10), + _hotkeyRow(context, 'Compound Filter Bar', [cmdCtrl, 'F'], wb), + const material.Divider(height: 10), + _hotkeyRow(context, 'Copy Selection (TSV)', [cmdCtrl, 'C'], wb), + const material.Divider(height: 10), + _hotkeyRow(context, 'Open Guide / Tour', ['F1'], wb), + ], + ), + ), + const material.SizedBox(height: 14), + + // Playground and Home buttons + material.Row( + children: [ + if (onLaunchDemo != null) + material.Expanded( + child: OutlineButton( + key: const Key('welcome_tour_demo_button'), + onPressed: onLaunchDemo, + leading: const material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + ), + child: const Text('Try Demo Playground'), + ), + ), + if (onLaunchDemo != null && onGoHome != null) + const material.SizedBox(width: 8), + if (onGoHome != null) + material.Expanded( + child: GhostButton( + key: const Key('welcome_tour_slide_home_button'), + onPressed: onGoHome, + leading: const material.Icon( + material.Icons.home_rounded, + size: 16, + ), + child: const Text('Start Screen'), + ), + ), + ], + ), + ], + ); + } + + material.Widget _hotkeyRow( + material.BuildContext context, + String label, + List keys, + QueryaWorkbenchTheme wb, + ) { + return material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.foreground, + fontWeight: material.FontWeight.w500, + ), + ), + KbdBadge(keys, fontSize: 10.5), ], ); } diff --git a/test/features/onboarding/welcome_tour_dialog_test.dart b/test/features/onboarding/welcome_tour_dialog_test.dart index 761c24d..74ae788 100644 --- a/test/features/onboarding/welcome_tour_dialog_test.dart +++ b/test/features/onboarding/welcome_tour_dialog_test.dart @@ -44,7 +44,7 @@ void main() { } }); - testWidgets('WelcomeTourDialog displays steps and allows navigation through all 4 slides', + testWidgets('WelcomeTourDialog displays steps and allows navigation through all 6 slides', (tester) async { await tester.pumpWidget( queryaThemeTestShell( @@ -57,7 +57,7 @@ void main() { // Slide 1: Connect in Seconds expect(find.text('Welcome to Querya'), findsOneWidget); - expect(find.text('Step 1 of 4'), findsOneWidget); + expect(find.text('Step 1 of 6'), findsOneWidget); expect(find.text('Connect in Seconds'), findsOneWidget); expect(find.byKey(const Key('welcome_tour_skip_button')), findsOneWidget); expect(find.byKey(const Key('welcome_tour_next_button')), findsOneWidget); @@ -66,7 +66,7 @@ void main() { await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); await tester.pumpAndSettle(); - expect(find.text('Step 2 of 4'), findsOneWidget); + expect(find.text('Step 2 of 6'), findsOneWidget); expect(find.text('Fluid Sidebar & Navigation'), findsOneWidget); expect(find.byKey(const Key('welcome_tour_prev_button')), findsOneWidget); @@ -74,23 +74,37 @@ void main() { await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); await tester.pumpAndSettle(); - expect(find.text('Step 3 of 4'), findsOneWidget); - expect(find.text('Interactive SQL & 2D Grid'), findsOneWidget); + expect(find.text('Step 3 of 6'), findsOneWidget); + expect(find.text('Pro SQL Editor & Workspaces'), findsOneWidget); // Click Next -> Slide 4 await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); await tester.pumpAndSettle(); - expect(find.text('Step 4 of 4'), findsOneWidget); - expect(find.text('Zero-Trust Security & Playground'), findsOneWidget); + expect(find.text('Step 4 of 6'), findsOneWidget); + expect(find.text('Interactive 2D Grid & DML Staging'), findsOneWidget); + + // Click Next -> Slide 5 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 5 of 6'), findsOneWidget); + expect(find.text('Compound Filter Bar & Quick Calc'), findsOneWidget); + + // Click Next -> Slide 6 (Hotkeys Matrix) + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 6 of 6'), findsOneWidget); + expect(find.text('Keyboard Shortcuts Cheat Sheet'), findsOneWidget); expect(find.text('Get Started'), findsOneWidget); - // Click Back -> Slide 3 + // Click Back -> Slide 5 await tester.tap(find.byKey(const Key('welcome_tour_prev_button'))); await tester.pumpAndSettle(); - expect(find.text('Step 3 of 4'), findsOneWidget); - expect(find.text('Interactive SQL & 2D Grid'), findsOneWidget); + expect(find.text('Step 5 of 6'), findsOneWidget); + expect(find.text('Compound Filter Bar & Quick Calc'), findsOneWidget); }); testWidgets('WelcomeTourDialog launches demo playground on action button tap', @@ -108,15 +122,13 @@ void main() { ); await tester.pumpAndSettle(); - // Advance to slide 4 - await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); - await tester.pumpAndSettle(); + // Advance to slide 6 (Playground & Hotkeys) + for (var i = 0; i < 5; i++) { + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + } - expect(find.text('Step 4 of 4'), findsOneWidget); + expect(find.text('Step 6 of 6'), findsOneWidget); expect(find.byKey(const Key('welcome_tour_demo_button')), findsOneWidget); await tester.tap(find.byKey(const Key('welcome_tour_demo_button'))); From a8b07066e8f4fe04d840860ac0c1ff6ebc24577f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 12:01:44 +0300 Subject: [PATCH 17/47] fix(test): remove unused import in connection_dialog_focus_traversal_test.dart (#610) --- .../connections/connection_dialog_focus_traversal_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/features/connections/connection_dialog_focus_traversal_test.dart b/test/features/connections/connection_dialog_focus_traversal_test.dart index fcc340e..50cc21d 100644 --- a/test/features/connections/connection_dialog_focus_traversal_test.dart +++ b/test/features/connections/connection_dialog_focus_traversal_test.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; From 298c51b67f8a1fd9e7a23668649005f9e7894c99 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 12:03:17 +0300 Subject: [PATCH 18/47] perf(memory): introduce string interning pool for low-cardinality query results (#604) --- .../database/result_row_string_convert.dart | 106 ++++++++++++++++-- .../result_row_string_convert_test.dart | 100 ++++++++++++++++- 2 files changed, 194 insertions(+), 12 deletions(-) diff --git a/lib/core/database/result_row_string_convert.dart b/lib/core/database/result_row_string_convert.dart index f77ef8b..90d8fab 100644 --- a/lib/core/database/result_row_string_convert.dart +++ b/lib/core/database/result_row_string_convert.dart @@ -3,16 +3,99 @@ import 'package:flutter/foundation.dart'; const int kResultStringConvertYieldEvery = 250; const int kResultStringConvertComputeThreshold = 1000; +/// High-performance string interning pool for deduplicating cell strings +/// across low-cardinality database columns (e.g. booleans, enums, status codes, IDs). +class StringInternPool { + StringInternPool({ + this.maxEntries = 4096, + this.maxStringLength = 128, + }) { + _pool.addAll(_preloaded); + } + + final int maxEntries; + final int maxStringLength; + + static const Map _preloaded = { + 'NULL': 'NULL', + 'true': 'true', + 'false': 'false', + '0': '0', + '1': '1', + '2': '2', + '3': '3', + '4': '4', + '5': '5', + '6': '6', + '7': '7', + '8': '8', + '9': '9', + '10': '10', + '': '', + 'active': 'active', + 'inactive': 'inactive', + 'pending': 'pending', + 'completed': 'completed', + 'success': 'success', + 'failed': 'failed', + 'error': 'error', + 'warning': 'warning', + 'info': 'info', + 'deleted': 'deleted', + 'draft': 'draft', + 'published': 'published', + }; + + final Map _pool = {}; + + int get size => _pool.length; + + /// Returns the canonical deduplicated instance of [value]. + String intern(String value) { + if (value.length > maxStringLength) { + return value; + } + final existing = _pool[value]; + if (existing != null) return existing; + + if (_pool.length < maxEntries) { + _pool[value] = value; + } + return value; + } + + /// Converts [value] to string and returns the interned canonical instance. + String internObject(Object? value) { + if (value == null) return 'NULL'; + if (value is String) return intern(value); + if (value is bool) return value ? 'true' : 'false'; + if (value is int && value >= 0 && value <= 10) { + return _preloaded[value.toString()] ?? value.toString(); + } + return intern(value.toString()); + } +} + /// Maps null cells to `'NULL'` and others via [Object.toString]. -String resultCellToDisplayString(Object? value) => - value == null ? 'NULL' : value.toString(); +String resultCellToDisplayString(Object? value, [StringInternPool? pool]) { + if (pool != null) { + return pool.internObject(value); + } + if (value == null) return 'NULL'; + if (value is bool) return value ? 'true' : 'false'; + return value.toString(); +} -/// Converts [rowValues] to string rows synchronously. -List> convertResultRowsToStringsSync(List> rowValues) { +/// Converts [rowValues] to string rows synchronously using a string interning pool. +List> convertResultRowsToStringsSync( + List> rowValues, { + StringInternPool? pool, +}) { if (rowValues.isEmpty) return const []; + final activePool = pool ?? StringInternPool(); return [ for (final row in rowValues) - [for (final value in row) resultCellToDisplayString(value)], + [for (final value in row) activePool.internObject(value)], ]; } @@ -20,18 +103,20 @@ List> convertResultRowsToStringsSync(List> rowValues) List> convertResultRowsToStringsCompute(List> rowValues) => convertResultRowsToStringsSync(rowValues); -/// Converts [rowValues] to string rows, yielding periodically. +/// Converts [rowValues] to string rows, yielding periodically and interning strings. Future>> convertResultRowsToStringsYielding( List> rowValues, { int yieldEvery = kResultStringConvertYieldEvery, + StringInternPool? pool, }) async { if (rowValues.isEmpty) return const []; + final activePool = pool ?? StringInternPool(); final out = >[]; for (var i = 0; i < rowValues.length; i++) { final row = rowValues[i]; out.add([ - for (final value in row) resultCellToDisplayString(value), + for (final value in row) activePool.internObject(value), ]); if (yieldEvery > 0 && (i + 1) % yieldEvery == 0) { await Future.delayed(Duration.zero); @@ -46,10 +131,15 @@ Future>> convertResultRowsToStringsAdaptive( List> rowValues, { int computeThreshold = kResultStringConvertComputeThreshold, int yieldEvery = kResultStringConvertYieldEvery, + StringInternPool? pool, }) async { if (rowValues.isEmpty) return const []; if (rowValues.length >= computeThreshold) { return compute(convertResultRowsToStringsCompute, rowValues); } - return convertResultRowsToStringsYielding(rowValues, yieldEvery: yieldEvery); + return convertResultRowsToStringsYielding( + rowValues, + yieldEvery: yieldEvery, + pool: pool, + ); } diff --git a/test/core/database/result_row_string_convert_test.dart b/test/core/database/result_row_string_convert_test.dart index 44d4253..cd9e380 100644 --- a/test/core/database/result_row_string_convert_test.dart +++ b/test/core/database/result_row_string_convert_test.dart @@ -2,6 +2,55 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; void main() { + group('StringInternPool', () { + test('deduplicates identical string instances', () { + final pool = StringInternPool(); + + // Create separate String instances dynamically + final s1 = String.fromCharCodes('active'.codeUnits); + final s2 = String.fromCharCodes('active'.codeUnits); + + expect(identical(s1, s2), isFalse); + + final interned1 = pool.intern(s1); + final interned2 = pool.intern(s2); + + expect(identical(interned1, interned2), isTrue); + }); + + test('preloads common database literals', () { + final pool = StringInternPool(); + + expect(identical(pool.internObject(null), 'NULL'), isTrue); + expect(identical(pool.internObject(true), 'true'), isTrue); + expect(identical(pool.internObject(false), 'false'), isTrue); + expect(identical(pool.internObject(0), '0'), isTrue); + expect(identical(pool.internObject(1), '1'), isTrue); + }); + + test('respects maxStringLength boundary', () { + final pool = StringInternPool(maxStringLength: 10); + const longStr = 'this_is_a_very_long_string_that_should_not_be_interned'; + + final res = pool.intern(longStr); + expect(res, longStr); + // Pool size should not increase for long string + final initialSize = pool.size; + pool.intern(longStr); + expect(pool.size, initialSize); + }); + + test('respects maxEntries capacity limit', () { + final pool = StringInternPool(maxEntries: 30); + + for (var i = 0; i < 50; i++) { + pool.intern('unique_key_$i'); + } + + expect(pool.size, lessThanOrEqualTo(30)); + }); + }); + group('result_row_string_convert', () { final sampleRows = >[ [1, null, 'a'], @@ -13,9 +62,24 @@ void main() { ['2', 'x', 'NULL'], ]; - test('convertResultRowsToStringsSync maps rows correctly', () { - expect(convertResultRowsToStringsSync(sampleRows), expectedOutput); - expect(convertResultRowsToStringsSync(const []), isEmpty); + test('convertResultRowsToStringsSync maps rows correctly and deduplicates repeated cells', () { + final rowsWithDuplicates = >[ + ['active', 1, true, 'US'], + ['active', 1, true, 'US'], + ['active', 2, false, 'EU'], + ]; + + final out = convertResultRowsToStringsSync(rowsWithDuplicates); + expect(out.length, 3); + expect(out[0], ['active', '1', 'true', 'US']); + expect(out[1], ['active', '1', 'true', 'US']); + expect(out[2], ['active', '2', 'false', 'EU']); + + // Deduplicated string references must be identical pointers + expect(identical(out[0][0], out[1][0]), isTrue); + expect(identical(out[0][1], out[1][1]), isTrue); + expect(identical(out[0][2], out[1][2]), isTrue); + expect(identical(out[0][3], out[1][3]), isTrue); }); test('convertResultRowsToStringsCompute maps rows correctly', () { @@ -54,6 +118,34 @@ void main() { expect(out[0], ['0', 'NULL', 'val_0']); expect(out[9], ['9', 'NULL', 'val_9']); }); + + test('benchmark 10,000 low-cardinality rows demonstrates pointer reuse', () { + final statuses = ['active', 'pending', 'cancelled', 'completed']; + final countries = ['US', 'DE', 'FR', 'GB', 'JP']; + + final dataset = List>.generate( + 10000, + (i) => [ + i % 10, + statuses[i % statuses.length], + countries[i % countries.length], + i % 2 == 0, + null, + ], + ); + + final stopwatch = Stopwatch()..start(); + final result = convertResultRowsToStringsSync(dataset); + stopwatch.stop(); + + expect(result.length, 10000); + expect(stopwatch.elapsedMilliseconds, lessThan(100)); + + // Pointer verification + expect(identical(result[0][1], result[4][1]), isTrue); + expect(identical(result[0][2], result[5][2]), isTrue); + expect(identical(result[0][3], result[2][3]), isTrue); + expect(identical(result[0][4], result[1][4]), isTrue); + }); }); } - From 7872e66178826f1f37b5216d873aae15e696fe04 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 12:12:21 +0300 Subject: [PATCH 19/47] feat(macos): implement native PlatformMenuBar for macOS system menu (#612) --- .../macos/querya_platform_menu_bar.dart | 344 ++++++++++++++++++ lib/features/main_screen/main_screen.dart | 39 +- .../macos/querya_platform_menu_bar_test.dart | 193 ++++++++++ 3 files changed, 570 insertions(+), 6 deletions(-) create mode 100644 lib/features/macos/querya_platform_menu_bar.dart create mode 100644 test/features/macos/querya_platform_menu_bar_test.dart diff --git a/lib/features/macos/querya_platform_menu_bar.dart b/lib/features/macos/querya_platform_menu_bar.dart new file mode 100644 index 0000000..5b77e42 --- /dev/null +++ b/lib/features/macos/querya_platform_menu_bar.dart @@ -0,0 +1,344 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../settings/preferences_dialog.dart'; + +/// Native macOS top menu bar integration via Flutter's [PlatformMenuBar]. +/// +/// Configures system-level application, file, edit, view, window, and help menus +/// with macOS-standard keyboard shortcuts (Command / Meta modifier). +class QueryaPlatformMenuBar extends StatelessWidget { + const QueryaPlatformMenuBar({ + super.key, + required this.child, + this.onNewConnection, + this.onNewQueryTab, + this.onOpenSqlScript, + this.onSaveQuery, + this.onExecuteQuery, + this.onCloseTab, + this.onToggleSidebar, + this.onOpenPreferences, + this.onOpenWelcomeTour, + this.onGoHome, + this.onFocusFilterBar, + this.onToggleGroupings, + }); + + final Widget child; + final VoidCallback? onNewConnection; + final VoidCallback? onNewQueryTab; + final VoidCallback? onOpenSqlScript; + final VoidCallback? onSaveQuery; + final VoidCallback? onExecuteQuery; + final VoidCallback? onCloseTab; + final VoidCallback? onToggleSidebar; + final VoidCallback? onOpenPreferences; + final VoidCallback? onOpenWelcomeTour; + final VoidCallback? onGoHome; + final VoidCallback? onFocusFilterBar; + final VoidCallback? onToggleGroupings; + + static const String repoUrl = 'https://github.com/QueryaHub/Querya-Desktop'; + static const String issuesUrl = + 'https://github.com/QueryaHub/Querya-Desktop/issues/new'; + + @override + Widget build(BuildContext context) { + return PlatformMenuBar( + menus: [ + // 1. Application Menu (macOS App Name) + PlatformMenu( + label: 'Querya', + menus: [ + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.about)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.about) + else + PlatformMenuItem( + label: 'About Querya', + onSelected: onOpenWelcomeTour, + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: 'Preferences...', + shortcut: const SingleActivator( + LogicalKeyboardKey.comma, + meta: true, + ), + onSelected: onOpenPreferences ?? + () => showPreferencesDialog(context), + ), + ], + ), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.hide)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.hide), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.hideOtherApplications)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.hideOtherApplications), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.showAllApplications)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.showAllApplications), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.quit)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.quit), + ], + ), + + // 2. File Menu + PlatformMenu( + label: 'File', + menus: [ + PlatformMenuItem( + label: 'New Connection...', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + ), + onSelected: onNewConnection, + ), + PlatformMenuItem( + label: 'New Query Tab', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + shift: true, + ), + onSelected: onNewQueryTab, + ), + PlatformMenuItem( + label: 'Open SQL Script...', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyO, + meta: true, + ), + onSelected: onOpenSqlScript, + ), + PlatformMenuItem( + label: 'Save Query', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyS, + meta: true, + ), + onSelected: onSaveQuery, + ), + if (onExecuteQuery != null) + PlatformMenuItem( + label: 'Run Query / Script', + shortcut: const SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ), + onSelected: onExecuteQuery, + ), + if (onCloseTab != null) + PlatformMenuItem( + label: 'Close Tab', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyW, + meta: true, + ), + onSelected: onCloseTab, + ), + ], + ), + + // 3. Edit Menu + PlatformMenu( + label: 'Edit', + menus: [ + PlatformMenuItem( + label: 'Undo', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + const UndoTextIntent(SelectionChangedCause.keyboard), + ); + }, + ), + PlatformMenuItem( + label: 'Redo', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + shift: true, + ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + const RedoTextIntent(SelectionChangedCause.keyboard), + ); + }, + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: 'Cut', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyX, + meta: true, + ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + const CopySelectionTextIntent.cut( + SelectionChangedCause.keyboard), + ); + }, + ), + const PlatformMenuItem( + label: 'Copy', + shortcut: SingleActivator( + LogicalKeyboardKey.keyC, + meta: true, + ), + ), + PlatformMenuItem( + label: 'Paste', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyV, + meta: true, + ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + const PasteTextIntent(SelectionChangedCause.keyboard), + ); + }, + ), + PlatformMenuItem( + label: 'Select All', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyA, + meta: true, + ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + const SelectAllTextIntent(SelectionChangedCause.keyboard), + ); + }, + ), + ], + ), + ], + ), + + // 4. View Menu + PlatformMenu( + label: 'View', + menus: [ + PlatformMenuItem( + label: 'Toggle Sidebar', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyB, + meta: true, + ), + onSelected: onToggleSidebar, + ), + if (onFocusFilterBar != null) + PlatformMenuItem( + label: 'Compound Filter Bar', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyF, + meta: true, + ), + onSelected: onFocusFilterBar, + ), + if (onToggleGroupings != null) + PlatformMenuItem( + label: 'Groupings & Pivot Drawer', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyG, + meta: true, + ), + onSelected: onToggleGroupings, + ), + PlatformMenuItem( + label: 'Return to Start Screen', + shortcut: const SingleActivator( + LogicalKeyboardKey.digit0, + meta: true, + shift: true, + ), + onSelected: onGoHome, + ), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.toggleFullScreen)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.toggleFullScreen), + ], + ), + + // 5. Window Menu + PlatformMenu( + label: 'Window', + menus: [ + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.minimizeWindow)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.minimizeWindow), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.zoomWindow)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.zoomWindow), + ], + ), + + // 6. Help Menu + PlatformMenu( + label: 'Help', + menus: [ + PlatformMenuItem( + label: 'Welcome Tour & Guide', + shortcut: const SingleActivator(LogicalKeyboardKey.f1), + onSelected: onOpenWelcomeTour, + ), + PlatformMenuItem( + label: 'Keyboard Shortcuts Cheat Sheet', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyH, + meta: true, + shift: true, + ), + onSelected: onOpenWelcomeTour, + ), + PlatformMenuItem( + label: 'Querya GitHub Repository', + onSelected: () => unawaited(launchUrl(Uri.parse(repoUrl))), + ), + PlatformMenuItem( + label: 'Report an Issue...', + onSelected: () => unawaited(launchUrl(Uri.parse(issuesUrl))), + ), + ], + ), + ], + child: child, + ); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index d13196c..0b83130 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -23,10 +23,12 @@ import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; -import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:querya_desktop/features/macos/querya_platform_menu_bar.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'main_screen_workspace_state.dart'; import 'workspace_panel.dart'; @@ -469,10 +471,34 @@ class _MainScreenState extends State { shift: true, ): _onGoHome, }, - child: SqlEditorGlobalActions( - activeConnection: workspace.activeConnection, - onOpenSqlWorkspace: _openSqlWorkspaceForConnection, - child: material.Scaffold( + child: QueryaPlatformMenuBar( + onNewConnection: () => + unawaited(_onNewDatabaseConnectionFromMenu()), + onNewQueryTab: () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const NewSqlIntent()); + }, + onOpenSqlScript: () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const OpenSqlIntent()); + }, + onSaveQuery: () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const SaveSqlIntent()); + }, + onExecuteQuery: () => + SqlEditorCommandBridge.instance.invokeExecute(), + onToggleSidebar: () => _splitKey.currentState?.toggleSidebar(), + onOpenPreferences: () => showPreferencesDialog(context), + onOpenWelcomeTour: () => _onOpenWelcomeTour(), + onGoHome: _onGoHome, + child: SqlEditorGlobalActions( + activeConnection: workspace.activeConnection, + onOpenSqlWorkspace: _openSqlWorkspaceForConnection, + child: material.Scaffold( backgroundColor: wb.canvas, body: WindowBorder( color: wb.borderSubtle.withValues(alpha: 0.35), @@ -565,6 +591,7 @@ class _MainScreenState extends State { ), ), ), + ), ), ), ); diff --git a/test/features/macos/querya_platform_menu_bar_test.dart b/test/features/macos/querya_platform_menu_bar_test.dart new file mode 100644 index 0000000..6212d22 --- /dev/null +++ b/test/features/macos/querya_platform_menu_bar_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/macos/querya_platform_menu_bar.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('QueryaPlatformMenuBar', () { + testWidgets('mounts PlatformMenuBar with standard macOS menu hierarchy', + (tester) async { + var newConnectionInvoked = false; + var newQueryTabInvoked = false; + var openSqlInvoked = false; + var saveQueryInvoked = false; + var executeQueryInvoked = false; + var toggleSidebarInvoked = false; + var preferencesInvoked = false; + var tourInvoked = false; + var goHomeInvoked = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaPlatformMenuBar( + onNewConnection: () => newConnectionInvoked = true, + onNewQueryTab: () => newQueryTabInvoked = true, + onOpenSqlScript: () => openSqlInvoked = true, + onSaveQuery: () => saveQueryInvoked = true, + onExecuteQuery: () => executeQueryInvoked = true, + onToggleSidebar: () => toggleSidebarInvoked = true, + onOpenPreferences: () => preferencesInvoked = true, + onOpenWelcomeTour: () => tourInvoked = true, + onGoHome: () => goHomeInvoked = true, + child: const material.Text('Main App Content'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Main App Content'), findsOneWidget); + + final platformMenuBarFinder = find.byType(PlatformMenuBar); + expect(platformMenuBarFinder, findsOneWidget); + + final platformMenuBar = + tester.widget(platformMenuBarFinder); + expect(platformMenuBar.menus.length, 6); + + // 1. App Menu (Querya) + final appMenu = platformMenuBar.menus[0] as PlatformMenu; + expect(appMenu.label, 'Querya'); + + // 2. File Menu + final fileMenu = platformMenuBar.menus[1] as PlatformMenu; + expect(fileMenu.label, 'File'); + final fileItems = fileMenu.menus.whereType().toList(); + expect(fileItems.any((m) => m.label == 'New Connection...'), isTrue); + expect(fileItems.any((m) => m.label == 'New Query Tab'), isTrue); + expect(fileItems.any((m) => m.label == 'Open SQL Script...'), isTrue); + expect(fileItems.any((m) => m.label == 'Save Query'), isTrue); + expect(fileItems.any((m) => m.label == 'Run Query / Script'), isTrue); + + // 3. Edit Menu + final editMenu = platformMenuBar.menus[2] as PlatformMenu; + expect(editMenu.label, 'Edit'); + + // 4. View Menu + final viewMenu = platformMenuBar.menus[3] as PlatformMenu; + expect(viewMenu.label, 'View'); + final viewItems = viewMenu.menus.whereType().toList(); + expect(viewItems.any((m) => m.label == 'Toggle Sidebar'), isTrue); + expect(viewItems.any((m) => m.label == 'Return to Start Screen'), isTrue); + + // 5. Window Menu + final windowMenu = platformMenuBar.menus[4] as PlatformMenu; + expect(windowMenu.label, 'Window'); + + // 6. Help Menu + final helpMenu = platformMenuBar.menus[5] as PlatformMenu; + expect(helpMenu.label, 'Help'); + final helpItems = helpMenu.menus.whereType().toList(); + expect(helpItems.any((m) => m.label == 'Welcome Tour & Guide'), isTrue); + expect(helpItems.any((m) => m.label == 'Keyboard Shortcuts Cheat Sheet'), + isTrue); + + // Invoke callbacks directly + fileItems + .firstWhere((m) => m.label == 'New Connection...') + .onSelected + ?.call(); + expect(newConnectionInvoked, isTrue); + + fileItems + .firstWhere((m) => m.label == 'New Query Tab') + .onSelected + ?.call(); + expect(newQueryTabInvoked, isTrue); + + fileItems + .firstWhere((m) => m.label == 'Open SQL Script...') + .onSelected + ?.call(); + expect(openSqlInvoked, isTrue); + + fileItems.firstWhere((m) => m.label == 'Save Query').onSelected?.call(); + expect(saveQueryInvoked, isTrue); + + fileItems + .firstWhere((m) => m.label == 'Run Query / Script') + .onSelected + ?.call(); + expect(executeQueryInvoked, isTrue); + + viewItems + .firstWhere((m) => m.label == 'Toggle Sidebar') + .onSelected + ?.call(); + expect(toggleSidebarInvoked, isTrue); + + viewItems + .firstWhere((m) => m.label == 'Return to Start Screen') + .onSelected + ?.call(); + expect(goHomeInvoked, isTrue); + + helpItems + .firstWhere((m) => m.label == 'Welcome Tour & Guide') + .onSelected + ?.call(); + expect(tourInvoked, isTrue); + + final prefGroup = appMenu.menus.whereType().first; + final prefItem = prefGroup.members.whereType().first; + prefItem.onSelected?.call(); + expect(preferencesInvoked, isTrue); + }); + + testWidgets('shortcuts verify Command (meta: true) keybindings', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: QueryaPlatformMenuBar( + child: material.Text('Shortcuts Test Content'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final platformMenuBar = tester.widget( + find.byType(PlatformMenuBar), + ); + + final fileMenu = platformMenuBar.menus[1] as PlatformMenu; + final fileItems = fileMenu.menus.whereType().toList(); + + final newConnItem = + fileItems.firstWhere((m) => m.label == 'New Connection...'); + expect(newConnItem.shortcut, + const SingleActivator(LogicalKeyboardKey.keyN, meta: true)); + + final newTabItem = + fileItems.firstWhere((m) => m.label == 'New Query Tab'); + expect( + newTabItem.shortcut, + const SingleActivator(LogicalKeyboardKey.keyN, + meta: true, shift: true)); + + final openSqlItem = + fileItems.firstWhere((m) => m.label == 'Open SQL Script...'); + expect(openSqlItem.shortcut, + const SingleActivator(LogicalKeyboardKey.keyO, meta: true)); + + final saveQueryItem = + fileItems.firstWhere((m) => m.label == 'Save Query'); + expect(saveQueryItem.shortcut, + const SingleActivator(LogicalKeyboardKey.keyS, meta: true)); + + final viewMenu = platformMenuBar.menus[3] as PlatformMenu; + final viewItems = viewMenu.menus.whereType().toList(); + final sidebarItem = + viewItems.firstWhere((m) => m.label == 'Toggle Sidebar'); + expect(sidebarItem.shortcut, + const SingleActivator(LogicalKeyboardKey.keyB, meta: true)); + }); + }); +} From 533fc542b3a905e67fae1779c6a30f61a4636fa1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 12:13:39 +0300 Subject: [PATCH 20/47] perf(memory): explore compact typed storage and lazy cell stringification for large results (#605) --- lib/core/database/compact_result_dataset.dart | 403 ++++++++++++++++++ .../database/compact_result_dataset_test.dart | 117 +++++ 2 files changed, 520 insertions(+) create mode 100644 lib/core/database/compact_result_dataset.dart create mode 100644 test/core/database/compact_result_dataset_test.dart diff --git a/lib/core/database/compact_result_dataset.dart b/lib/core/database/compact_result_dataset.dart new file mode 100644 index 0000000..ce08563 --- /dev/null +++ b/lib/core/database/compact_result_dataset.dart @@ -0,0 +1,403 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +import 'result_row_string_convert.dart'; + +/// Supported compact column storage kinds. +enum CompactColumnKind { + int64, + float64, + string, + generic, +} + +/// Columnar storage interface for a single database column in a result set. +sealed class CompactColumn { + int get length; + CompactColumnKind get kind; + Object? rawValueAt(int index); + String stringValueAt(int index, [StringInternPool? pool]); + bool isNull(int index); +} + +/// Compact 64-bit integer column backed by [Int64List] and a packed null bitmap. +class Int64CompactColumn implements CompactColumn { + Int64CompactColumn({ + required this.values, + required this.nullBitmap, + required this.length, + }); + + final Int64List values; + final Uint8List nullBitmap; // 1 bit per row indicates null + @override + final int length; + + @override + CompactColumnKind get kind => CompactColumnKind.int64; + + @override + bool isNull(int index) { + final byteIndex = index >> 3; + final bitOffset = index & 7; + return (nullBitmap[byteIndex] & (1 << bitOffset)) != 0; + } + + @override + Object? rawValueAt(int index) { + if (isNull(index)) return null; + return values[index]; + } + + @override + String stringValueAt(int index, [StringInternPool? pool]) { + if (isNull(index)) return 'NULL'; + final val = values[index]; + if (val >= 0 && val <= 10 && pool != null) { + return pool.internObject(val); + } + return val.toString(); + } +} + +/// Compact 64-bit float column backed by [Float64List] and a packed null bitmap. +class Float64CompactColumn implements CompactColumn { + Float64CompactColumn({ + required this.values, + required this.nullBitmap, + required this.length, + }); + + final Float64List values; + final Uint8List nullBitmap; + @override + final int length; + + @override + CompactColumnKind get kind => CompactColumnKind.float64; + + @override + bool isNull(int index) { + final byteIndex = index >> 3; + final bitOffset = index & 7; + return (nullBitmap[byteIndex] & (1 << bitOffset)) != 0; + } + + @override + Object? rawValueAt(int index) { + if (isNull(index)) return null; + return values[index]; + } + + @override + String stringValueAt(int index, [StringInternPool? pool]) { + if (isNull(index)) return 'NULL'; + final val = values[index]; + if (val == val.toInt() && !val.isNaN && !val.isInfinite) { + return val.toInt().toString(); + } + return val.toString(); + } +} + +/// String column backed by interned String references. +class StringCompactColumn implements CompactColumn { + StringCompactColumn({ + required this.values, + required this.length, + }); + + final List values; + @override + final int length; + + @override + CompactColumnKind get kind => CompactColumnKind.string; + + @override + bool isNull(int index) => values[index] == null; + + @override + Object? rawValueAt(int index) => values[index]; + + @override + String stringValueAt(int index, [StringInternPool? pool]) { + final val = values[index]; + if (val == null) return 'NULL'; + return pool != null ? pool.intern(val) : val; + } +} + +/// Generic object column for composite or unparsed database types. +class GenericCompactColumn implements CompactColumn { + GenericCompactColumn({ + required this.values, + required this.length, + }); + + final List values; + @override + final int length; + + @override + CompactColumnKind get kind => CompactColumnKind.generic; + + @override + bool isNull(int index) => values[index] == null; + + @override + Object? rawValueAt(int index) => values[index]; + + @override + String stringValueAt(int index, [StringInternPool? pool]) { + final val = values[index]; + if (val == null) return 'NULL'; + return pool != null ? pool.internObject(val) : val.toString(); + } +} + +/// Memory-efficient columnar dataset for query result tables. +/// +/// Converts numeric columns to contiguous primitive TypedData arrays +/// and lazily stringifies cells on-demand for viewport rendering, +/// eliminating the allocation of millions of intermediate String objects in heap. +class CompactResultDataset { + CompactResultDataset({ + required this.columnNames, + required this.columns, + required this.rowCount, + StringInternPool? pool, + }) : pool = pool ?? StringInternPool(); + + final List columnNames; + final List columns; + final int rowCount; + final StringInternPool pool; + + int get columnCount => columnNames.length; + + /// Returns the raw cell value (int, double, String, or null). + Object? rawCellAt(int rowIndex, int colIndex) { + if (colIndex < 0 || colIndex >= columns.length) return null; + return columns[colIndex].rawValueAt(rowIndex); + } + + /// Lazily stringifies the cell value at [rowIndex], [colIndex]. + String cellAt(int rowIndex, int colIndex) { + if (colIndex < 0 || colIndex >= columns.length) return 'NULL'; + return columns[colIndex].stringValueAt(rowIndex, pool); + } + + /// Returns a full row of formatted strings for [rowIndex]. + List rowStrings(int rowIndex) { + return [ + for (var col = 0; col < columns.length; col++) + cellAt(rowIndex, col), + ]; + } + + /// Returns a full row of raw typed values for [rowIndex]. + List rawRow(int rowIndex) { + return [ + for (var col = 0; col < columns.length; col++) + rawCellAt(rowIndex, col), + ]; + } + + /// Exposes a lazy, unmodifiable `List>` row view that formats + /// cells on-the-fly when indexed by viewport-based virtual grids. + List> asLazyRowList() => _LazyDatasetRowList(this); + + /// Constructs a [CompactResultDataset] from raw rows, detecting column types + /// and packing numeric columns into [Int64List] / [Float64List]. + static CompactResultDataset fromRawRows( + List columnNames, + List> rawRows, { + StringInternPool? pool, + }) { + final rowCount = rawRows.length; + final colCount = columnNames.length; + final internPool = pool ?? StringInternPool(); + + if (rowCount == 0 || colCount == 0) { + return CompactResultDataset( + columnNames: columnNames, + columns: const [], + rowCount: 0, + pool: internPool, + ); + } + + // 1. Detect column types by inspecting non-null samples + final kinds = List.filled(colCount, CompactColumnKind.generic); + for (var col = 0; col < colCount; col++) { + var allInt = true; + var allFloat = true; + var allString = true; + var sampleCount = 0; + + for (var row = 0; row < rowCount; row++) { + final val = rawRows[row].length > col ? rawRows[row][col] : null; + if (val == null) continue; + sampleCount++; + + if (val is! int) { + allInt = false; + } + if (val is! num) { + allFloat = false; + } + if (val is! String) { + allString = false; + } + + if (sampleCount >= 60 && !allInt && !allFloat && !allString) { + break; + } + } + + if (sampleCount > 0) { + if (allInt) { + kinds[col] = CompactColumnKind.int64; + } else if (allFloat) { + kinds[col] = CompactColumnKind.float64; + } else if (allString) { + kinds[col] = CompactColumnKind.string; + } else { + kinds[col] = CompactColumnKind.generic; + } + } else { + kinds[col] = CompactColumnKind.string; + } + } + + // 2. Allocate packed columnar buffers + final compactColumns = []; + final bitmapBytes = (rowCount + 7) >> 3; + + for (var col = 0; col < colCount; col++) { + final kind = kinds[col]; + switch (kind) { + case CompactColumnKind.int64: + final values = Int64List(rowCount); + final nullBitmap = Uint8List(bitmapBytes); + for (var row = 0; row < rowCount; row++) { + final val = rawRows[row].length > col ? rawRows[row][col] : null; + if (val == null) { + final byte = row >> 3; + final bit = row & 7; + nullBitmap[byte] |= (1 << bit); + } else if (val is int) { + values[row] = val; + } else if (val is num) { + values[row] = val.toInt(); + } + } + compactColumns.add(Int64CompactColumn( + values: values, + nullBitmap: nullBitmap, + length: rowCount, + )); + + case CompactColumnKind.float64: + final values = Float64List(rowCount); + final nullBitmap = Uint8List(bitmapBytes); + for (var row = 0; row < rowCount; row++) { + final val = rawRows[row].length > col ? rawRows[row][col] : null; + if (val == null) { + final byte = row >> 3; + final bit = row & 7; + nullBitmap[byte] |= (1 << bit); + } else if (val is num) { + values[row] = val.toDouble(); + } + } + compactColumns.add(Float64CompactColumn( + values: values, + nullBitmap: nullBitmap, + length: rowCount, + )); + + case CompactColumnKind.string: + final values = List.filled(rowCount, null); + for (var row = 0; row < rowCount; row++) { + final val = rawRows[row].length > col ? rawRows[row][col] : null; + if (val is String) { + values[row] = internPool.intern(val); + } else if (val != null) { + values[row] = internPool.intern(val.toString()); + } + } + compactColumns.add(StringCompactColumn( + values: values, + length: rowCount, + )); + + case CompactColumnKind.generic: + final values = List.filled(rowCount, null); + for (var row = 0; row < rowCount; row++) { + final val = rawRows[row].length > col ? rawRows[row][col] : null; + values[row] = val; + } + compactColumns.add(GenericCompactColumn( + values: values, + length: rowCount, + )); + } + } + + return CompactResultDataset( + columnNames: columnNames, + columns: compactColumns, + rowCount: rowCount, + pool: internPool, + ); + } +} + +class _LazyDatasetRowList extends ListBase> { + _LazyDatasetRowList(this.dataset); + + final CompactResultDataset dataset; + + @override + int get length => dataset.rowCount; + + @override + set length(int newLength) => + throw UnsupportedError('Dataset rows are unmodifiable'); + + @override + List operator [](int index) { + RangeError.checkValidIndex(index, this, 'index', length); + return _LazyDatasetRow(dataset, index); + } + + @override + void operator []=(int index, List value) => + throw UnsupportedError('Dataset rows are unmodifiable'); +} + +class _LazyDatasetRow extends ListBase { + _LazyDatasetRow(this.dataset, this.rowIndex); + + final CompactResultDataset dataset; + final int rowIndex; + + @override + int get length => dataset.columnCount; + + @override + set length(int newLength) => + throw UnsupportedError('Row cells are unmodifiable'); + + @override + String operator [](int index) { + RangeError.checkValidIndex(index, this, 'index', length); + return dataset.cellAt(rowIndex, index); + } + + @override + void operator []=(int index, String value) => + throw UnsupportedError('Row cells are unmodifiable'); +} diff --git a/test/core/database/compact_result_dataset_test.dart b/test/core/database/compact_result_dataset_test.dart new file mode 100644 index 0000000..d42c0ee --- /dev/null +++ b/test/core/database/compact_result_dataset_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/compact_result_dataset.dart'; + +void main() { + group('CompactResultDataset', () { + test('packs typed columns into specialized compact columnar buffers', () { + final columns = ['id', 'amount', 'status', 'meta']; + final rows = >[ + [1, 19.99, 'active', {'role': 'admin'}], + [2, 49.50, 'pending', null], + [3, null, 'active', {'role': 'user'}], + [null, 0.0, null, null], + ]; + + final dataset = CompactResultDataset.fromRawRows(columns, rows); + + expect(dataset.rowCount, 4); + expect(dataset.columnCount, 4); + expect(dataset.columns[0].kind, CompactColumnKind.int64); + expect(dataset.columns[1].kind, CompactColumnKind.float64); + expect(dataset.columns[2].kind, CompactColumnKind.string); + expect(dataset.columns[3].kind, CompactColumnKind.generic); + + // Raw value tests + expect(dataset.rawCellAt(0, 0), 1); + expect(dataset.rawCellAt(0, 1), 19.99); + expect(dataset.rawCellAt(0, 2), 'active'); + expect(dataset.rawCellAt(0, 3), {'role': 'admin'}); + + // Null handling + expect(dataset.rawCellAt(2, 1), isNull); + expect(dataset.rawCellAt(3, 0), isNull); + expect(dataset.rawCellAt(3, 2), isNull); + + // Stringified lazy cells + expect(dataset.cellAt(0, 0), '1'); + expect(dataset.cellAt(0, 1), '19.99'); + expect(dataset.cellAt(0, 2), 'active'); + expect(dataset.cellAt(2, 1), 'NULL'); + expect(dataset.cellAt(3, 0), 'NULL'); + expect(dataset.cellAt(3, 2), 'NULL'); + expect(dataset.cellAt(3, 1), '0'); + }); + + test('asLazyRowList provides transparent List> indexable interface', () { + final columns = ['code', 'count']; + final rows = >[ + ['alpha', 10], + ['beta', 25], + ['gamma', null], + ]; + + final dataset = CompactResultDataset.fromRawRows(columns, rows); + final lazyRows = dataset.asLazyRowList(); + + expect(lazyRows.length, 3); + expect(lazyRows[0].length, 2); + expect(lazyRows[0][0], 'alpha'); + expect(lazyRows[0][1], '10'); + expect(lazyRows[1][0], 'beta'); + expect(lazyRows[1][1], '25'); + expect(lazyRows[2][0], 'gamma'); + expect(lazyRows[2][1], 'NULL'); + + expect(() => lazyRows[0][0] = 'modified', throwsUnsupportedError); + expect(() => lazyRows.add(['delta', '50']), throwsUnsupportedError); + }); + + test('handles empty dataset gracefully', () { + final dataset = CompactResultDataset.fromRawRows(['id', 'name'], []); + expect(dataset.rowCount, 0); + expect(dataset.asLazyRowList(), isEmpty); + }); + + test('100,000-row benchmark verifies packing throughput and fast viewport slicing', () { + final columns = ['id', 'user_id', 'balance', 'status', 'flag']; + final statuses = ['active', 'inactive', 'suspended', 'trial']; + + final rawData = List>.generate( + 100000, + (i) => [ + i + 1, + (i * 3) % 10000, + (i % 100) * 1.5, + statuses[i % statuses.length], + i % 2 == 0 ? 1 : 0, + ], + ); + + final packStopwatch = Stopwatch()..start(); + final dataset = CompactResultDataset.fromRawRows(columns, rawData); + packStopwatch.stop(); + + expect(dataset.rowCount, 100000); + expect(packStopwatch.elapsedMilliseconds, lessThan(300)); + + final lazyRows = dataset.asLazyRowList(); + + // Viewport simulation: slice 50 rows x 5 columns + final viewportStopwatch = Stopwatch()..start(); + final viewportSlice = >[]; + for (var r = 50000; r < 50050; r++) { + final row = []; + for (var c = 0; c < 5; c++) { + row.add(lazyRows[r][c]); + } + viewportSlice.add(row); + } + viewportStopwatch.stop(); + + expect(viewportSlice.length, 50); + expect(viewportStopwatch.elapsedMilliseconds, lessThan(5)); + expect(viewportSlice[0][0], '50001'); + expect(viewportSlice[0][3], 'active'); + }); + }); +} From 598a4322605228cc9cd1b63ac5f976eba772a3d3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 12:18:42 +0300 Subject: [PATCH 21/47] feat(extensions): Querya Extension Driver Mutation Standard (#563) --- docs/tz-block-d-external-plugin.md | 83 +++++++++++ .../extensions/extension_driver_session.dart | 48 ++++++ .../models/extension_driver_capabilities.dart | 21 ++- .../extensions/extension_table_view.dart | 140 +++++++++++++++++- .../extension_driver_session_test.dart | 8 + .../extensions/extension_table_view_test.dart | 108 ++++++++++++++ 6 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 test/features/extensions/extension_table_view_test.dart diff --git a/docs/tz-block-d-external-plugin.md b/docs/tz-block-d-external-plugin.md index 0412ba5..4dc0919 100644 --- a/docs/tz-block-d-external-plugin.md +++ b/docs/tz-block-d-external-plugin.md @@ -53,3 +53,86 @@ - **Метод `extension.getTreeSchema`**: Возвращает первичную структуру бокового меню (например, корневые папки "Databases" и "Users"). Разделение логики: Ядро занимается пикселями и дизайном, Плагин — логикой и структурами данных. + +--- + +## 5. Стандарт мутаций данных (Querya Extension Mutation Standard) + +Если плагин поддерживает интерактивное редактирование данных в 2D-таблицах (`ExtensionDriverCapabilities.supportsMutations: true`), он реализует следующие JSON-RPC методы: + +### 5.1. `db.getTableSchema` +Возвращает метаданные колонок, признак первичного ключа и возможность `NULL`. +* **Запрос:** + ```json + { + "jsonrpc": "2.0", + "method": "db.getTableSchema", + "params": { + "connectionId": 123, + "database": "analytics", + "schema": "public", + "tableName": "users" + }, + "id": 4 + } + ``` +* **Ответ:** + ```json + { + "jsonrpc": "2.0", + "result": { + "tableName": "users", + "schema": "public", + "primaryKeys": ["id"], + "columns": [ + { "name": "id", "dataType": "integer", "isPrimaryKey": true, "isNullable": false }, + { "name": "email", "dataType": "varchar", "isPrimaryKey": false, "isNullable": true }, + { "name": "age", "dataType": "integer", "isPrimaryKey": false, "isNullable": true } + ] + }, + "id": 4 + } + ``` + +### 5.2. `db.mutate` +Выполняет атомарный пакет мутаций (вставка, обновление, удаление строк). +* **Запрос:** + ```json + { + "jsonrpc": "2.0", + "method": "db.mutate", + "params": { + "connectionId": 123, + "database": "analytics", + "tableName": "users", + "mutations": [ + { + "type": "update", + "where": { "id": "42" }, + "set": { "email": "new_email@domain.com" } + }, + { + "type": "insert", + "values": { "id": "43", "email": "bob@domain.com", "age": "30" } + }, + { + "type": "delete", + "where": { "id": "10" } + } + ] + }, + "id": 5 + } + ``` +* **Ответ:** + ```json + { + "jsonrpc": "2.0", + "result": { + "success": true, + "affectedRows": 3 + }, + "id": 5 + } + ``` + diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index a082edd..47b0c3f 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; @@ -378,6 +379,53 @@ class ExtensionDriverSession { } } + /// Queries table schema metadata (column types, nullability, PKs) via `db.getTableSchema`. + Future getTableSchema( + ConnectionRow row, { + required String database, + String? schema, + required String tableName, + }) async { + final bridge = await ensureConnected(row); + try { + final result = await bridge.sendRequest('db.getTableSchema', { + 'connectionId': row.id, + 'database': database, + if (schema != null && schema.isNotEmpty) 'schema': schema, + 'tableName': tableName, + }); + if (result is Map) { + return TableSchemaMeta.fromJson(Map.from(result)); + } + return TableSchemaMeta(tableName: tableName, schema: schema); + } catch (e) { + debugPrint('ExtensionDriverSession getTableSchema fallback ($e)'); + return TableSchemaMeta(tableName: tableName, schema: schema); + } + } + + /// Executes batch data mutations (insert, update, delete) via `db.mutate`. + Future> mutate( + ConnectionRow row, { + required String database, + String? schema, + required String tableName, + required List> mutations, + }) async { + final bridge = await ensureConnected(row); + final result = await bridge.sendRequest('db.mutate', { + 'connectionId': row.id, + 'database': database, + if (schema != null && schema.isNotEmpty) 'schema': schema, + 'tableName': tableName, + 'mutations': mutations, + }); + if (result is Map) { + return Map.from(result); + } + return {'success': true, 'affectedRows': mutations.length}; + } + Future disconnect(int connectionId) async { final bridge = _bridges.remove(connectionId); _manifests.remove(connectionId); diff --git a/lib/core/extensions/models/extension_driver_capabilities.dart b/lib/core/extensions/models/extension_driver_capabilities.dart index 088215f..4c128cc 100644 --- a/lib/core/extensions/models/extension_driver_capabilities.dart +++ b/lib/core/extensions/models/extension_driver_capabilities.dart @@ -6,6 +6,8 @@ class ExtensionDriverCapabilities { this.supportsDDLInspection = false, this.supportsPrivileges = false, this.hasServerStats = false, + this.supportsMutations = false, + this.supportsBatchMutations = false, }); /// True if `db.query` supports transaction control queries (BEGIN, COMMIT, ROLLBACK). @@ -23,6 +25,12 @@ class ExtensionDriverCapabilities { /// True if the driver supports `db.getServerStats`. final bool hasServerStats; + /// True if the driver supports `db.getTableSchema` and `db.mutate`. + final bool supportsMutations; + + /// True if the driver supports batch multi-row mutations in `db.mutate`. + final bool supportsBatchMutations; + factory ExtensionDriverCapabilities.fromRpc(Object? raw) { if (raw is! Map) return const ExtensionDriverCapabilities(); final map = raw is Map @@ -42,6 +50,11 @@ class ExtensionDriverCapabilities { map['supports_privileges'] == true, hasServerStats: map['hasServerStats'] == true || map['has_server_stats'] == true, + supportsMutations: + map['supportsMutations'] == true || map['supports_mutations'] == true, + supportsBatchMutations: + map['supportsBatchMutations'] == true || + map['supports_batch_mutations'] == true, ); } @@ -51,6 +64,8 @@ class ExtensionDriverCapabilities { 'supportsDDLInspection': supportsDDLInspection, 'supportsPrivileges': supportsPrivileges, 'hasServerStats': hasServerStats, + 'supportsMutations': supportsMutations, + 'supportsBatchMutations': supportsBatchMutations, }; @override @@ -62,7 +77,9 @@ class ExtensionDriverCapabilities { supportsCancel == other.supportsCancel && supportsDDLInspection == other.supportsDDLInspection && supportsPrivileges == other.supportsPrivileges && - hasServerStats == other.hasServerStats; + hasServerStats == other.hasServerStats && + supportsMutations == other.supportsMutations && + supportsBatchMutations == other.supportsBatchMutations; @override int get hashCode => @@ -72,5 +89,7 @@ class ExtensionDriverCapabilities { supportsDDLInspection, supportsPrivileges, hasServerStats, + supportsMutations, + supportsBatchMutations, ); } diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index 38ca00e..bb8429a 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -1,9 +1,12 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/extensions/extension_table_toolbar.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -44,6 +47,10 @@ class _ExtensionTableViewState extends material.State { bool _filterActive = false; final _filterController = material.TextEditingController(); + ExtensionDriverCapabilities? _capabilities; + DataGridStagingBuffer? _stagingBuffer; + bool _isSaving = false; + String get _qualifiedName => '`${widget.database}`.`${widget.tableName}`'; String get _whereClause { @@ -67,6 +74,7 @@ class _ExtensionTableViewState extends material.State { _totalRows = null; _filterController.clear(); _filterActive = false; + _stagingBuffer = null; unawaited(_loadPage(refreshCount: true)); } } @@ -123,9 +131,7 @@ class _ExtensionTableViewState extends material.State { _updateStatusLine(); }); } - } catch (_) { - // Ignore count errors on stream or schema tables that do not support count queries - } + } catch (_) {} } } @@ -139,6 +145,9 @@ class _ExtensionTableViewState extends material.State { }); try { + _capabilities ??= await ExtensionDriverSession.instance + .getCapabilities(widget.connectionRow); + final dataResult = await ExtensionDriverSession.instance.query( widget.connectionRow, 'SELECT * FROM $_qualifiedName$_whereClause LIMIT ${widget.pageSize} OFFSET $_offset', @@ -149,6 +158,12 @@ class _ExtensionTableViewState extends material.State { _columns = dataResult.columns; _rows = dataResult.rows; _loading = false; + if (!widget.isView && (_capabilities?.supportsMutations == true)) { + _stagingBuffer = + DataGridStagingBuffer(columns: _columns, rows: _rows); + } else { + _stagingBuffer = null; + } _updateStatusLine(); }); @@ -163,6 +178,122 @@ class _ExtensionTableViewState extends material.State { } } + Future _onApplyChanges() async { + final buffer = _stagingBuffer; + if (buffer == null || !buffer.isDirty) return; + + setState(() => _isSaving = true); + try { + final schema = await ExtensionDriverSession.instance.getTableSchema( + widget.connectionRow, + database: widget.database, + tableName: widget.tableName, + ); + + final mutations = >[]; + + // 1. Updates + for (final entry in buffer.modifiedCells.entries) { + final rowIndex = entry.key; + final colMap = entry.value; + final origRow = buffer.originalRows[rowIndex]; + + final whereMap = {}; + if (schema.primaryKeys.isNotEmpty) { + for (final pk in schema.primaryKeys) { + final idx = _columns.indexOf(pk); + if (idx != -1 && idx < origRow.length) { + whereMap[pk] = origRow[idx]; + } + } + } else { + for (var c = 0; c < _columns.length; c++) { + whereMap[_columns[c]] = c < origRow.length ? origRow[c] : null; + } + } + + final setMap = {}; + for (final colEntry in colMap.entries) { + final colName = _columns[colEntry.key]; + final val = colEntry.value; + setMap[colName] = + val == TableMutationEngine.kNullSentinel ? null : val; + } + + mutations.add({ + 'type': 'update', + 'where': whereMap, + 'set': setMap, + }); + } + + // 2. Inserts + for (final row in buffer.insertedRows) { + final valuesMap = {}; + for (var c = 0; c < _columns.length; c++) { + final val = c < row.length ? row[c] : null; + valuesMap[_columns[c]] = (val == null || + val == TableMutationEngine.kNullSentinel || + val == 'NULL') + ? null + : val; + } + mutations.add({ + 'type': 'insert', + 'values': valuesMap, + }); + } + + // 3. Deletes + for (final rowIndex in buffer.deletedRowIndices) { + final origRow = buffer.originalRows[rowIndex]; + final whereMap = {}; + if (schema.primaryKeys.isNotEmpty) { + for (final pk in schema.primaryKeys) { + final idx = _columns.indexOf(pk); + if (idx != -1 && idx < origRow.length) { + whereMap[pk] = origRow[idx]; + } + } + } else { + for (var c = 0; c < _columns.length; c++) { + whereMap[_columns[c]] = c < origRow.length ? origRow[c] : null; + } + } + mutations.add({ + 'type': 'delete', + 'where': whereMap, + }); + } + + if (mutations.isNotEmpty) { + final res = await ExtensionDriverSession.instance.mutate( + widget.connectionRow, + database: widget.database, + tableName: widget.tableName, + mutations: mutations, + ); + if (!mounted) return; + final count = res['affectedRows'] ?? mutations.length; + showAppToast( + context: context, + message: 'Successfully applied $count mutation(s).', + variant: AppToastVariant.success, + ); + unawaited(_loadPage(refreshCount: true)); + } + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to apply mutations: $e', + variant: AppToastVariant.error, + ); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + void _applyFilter() { _offset = 0; _totalRows = null; @@ -359,6 +490,9 @@ class _ExtensionTableViewState extends material.State { isLoading: _loading, statusLine: _statusLine, showExportToolbar: false, + stagingBuffer: _stagingBuffer, + onApplyChanges: _stagingBuffer != null ? _onApplyChanges : null, + isSaving: _isSaving, ), ), ], diff --git a/test/core/extensions/extension_driver_session_test.dart b/test/core/extensions/extension_driver_session_test.dart index de74d71..1830155 100644 --- a/test/core/extensions/extension_driver_session_test.dart +++ b/test/core/extensions/extension_driver_session_test.dart @@ -61,6 +61,8 @@ void main() { 'supportsDDLInspection': true, 'supportsPrivileges': false, 'hasServerStats': true, + 'supportsMutations': true, + 'supportsBatchMutations': true, }); expect(caps.supportsTransactions, isTrue); @@ -68,6 +70,12 @@ void main() { expect(caps.supportsDDLInspection, isTrue); expect(caps.supportsPrivileges, isFalse); expect(caps.hasServerStats, isTrue); + expect(caps.supportsMutations, isTrue); + expect(caps.supportsBatchMutations, isTrue); + + final json = caps.toJson(); + expect(json['supportsMutations'], isTrue); + expect(json['supportsBatchMutations'], isTrue); }); test('ExtensionServerStats.fromRpc normalizes metrics map', () { diff --git a/test/features/extensions/extension_table_view_test.dart b/test/features/extensions/extension_table_view_test.dart new file mode 100644 index 0000000..d2fce66 --- /dev/null +++ b/test/features/extensions/extension_table_view_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; + +void main() { + group('ExtensionDriver Mutation Standard & Staging', () { + test('ExtensionDriverCapabilities default vs mutation flags', () { + const defaultCaps = ExtensionDriverCapabilities(); + expect(defaultCaps.supportsMutations, isFalse); + expect(defaultCaps.supportsBatchMutations, isFalse); + + final capsWithMutations = ExtensionDriverCapabilities.fromRpc({ + 'supports_mutations': true, + 'supports_batch_mutations': true, + }); + expect(capsWithMutations.supportsMutations, isTrue); + expect(capsWithMutations.supportsBatchMutations, isTrue); + }); + + test('DataGridStagingBuffer generates valid mutation payload for ExtensionDriver mutate standard', () { + final buffer = DataGridStagingBuffer( + columns: ['id', 'email', 'status'], + rows: [ + ['1', 'alice@test.com', 'active'], + ['2', 'bob@test.com', 'pending'], + ], + ); + + // 1. Stage update on row 0, col 1 (email) + buffer.setCell(0, 1, 'alice_new@test.com'); + + // 2. Stage delete on row 1 + buffer.toggleDeleteRow(1); + + // 3. Stage insert + buffer.addRow(['3', 'carol@test.com', 'active']); + + expect(buffer.isDirty, isTrue); + expect(buffer.changeCount, 3); + + final mutations = >[]; + + // Replicate ExtensionTableView mutation mapping logic + final columns = buffer.columns; + + for (final entry in buffer.modifiedCells.entries) { + final rowIndex = entry.key; + final colMap = entry.value; + final origRow = buffer.originalRows[rowIndex]; + + final whereMap = { + columns[0]: origRow[0], + }; + + final setMap = {}; + for (final colEntry in colMap.entries) { + final colName = columns[colEntry.key]; + final val = colEntry.value; + setMap[colName] = val == TableMutationEngine.kNullSentinel ? null : val; + } + + mutations.add({ + 'type': 'update', + 'where': whereMap, + 'set': setMap, + }); + } + + for (final row in buffer.insertedRows) { + final valuesMap = {}; + for (var c = 0; c < columns.length; c++) { + final val = c < row.length ? row[c] : null; + valuesMap[columns[c]] = + (val == null || val == TableMutationEngine.kNullSentinel || val == 'NULL') + ? null + : val; + } + mutations.add({ + 'type': 'insert', + 'values': valuesMap, + }); + } + + for (final rowIndex in buffer.deletedRowIndices) { + final origRow = buffer.originalRows[rowIndex]; + final whereMap = { + columns[0]: origRow[0], + }; + mutations.add({ + 'type': 'delete', + 'where': whereMap, + }); + } + + expect(mutations.length, 3); + expect(mutations[0]['type'], 'update'); + expect(mutations[0]['where'], {'id': '1'}); + expect(mutations[0]['set'], {'email': 'alice_new@test.com'}); + + expect(mutations[1]['type'], 'insert'); + expect(mutations[1]['values'], {'id': '3', 'email': 'carol@test.com', 'status': 'active'}); + + expect(mutations[2]['type'], 'delete'); + expect(mutations[2]['where'], {'id': '2'}); + }); + }); +} From d4c82130da298e3d7a16112b2b222cac41e6826e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 12:21:32 +0300 Subject: [PATCH 22/47] test(golden): add explicit @Tags(['golden']) annotation to golden tests --- test/features/main_screen/shell_chrome_golden_test.dart | 3 +++ .../features/main_screen/workspace_empty_hero_golden_test.dart | 3 +++ 2 files changed, 6 insertions(+) diff --git a/test/features/main_screen/shell_chrome_golden_test.dart b/test/features/main_screen/shell_chrome_golden_test.dart index 5207c68..a9a96a2 100644 --- a/test/features/main_screen/shell_chrome_golden_test.dart +++ b/test/features/main_screen/shell_chrome_golden_test.dart @@ -1,3 +1,6 @@ +@Tags(['golden']) +library; + import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; diff --git a/test/features/main_screen/workspace_empty_hero_golden_test.dart b/test/features/main_screen/workspace_empty_hero_golden_test.dart index 5bb2dfe..86f28c4 100644 --- a/test/features/main_screen/workspace_empty_hero_golden_test.dart +++ b/test/features/main_screen/workspace_empty_hero_golden_test.dart @@ -1,3 +1,6 @@ +@Tags(['golden']) +library; + import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/main_screen/workspace_empty_hero.dart'; From 1ef62beb57dab167e20f273a610e8db39099a8f0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 12:35:45 +0300 Subject: [PATCH 23/47] fix(storage): ensure monotonic ID ordering for sql query history list and batch prune --- lib/core/storage/local_db.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index c611c62..9815d63 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -307,7 +307,7 @@ class LocalDb { ''' SELECT id FROM sql_query_history WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ORDER BY recorded_at ASC, id ASC + ORDER BY id ASC LIMIT ? ''', [connectionId, databaseName, excess], @@ -340,7 +340,7 @@ class LocalDb { SELECT id, connection_id, database_name, sql_text, recorded_at FROM sql_query_history WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ORDER BY recorded_at DESC, id DESC + ORDER BY id DESC LIMIT ? ''', [connectionId, dbKey, limit], @@ -350,6 +350,7 @@ class LocalDb { /// Removes all history rows for [connectionId] (every database bucket). Future clearSqlQueryHistoryForConnection(int connectionId) async { + _historyInsertCounts.removeWhere((k, _) => k.startsWith('$connectionId::')); final db = await _open(); await db.delete( 'sql_query_history', @@ -363,8 +364,9 @@ class LocalDb { required int connectionId, String? databaseName, }) async { - final db = await _open(); final dbKey = _normalizeHistoryDatabaseName(databaseName); + _historyInsertCounts.remove('$connectionId::${dbKey ?? ''}'); + final db = await _open(); await db.rawDelete( ''' DELETE FROM sql_query_history @@ -524,6 +526,7 @@ class LocalDb { /// Deletes a connection. SQLite deletion always proceeds even if the secure /// store delete fails (e.g. missing key or unavailable libsecret daemon). Future removeConnection(int id) async { + _historyInsertCounts.removeWhere((k, _) => k.startsWith('$id::')); try { await ConnectionSecretsStore.deleteForConnection(id); } catch (_) { From aa1fffe5b78497f75ff35dd122580062002910dc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 29 Aug 2026 10:28:24 +0300 Subject: [PATCH 24/47] ui(motion): harmonize connection workspace motion to prevent dual-axis wobble (#631) --- lib/features/main_screen/workspace_panel.dart | 5 +++- .../workspace_panel_layout_test.dart | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index f11c784..a8bbf4d 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -11,7 +11,8 @@ import 'package:flutter/material.dart' as material MainAxisSize, SizedBox, Widget, - Column; + Column, + Offset; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; @@ -372,10 +373,12 @@ class _WorkspacePanelState extends State { required material.Widget? object, }) { return QueryaSwitchingBody( + slide: material.Offset.zero, index: showingObject ? 1 : 0, children: [ home, QueryaFadeSlide( + offset: const material.Offset(0, 0.015), child: object ?? const material.SizedBox.expand( key: ValueKey('workspace_object_placeholder'), diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index 79f795c..114388a 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -255,5 +255,30 @@ void main() { expect(find.byType(RedisView), findsOneWidget); expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); }); + + testWidgets( + 'home↔object morph uses slide: Offset.zero to prevent dual-axis wobble', + (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(activeConnection: redisConnection), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final switchingBodies = tester + .widgetList(find.byType(QueryaSwitchingBody)) + .toList(); + final homeObjectSwitchingBody = switchingBodies.firstWhere( + (sb) => sb.slide == material.Offset.zero, + orElse: () => throw StateError('No switching body with Offset.zero'), + ); + expect(homeObjectSwitchingBody.slide, material.Offset.zero); + }); }); } From 69bb272322b2a90c60cd8279c1069dd1439b46c8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 29 Aug 2026 10:29:42 +0300 Subject: [PATCH 25/47] feat(ux): bi-directional navigation between statistics/home and active table (#630) --- .../extensions/extension_table_toolbar.dart | 19 +- .../extensions/extension_table_view.dart | 3 + .../extensions/extension_workspace_home.dart | 20 ++ lib/features/main_screen/main_screen.dart | 13 ++ .../main_screen_workspace_state.dart | 196 ++++++++++++++++-- lib/features/main_screen/workspace_panel.dart | 52 ++++- lib/features/mysql/mysql_table_view.dart | 19 +- lib/features/mysql/mysql_workspace_home.dart | 24 +++ .../postgresql/postgres_object_workspace.dart | 4 + .../postgresql/postgres_table_toolbar.dart | 19 +- .../postgresql/postgres_table_view.dart | 3 + .../postgresql/postgres_workspace_home.dart | 24 +++ lib/features/sqlite/sqlite_table_view.dart | 19 +- .../sqlite/sqlite_workspace_home.dart | 24 +++ .../extension_table_toolbar_test.dart | 6 +- .../main_screen_workspace_state_test.dart | 30 +++ .../workspace_panel_layout_test.dart | 39 ++++ 17 files changed, 486 insertions(+), 28 deletions(-) diff --git a/lib/features/extensions/extension_table_toolbar.dart b/lib/features/extensions/extension_table_toolbar.dart index 83b1e51..d1e6421 100644 --- a/lib/features/extensions/extension_table_toolbar.dart +++ b/lib/features/extensions/extension_table_toolbar.dart @@ -22,6 +22,7 @@ class ExtensionTableToolbar extends material.StatelessWidget { this.onCancelQuery, this.onCopyFormat, this.onSaveFormat, + this.onNavigateHome, }); final String title; @@ -40,6 +41,7 @@ class ExtensionTableToolbar extends material.StatelessWidget { final VoidCallback? onCancelQuery; final material.ValueChanged? onCopyFormat; final material.ValueChanged? onSaveFormat; + final VoidCallback? onNavigateHome; @override material.Widget build(material.BuildContext context) { @@ -58,6 +60,21 @@ class ExtensionTableToolbar extends material.StatelessWidget { ), child: material.Row( children: [ + if (onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to driver overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: onNavigateHome, + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, + ), + child: const Text('Driver'), + ), + ), + const Gap(10), + ], material.Icon(tableIcon, size: 18, color: cs.primary), const Gap(8), material.Expanded( @@ -163,7 +180,7 @@ class ExtensionTableToolbar extends material.StatelessWidget { material.Icons.chevron_left_rounded, size: 16, ), - child: const Text('Back'), + child: const Text('Prev'), ), const Gap(4), OutlineButton( diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index bb8429a..0b84031 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -22,6 +22,7 @@ class ExtensionTableView extends material.StatefulWidget { required this.tableName, this.isView = false, this.pageSize = _defaultPageSize, + this.onNavigateHome, }); final ConnectionRow connectionRow; @@ -29,6 +30,7 @@ class ExtensionTableView extends material.StatefulWidget { final String tableName; final bool isView; final int pageSize; + final VoidCallback? onNavigateHome; @override material.State createState() => @@ -401,6 +403,7 @@ class _ExtensionTableViewState extends material.State { loading: _loading, canGoPrevious: _canGoBack && !_loading, canGoNext: _canGoForward && !_loading, + onNavigateHome: widget.onNavigateHome, filterActive: _filterActive || _filterController.text.isNotEmpty, filterText: _filterController.text, onToggleFilter: () { diff --git a/lib/features/extensions/extension_workspace_home.dart b/lib/features/extensions/extension_workspace_home.dart index f58c93a..6f99e3e 100644 --- a/lib/features/extensions/extension_workspace_home.dart +++ b/lib/features/extensions/extension_workspace_home.dart @@ -15,6 +15,8 @@ class ExtensionWorkspaceHome extends material.StatefulWidget { required this.connectionRow, this.selectedObject, this.sqlTabRequestToken = 0, + this.lastSelectedExtensionObject, + this.onRestoreLastSelectedObject, this.isReadOnly = false, }); @@ -22,6 +24,11 @@ class ExtensionWorkspaceHome extends material.StatefulWidget { final ExtensionSelectedObject? selectedObject; final int sqlTabRequestToken; final bool isReadOnly; + final ({ + String database, + String name, + })? lastSelectedExtensionObject; + final VoidCallback? onRestoreLastSelectedObject; @override material.State createState() => @@ -94,6 +101,19 @@ class _ExtensionWorkspaceHomeState color: theme.colorScheme.mutedForeground, ), ], + if (widget.lastSelectedExtensionObject != null && + widget.onRestoreLastSelectedObject != null) ...[ + const Gap(12), + OutlineButton( + size: ButtonSize.small, + onPressed: widget.onRestoreLastSelectedObject, + leading: const material.Icon( + material.Icons.table_chart_outlined, + size: 14, + ), + child: Text('Return to ${widget.lastSelectedExtensionObject!.name}'), + ), + ], const Spacer(), QueryaTabStrip( labels: const ['Server', 'SQL'], diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 0b83130..043088f 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -923,6 +923,19 @@ class _MainContentSplitState extends State<_MainContentSplit> selectedSqliteObject: ws.selectedSqliteObject, sqliteSqlTabRequestToken: ws.sqliteSqlTabRequestToken, selectedExtensionObject: ws.selectedExtensionObject, + lastSelectedPostgresObject: ws.lastSelectedPostgresObject, + lastSelectedMysqlObject: ws.lastSelectedMysqlObject, + lastSelectedSqliteObject: ws.lastSelectedSqliteObject, + lastSelectedExtensionObject: + ws.lastSelectedExtensionObject, + onNavigateHome: () { + widget.workspace.value = + widget.workspace.value.unselectActiveObject(); + }, + onRestoreLastSelectedObject: () { + widget.workspace.value = + widget.workspace.value.restoreLastSelectedObject(); + }, isReadOnly: ws.isReadOnly, onRequestNewConnection: widget.onRequestNewConnection, onRequestNewConnectionFromUrl: diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart index 70ecc3d..f34bc65 100644 --- a/lib/features/main_screen/main_screen_workspace_state.dart +++ b/lib/features/main_screen/main_screen_workspace_state.dart @@ -20,6 +20,10 @@ class MainScreenWorkspaceState { this.selectedSqliteObject, this.sqliteSqlTabRequestToken = 0, this.selectedExtensionObject, + this.lastSelectedPostgresObject, + this.lastSelectedMysqlObject, + this.lastSelectedSqliteObject, + this.lastSelectedExtensionObject, this.isReadOnly = false, }); @@ -61,6 +65,31 @@ class MainScreenWorkspaceState { String database, String name, })? selectedExtensionObject; + + /// Remembers the last visited table/view for the active connection to support 1-click return from stats. + final ({ + String database, + String schema, + String name, + PostgresObjectKind kind + })? lastSelectedPostgresObject; + + final ({ + String database, + String name, + MysqlObjectKind kind + })? lastSelectedMysqlObject; + + final ({ + String name, + SqliteObjectKind kind + })? lastSelectedSqliteObject; + + final ({ + String database, + String name, + })? lastSelectedExtensionObject; + final bool isReadOnly; static const empty = MainScreenWorkspaceState(); @@ -79,11 +108,16 @@ class MainScreenWorkspaceState { selectedSqliteObject: selectedSqliteObject, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, selectedExtensionObject: selectedExtensionObject, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: !isReadOnly, ); } MainScreenWorkspaceState selectConnection(ConnectionRow connection) { + final same = activeConnection?.id == connection.id; return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, @@ -97,10 +131,89 @@ class MainScreenWorkspaceState { selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, selectedExtensionObject: null, + lastSelectedPostgresObject: + same ? (selectedPostgresObject ?? lastSelectedPostgresObject) : null, + lastSelectedMysqlObject: + same ? (selectedMysqlObject ?? lastSelectedMysqlObject) : null, + lastSelectedSqliteObject: + same ? (selectedSqliteObject ?? lastSelectedSqliteObject) : null, + lastSelectedExtensionObject: same + ? (selectedExtensionObject ?? lastSelectedExtensionObject) + : null, isReadOnly: false, ); } + /// Clears the active table/view selection to show server stats/home, but retains + /// the reference in [lastSelectedPostgresObject] etc. so users can return in 1 click. + MainScreenWorkspaceState unselectActiveObject() { + return MainScreenWorkspaceState( + activeConnection: activeConnection, + activeRedisDb: activeRedisDb, + activeMongoDB: activeMongoDB, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: postgresSqlEditorContext, + postgresSqlEditorContextToken: postgresSqlEditorContextToken, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + selectedSqliteObject: null, + sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + selectedExtensionObject: null, + lastSelectedPostgresObject: + selectedPostgresObject ?? lastSelectedPostgresObject, + lastSelectedMysqlObject: + selectedMysqlObject ?? lastSelectedMysqlObject, + lastSelectedSqliteObject: + selectedSqliteObject ?? lastSelectedSqliteObject, + lastSelectedExtensionObject: + selectedExtensionObject ?? lastSelectedExtensionObject, + isReadOnly: isReadOnly, + ); + } + + /// Restores the last visited table/view for the active connection. + MainScreenWorkspaceState restoreLastSelectedObject() { + final conn = activeConnection; + if (conn == null) return this; + if (lastSelectedPostgresObject != null) { + final obj = lastSelectedPostgresObject!; + return selectPostgresObject( + conn, + obj.database, + obj.schema, + obj.name, + obj.kind, + ); + } + if (lastSelectedMysqlObject != null) { + final obj = lastSelectedMysqlObject!; + return selectMysqlObject( + conn, + obj.database, + obj.name, + obj.kind, + ); + } + if (lastSelectedSqliteObject != null) { + final obj = lastSelectedSqliteObject!; + return selectSqliteObject( + conn, + obj.name, + obj.kind, + ); + } + if (lastSelectedExtensionObject != null) { + final obj = lastSelectedExtensionObject!; + return selectExtensionObject( + conn, + obj.database, + obj.name, + ); + } + return this; + } + MainScreenWorkspaceState selectPostgresObject( ConnectionRow connection, String database, @@ -108,16 +221,17 @@ class MainScreenWorkspaceState { String name, PostgresObjectKind kind, ) { + final pg = ( + database: database, + schema: schema, + name: name, + kind: kind, + ); return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, activeMongoDB: null, - selectedPostgresObject: ( - database: database, - schema: schema, - name: name, - kind: kind, - ), + selectedPostgresObject: pg, postgresSqlTabRequestToken: postgresSqlTabRequestToken, postgresSqlEditorContext: null, postgresSqlEditorContextToken: 0, @@ -125,6 +239,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: pg, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -135,6 +253,11 @@ class MainScreenWorkspaceState { String name, MysqlObjectKind kind, ) { + final my = ( + database: database, + name: name, + kind: kind, + ); return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, @@ -143,14 +266,14 @@ class MainScreenWorkspaceState { postgresSqlTabRequestToken: postgresSqlTabRequestToken, postgresSqlEditorContext: null, postgresSqlEditorContextToken: 0, - selectedMysqlObject: ( - database: database, - name: name, - kind: kind, - ), + selectedMysqlObject: my, mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: my, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -160,6 +283,10 @@ class MainScreenWorkspaceState { String name, SqliteObjectKind kind, ) { + final sq = ( + name: name, + kind: kind, + ); return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, @@ -170,11 +297,12 @@ class MainScreenWorkspaceState { postgresSqlEditorContextToken: 0, selectedMysqlObject: null, mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, - selectedSqliteObject: ( - name: name, - kind: kind, - ), + selectedSqliteObject: sq, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: sq, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -184,6 +312,10 @@ class MainScreenWorkspaceState { String database, String name, ) { + final ext = ( + database: database, + name: name, + ); return MainScreenWorkspaceState( activeConnection: connection, activeRedisDb: null, @@ -196,10 +328,11 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, - selectedExtensionObject: ( - database: database, - name: name, - ), + selectedExtensionObject: ext, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: ext, isReadOnly: isReadOnly, ); } @@ -217,6 +350,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -235,6 +372,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -294,6 +435,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: seed ?? lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -311,6 +456,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken + 1, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -328,6 +477,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken + 1, + lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedExtensionObject: lastSelectedExtensionObject, isReadOnly: isReadOnly, ); } @@ -349,6 +502,11 @@ class MainScreenWorkspaceState { sqliteSqlTabRequestToken == other.sqliteSqlTabRequestToken && _extensionEquals( selectedExtensionObject, other.selectedExtensionObject) && + _pgEquals(lastSelectedPostgresObject, other.lastSelectedPostgresObject) && + _mysqlEquals(lastSelectedMysqlObject, other.lastSelectedMysqlObject) && + _sqliteEquals(lastSelectedSqliteObject, other.lastSelectedSqliteObject) && + _extensionEquals( + lastSelectedExtensionObject, other.lastSelectedExtensionObject) && isReadOnly == other.isReadOnly; } diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index a8bbf4d..c91e0de 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material Alignment, Container, EdgeInsets, + Offset, Padding, Center, Icon, @@ -11,8 +12,7 @@ import 'package:flutter/material.dart' as material MainAxisSize, SizedBox, Widget, - Column, - Offset; + Column; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; @@ -55,6 +55,12 @@ class WorkspacePanel extends StatefulWidget { this.sqliteSqlTabRequestToken = 0, this.selectedExtensionObject, this.extensionSqlTabRequestToken = 0, + this.lastSelectedPostgresObject, + this.lastSelectedMysqlObject, + this.lastSelectedSqliteObject, + this.lastSelectedExtensionObject, + this.onNavigateHome, + this.onRestoreLastSelectedObject, this.isReadOnly = false, this.onRequestNewConnection, this.onRequestNewConnectionFromUrl, @@ -125,6 +131,36 @@ class WorkspacePanel extends StatefulWidget { /// Incremented by [MainScreen] to switch the Extension home view to the SQL tab. final int extensionSqlTabRequestToken; + /// Last selected objects for 1-click return from stats. + final ({ + String database, + String schema, + String name, + PostgresObjectKind kind + })? lastSelectedPostgresObject; + + final ({ + String database, + String name, + MysqlObjectKind kind + })? lastSelectedMysqlObject; + + final ({ + String name, + SqliteObjectKind kind + })? lastSelectedSqliteObject; + + final ({ + String database, + String name, + })? lastSelectedExtensionObject; + + /// Callback to return to the active connection's stats / overview home. + final VoidCallback? onNavigateHome; + + /// Callback to restore the last selected table / object view. + final VoidCallback? onRestoreLastSelectedObject; + /// Empty-state hero: primary CTA to add a connection. final void Function()? onRequestNewConnection; final void Function()? onRequestNewConnectionFromUrl; @@ -206,6 +242,8 @@ class _WorkspacePanelState extends State { postgresSqlEditorContextToken: widget.postgresSqlEditorContextToken, sqlTabRequestToken: widget.postgresSqlTabRequestToken, + lastSelectedPostgresObject: widget.lastSelectedPostgresObject, + onRestoreLastSelectedObject: widget.onRestoreLastSelectedObject, isReadOnly: widget.isReadOnly, ), object: pg == null @@ -213,6 +251,7 @@ class _WorkspacePanelState extends State { : buildPostgresObjectWorkspace( connection: activeConn, pg: pg, + onNavigateHome: widget.onNavigateHome, ), ); break; @@ -240,6 +279,7 @@ class _WorkspacePanelState extends State { database: my.database, tableName: my.name, isView: my.kind == MysqlObjectKind.view, + onNavigateHome: widget.onNavigateHome, ); } } @@ -249,6 +289,8 @@ class _WorkspacePanelState extends State { key: ValueKey('mysql_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.mysqlSqlTabRequestToken, + lastSelectedMysqlObject: widget.lastSelectedMysqlObject, + onRestoreLastSelectedObject: widget.onRestoreLastSelectedObject, isReadOnly: widget.isReadOnly, ), object: mysqlObject, @@ -296,6 +338,8 @@ class _WorkspacePanelState extends State { key: ValueKey('sqlite_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.sqliteSqlTabRequestToken, + lastSelectedSqliteObject: widget.lastSelectedSqliteObject, + onRestoreLastSelectedObject: widget.onRestoreLastSelectedObject, isReadOnly: widget.isReadOnly, ), object: sq == null @@ -307,6 +351,7 @@ class _WorkspacePanelState extends State { connectionRow: activeConn, tableName: sq.name, isView: sq.kind == SqliteObjectKind.view, + onNavigateHome: widget.onNavigateHome, ), ); break; @@ -319,6 +364,8 @@ class _WorkspacePanelState extends State { key: ValueKey('ext_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.extensionSqlTabRequestToken, + lastSelectedExtensionObject: widget.lastSelectedExtensionObject, + onRestoreLastSelectedObject: widget.onRestoreLastSelectedObject, isReadOnly: widget.isReadOnly, ), object: obj == null @@ -330,6 +377,7 @@ class _WorkspacePanelState extends State { connectionRow: activeConn, database: obj.database, tableName: obj.name, + onNavigateHome: widget.onNavigateHome, ), ); } diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index c3a116a..ff84b44 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -20,6 +20,7 @@ class MysqlTableView extends material.StatefulWidget { required this.tableName, this.isView = false, this.limit = _defaultLimit, + this.onNavigateHome, }); final ConnectionRow connectionRow; @@ -27,6 +28,7 @@ class MysqlTableView extends material.StatefulWidget { final String tableName; final bool isView; final int limit; + final VoidCallback? onNavigateHome; @override material.State createState() => _MysqlTableViewState(); @@ -474,6 +476,21 @@ class _MysqlTableViewState extends material.State { ), child: material.Row( children: [ + if (widget.onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to server overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: widget.onNavigateHome, + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, + ), + child: const Text('Server'), + ), + ), + const Gap(10), + ], material.Icon( widget.isView ? material.Icons.view_agenda_rounded @@ -557,7 +574,7 @@ class _MysqlTableViewState extends material.State { material.Icons.chevron_left_rounded, size: 16, ), - child: const Text('Back'), + child: const Text('Prev'), ), const Gap(4), OutlineButton( diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index 4d8993e..91096b8 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -3,6 +3,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/mysql/mysql_sql_workspace.dart'; import 'package:querya_desktop/features/mysql/mysql_stats_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -13,12 +14,22 @@ class MysqlWorkspaceHome extends material.StatefulWidget { super.key, required this.connectionRow, this.sqlTabRequestToken = 0, + this.lastSelectedMysqlObject, + this.onRestoreLastSelectedObject, this.isReadOnly = false, }); final ConnectionRow connectionRow; final bool isReadOnly; + /// Remembers the last visited table/view for 1-click return. + final ({ + String database, + String name, + MysqlObjectKind kind + })? lastSelectedMysqlObject; + final VoidCallback? onRestoreLastSelectedObject; + /// Parent increments to switch to the SQL tab (e.g. context menu on connection). final int sqlTabRequestToken; @@ -82,6 +93,19 @@ class _MysqlWorkspaceHomeState extends material.State { color: theme.colorScheme.mutedForeground, ), ], + if (widget.lastSelectedMysqlObject != null && + widget.onRestoreLastSelectedObject != null) ...[ + const Gap(12), + OutlineButton( + size: ButtonSize.small, + onPressed: widget.onRestoreLastSelectedObject, + leading: const material.Icon( + material.Icons.table_chart_outlined, + size: 14, + ), + child: Text('Return to ${widget.lastSelectedMysqlObject!.name}'), + ), + ], const Spacer(), QueryaTabStrip( labels: const ['Server', 'SQL'], diff --git a/lib/features/postgresql/postgres_object_workspace.dart b/lib/features/postgresql/postgres_object_workspace.dart index 5ddab61..117e637 100644 --- a/lib/features/postgresql/postgres_object_workspace.dart +++ b/lib/features/postgresql/postgres_object_workspace.dart @@ -15,6 +15,7 @@ Widget buildPostgresObjectWorkspace({ String name, PostgresObjectKind kind }) pg, + VoidCallback? onNavigateHome, }) { switch (pg.kind) { case PostgresObjectKind.table: @@ -28,6 +29,7 @@ Widget buildPostgresObjectWorkspace({ tableName: pg.name, isView: false, isMaterializedView: false, + onNavigateHome: onNavigateHome, ); case PostgresObjectKind.view: return PostgresTableView( @@ -40,6 +42,7 @@ Widget buildPostgresObjectWorkspace({ tableName: pg.name, isView: true, isMaterializedView: false, + onNavigateHome: onNavigateHome, ); case PostgresObjectKind.materializedView: return PostgresTableView( @@ -52,6 +55,7 @@ Widget buildPostgresObjectWorkspace({ tableName: pg.name, isView: false, isMaterializedView: true, + onNavigateHome: onNavigateHome, ); case PostgresObjectKind.function: return PostgresRoutineView( diff --git a/lib/features/postgresql/postgres_table_toolbar.dart b/lib/features/postgresql/postgres_table_toolbar.dart index 20c1b50..698f0fa 100644 --- a/lib/features/postgresql/postgres_table_toolbar.dart +++ b/lib/features/postgresql/postgres_table_toolbar.dart @@ -20,6 +20,7 @@ class PostgresTableToolbar extends material.StatelessWidget { required this.onGoPrevious, required this.onGoNext, required this.onRefresh, + this.onNavigateHome, }); final String title; @@ -37,6 +38,7 @@ class PostgresTableToolbar extends material.StatelessWidget { final VoidCallback onGoPrevious; final VoidCallback onGoNext; final VoidCallback onRefresh; + final VoidCallback? onNavigateHome; @override material.Widget build(material.BuildContext context) { @@ -53,6 +55,21 @@ class PostgresTableToolbar extends material.StatelessWidget { ), child: material.Row( children: [ + if (onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to server overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: onNavigateHome, + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, + ), + child: const Text('Server'), + ), + ), + const Gap(10), + ], material.Icon(tableIcon, size: 18, color: cs.primary), const Gap(8), material.Expanded( @@ -151,7 +168,7 @@ class PostgresTableToolbar extends material.StatelessWidget { material.Icons.chevron_left_rounded, size: 16, ), - child: const Text('Back'), + child: const Text('Prev'), ), const Gap(4), OutlineButton( diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 5a85e20..78b8a57 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -19,6 +19,7 @@ class PostgresTableView extends material.StatefulWidget { this.isView = false, this.isMaterializedView = false, this.limit = kPostgresBrowseDefaultRowLimit, + this.onNavigateHome, }); final ConnectionRow connectionRow; @@ -26,6 +27,7 @@ class PostgresTableView extends material.StatefulWidget { final String schema; final String tableName; final bool isView; + final VoidCallback? onNavigateHome; /// When true, toolbar offers REFRESH MATERIALIZED VIEW and matview label. final bool isMaterializedView; @@ -515,6 +517,7 @@ class _PostgresTableViewState extends material.State { loading: _loading, canGoPrevious: _canGoPrevious, canGoNext: _canGoNext, + onNavigateHome: widget.onNavigateHome, onOpenSql: _openSqlEditor, onOpenPrivileges: _openPrivileges, onRefreshMaterializedView: _refreshMaterializedView, diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index 95534b9..efe5d12 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -16,12 +16,23 @@ class PostgresWorkspaceHome extends material.StatefulWidget { this.postgresSqlEditorContext, this.postgresSqlEditorContextToken = 0, this.sqlTabRequestToken = 0, + this.lastSelectedPostgresObject, + this.onRestoreLastSelectedObject, this.isReadOnly = false, }); final ConnectionRow connectionRow; final bool isReadOnly; + /// Remembers the last visited table/view for 1-click return. + final ({ + String database, + String schema, + String name, + PostgresObjectKind kind + })? lastSelectedPostgresObject; + final VoidCallback? onRestoreLastSelectedObject; + /// Set when opening SQL from the tree (e.g. "Open in SQL") to seed session DB + template. final ({ String database, @@ -128,6 +139,19 @@ class _PostgresWorkspaceHomeState color: theme.colorScheme.mutedForeground, ), ], + if (widget.lastSelectedPostgresObject != null && + widget.onRestoreLastSelectedObject != null) ...[ + const Gap(12), + OutlineButton( + size: ButtonSize.small, + onPressed: widget.onRestoreLastSelectedObject, + leading: const material.Icon( + material.Icons.table_chart_outlined, + size: 14, + ), + child: Text('Return to ${widget.lastSelectedPostgresObject!.name}'), + ), + ], const Spacer(), QueryaTabStrip( labels: const ['Server', 'SQL'], diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 8809598..7007bef 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -14,12 +14,14 @@ class SqliteTableView extends material.StatefulWidget { required this.tableName, this.isView = false, this.limit = _defaultLimit, + this.onNavigateHome, }); final ConnectionRow connectionRow; final String tableName; final bool isView; final int limit; + final VoidCallback? onNavigateHome; @override material.State createState() => _SqliteTableViewState(); @@ -449,6 +451,21 @@ class _SqliteTableViewState extends material.State { ), child: material.Row( children: [ + if (widget.onNavigateHome != null) ...[ + material.Tooltip( + message: 'Return to overview', + child: OutlineButton( + size: ButtonSize.small, + onPressed: widget.onNavigateHome, + leading: const material.Icon( + material.Icons.dns_outlined, + size: 14, + ), + child: const Text('Overview'), + ), + ), + const Gap(10), + ], material.Icon( widget.isView ? material.Icons.view_agenda_rounded @@ -530,7 +547,7 @@ class _SqliteTableViewState extends material.State { material.Icons.chevron_left_rounded, size: 16, ), - child: const Text('Back'), + child: const Text('Prev'), ), const Gap(4), OutlineButton( diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index 5eb50af..4530700 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/storage/local_db.dart' show ConnectionRow; +import 'package:querya_desktop/features/connections/connections_panel.dart' + show SqliteObjectKind; import 'package:querya_desktop/features/sqlite/sqlite_overview_tab.dart'; import 'package:querya_desktop/features/sqlite/sqlite_sql_workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -10,6 +12,8 @@ class SqliteWorkspaceHome extends material.StatefulWidget { super.key, required this.connectionRow, this.sqlTabRequestToken = 0, + this.lastSelectedSqliteObject, + this.onRestoreLastSelectedObject, this.isReadOnly = false, }); @@ -17,6 +21,13 @@ class SqliteWorkspaceHome extends material.StatefulWidget { final int sqlTabRequestToken; final bool isReadOnly; + /// Remembers the last visited table/view for 1-click return. + final ({ + String name, + SqliteObjectKind kind + })? lastSelectedSqliteObject; + final VoidCallback? onRestoreLastSelectedObject; + @override material.State createState() => _SqliteWorkspaceHomeState(); @@ -77,6 +88,19 @@ class _SqliteWorkspaceHomeState extends material.State { color: theme.colorScheme.mutedForeground, ), ], + if (widget.lastSelectedSqliteObject != null && + widget.onRestoreLastSelectedObject != null) ...[ + const Gap(12), + OutlineButton( + size: ButtonSize.small, + onPressed: widget.onRestoreLastSelectedObject, + leading: const material.Icon( + material.Icons.table_chart_outlined, + size: 14, + ), + child: Text('Return to ${widget.lastSelectedSqliteObject!.name}'), + ), + ], const Spacer(), QueryaTabStrip( labels: const ['Overview', 'SQL'], diff --git a/test/features/extensions/extension_table_toolbar_test.dart b/test/features/extensions/extension_table_toolbar_test.dart index c7e6645..44dd04c 100644 --- a/test/features/extensions/extension_table_toolbar_test.dart +++ b/test/features/extensions/extension_table_toolbar_test.dart @@ -41,7 +41,7 @@ void main() { expect(find.text('Rows 1–200 of 5,000'), findsOneWidget); expect(find.text('DDL'), findsOneWidget); expect(find.text('Filter'), findsOneWidget); - expect(find.text('Back'), findsOneWidget); + expect(find.text('Prev'), findsOneWidget); expect(find.text('Next'), findsOneWidget); expect(find.text('Refresh'), findsOneWidget); @@ -53,8 +53,8 @@ void main() { await tester.tap(find.text('Filter')); expect(filterToggled, isTrue); - await tester.ensureVisible(find.text('Back')); - await tester.tap(find.text('Back'), warnIfMissed: false); + await tester.ensureVisible(find.text('Prev')); + await tester.tap(find.text('Prev'), warnIfMissed: false); expect(prevClicked, isTrue); await tester.ensureVisible(find.text('Next')); diff --git a/test/features/main_screen/main_screen_workspace_state_test.dart b/test/features/main_screen/main_screen_workspace_state_test.dart index 411eac6..dd33190 100644 --- a/test/features/main_screen/main_screen_workspace_state_test.dart +++ b/test/features/main_screen/main_screen_workspace_state_test.dart @@ -184,5 +184,35 @@ void main() { state = state.selectConnection(mysqlConn); expect(state.isReadOnly, isFalse); }); + + test('unselectActiveObject and restoreLastSelectedObject for fluid return', + () { + final withTable = MainScreenWorkspaceState.empty.selectPostgresObject( + pgConn, + 'warehouse', + 'public', + 'stock', + PostgresObjectKind.table, + ); + expect(withTable.selectedPostgresObject?.name, 'stock'); + expect(withTable.lastSelectedPostgresObject?.name, 'stock'); + + final unselected = withTable.unselectActiveObject(); + expect(unselected.selectedPostgresObject, isNull); + expect(unselected.lastSelectedPostgresObject?.name, 'stock'); + + final restored = unselected.restoreLastSelectedObject(); + expect(restored.selectedPostgresObject?.name, 'stock'); + expect(restored.selectedPostgresObject?.schema, 'public'); + + // Selecting the same connection keeps lastSelected + final reselectedSame = withTable.selectConnection(pgConn); + expect(reselectedSame.selectedPostgresObject, isNull); + expect(reselectedSame.lastSelectedPostgresObject?.name, 'stock'); + + // Selecting a different connection clears lastSelected + final diffConn = reselectedSame.selectConnection(mysqlConn); + expect(diffConn.lastSelectedPostgresObject, isNull); + }); }); } diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index 114388a..4273efe 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -4,6 +4,8 @@ import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/main_screen/workspace_panel.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart' + show SqliteObjectKind; import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; @@ -280,5 +282,42 @@ void main() { ); expect(homeObjectSwitchingBody.slide, material.Offset.zero); }); + + testWidgets( + 'SqliteWorkspaceHome renders quick return chip when lastSelectedSqliteObject present', + (tester) async { + const sqConn = ConnectionRow( + id: 10, + type: 'sqlite', + name: 'sqlite-main', + host: ':memory:', + port: 0, + createdAt: '0', + ); + var restored = false; + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: material.SizedBox.expand( + child: WorkspacePanel( + activeConnection: sqConn, + lastSelectedSqliteObject: ( + name: 'users', + kind: SqliteObjectKind.table, + ), + onRestoreLastSelectedObject: () => restored = true, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final returnButton = find.text('Return to users'); + expect(returnButton, findsOneWidget); + await tester.tap(returnButton); + expect(restored, isTrue); + }); }); } From adc1aef848bf86f898a4fb900b61827c39cd502b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 29 Aug 2026 10:30:06 +0300 Subject: [PATCH 26/47] fix(ux): add fluid transitions and persistent breadcrumbs to Mongo and Redis (#632) --- lib/features/mongodb/mongo_explorer_view.dart | 41 ++++++++++++------- lib/features/redis/redis_explorer_view.dart | 40 ++++++++++++------ 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index e38a446..08b7bdc 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -20,7 +21,7 @@ class _Crumb { final _Level level; } -enum _Level { databases, collections, documents, document } +enum _Level { databases, collections, documents, document, stats } // ─── Main explorer widget ─────────────────────────────────────────────────── @@ -174,10 +175,16 @@ class _MongoExplorerViewState extends material.State { final id = _selectedDocument!['_id']?.toString() ?? 'Document'; list.add(_Crumb(id, _Level.document)); } + if (_showStats) { + list.add(const _Crumb('Statistics', _Level.stats)); + } return list; } void _onCrumbTap(_Crumb crumb) { + if (_showStats && crumb.level != _Level.stats) { + setState(() => _showStats = false); + } switch (crumb.level) { case _Level.databases: _navigateToDatabases(); @@ -187,6 +194,8 @@ class _MongoExplorerViewState extends material.State { _navigateToDocuments(); case _Level.document: break; // Already on the document + case _Level.stats: + break; } } @@ -247,16 +256,6 @@ class _MongoExplorerViewState extends material.State { final conn = _connection; if (conn == null) return const material.SizedBox.shrink(); - // Statistics mode — render MongoStatsView full-screen - if (_showStats) { - return MongoStatsView( - key: ValueKey('stats_${widget.connectionRow.id}'), - connectionRow: widget.connectionRow, - connection: conn, - onBack: () => setState(() => _showStats = false), - ); - } - return material.Container( color: cs.background, child: material.Column( @@ -267,11 +266,25 @@ class _MongoExplorerViewState extends material.State { crumbs: _crumbs, onCrumbTap: _onCrumbTap, onRefresh: () => setState(() => _refreshToken++), - onStats: () => setState(() => _showStats = true), + onStats: () => setState(() => _showStats = !_showStats), ), const Divider(height: 1), - // Content - material.Expanded(child: _buildContent(conn)), + // Content with fluid cross-fade morph between explorer and stats + material.Expanded( + child: QueryaSwitchingBody( + slide: material.Offset.zero, + index: _showStats ? 1 : 0, + children: [ + _buildContent(conn), + MongoStatsView( + key: ValueKey('stats_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + connection: conn, + onBack: () => setState(() => _showStats = false), + ), + ], + ), + ), ], ), ); diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index af037da..5a5e163 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; +import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -17,7 +18,7 @@ class _Crumb { final _Level level; } -enum _Level { keys, key } +enum _Level { keys, key, stats } // ─── Main explorer widget ─────────────────────────────────────────────────── @@ -138,15 +139,23 @@ class _RedisExplorerViewState extends material.State { if (_selectedKey != null) { list.add(_Crumb(_selectedKey!, _Level.key)); } + if (_showStats) { + list.add(const _Crumb('Statistics', _Level.stats)); + } return list; } void _onCrumbTap(_Crumb crumb) { + if (_showStats && crumb.level != _Level.stats) { + setState(() => _showStats = false); + } switch (crumb.level) { case _Level.keys: _navigateToKeys(); case _Level.key: break; + case _Level.stats: + break; } } @@ -207,16 +216,6 @@ class _RedisExplorerViewState extends material.State { final conn = _connection; if (conn == null) return const material.SizedBox.shrink(); - // Statistics mode - if (_showStats) { - return RedisView( - key: ValueKey('stats_${widget.connectionRow.id}'), - connectionRow: widget.connectionRow, - connection: conn, - onBack: () => setState(() => _showStats = false), - ); - } - return material.Container( color: cs.background, child: material.Column( @@ -226,10 +225,25 @@ class _RedisExplorerViewState extends material.State { crumbs: _crumbs, onCrumbTap: _onCrumbTap, onRefresh: () => setState(() => _refreshEpoch++), - onStats: () => setState(() => _showStats = true), + onStats: () => setState(() => _showStats = !_showStats), ), const Divider(height: 1), - material.Expanded(child: _buildContent(conn)), + // Content with fluid cross-fade morph between keys and stats + material.Expanded( + child: QueryaSwitchingBody( + slide: material.Offset.zero, + index: _showStats ? 1 : 0, + children: [ + _buildContent(conn), + RedisView( + key: ValueKey('stats_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + connection: conn, + onBack: () => setState(() => _showStats = false), + ), + ], + ), + ), ], ), ); From 7df9cb9e3d8454f6f0abaf06a288de80574877c6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 29 Aug 2026 10:30:40 +0300 Subject: [PATCH 27/47] feat(tree): sync active table/view selection to connections sidebar tree (#633) --- .../connections/connections_panel.dart | 74 ++++++++++++- .../connections/connections_panel_mongo.dart | 5 + .../connections/connections_panel_mysql.dart | 8 ++ .../connections_panel_pg_tree.dart | 104 ++++++++++++------ .../connections/connections_panel_redis.dart | 8 ++ .../connections/connections_panel_sqlite.dart | 7 ++ lib/features/main_screen/main_screen.dart | 17 +-- .../connections_panel_layout_test.dart | 25 +++++ 8 files changed, 202 insertions(+), 46 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 32131f0..7da3ddb 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -53,7 +53,8 @@ import 'package:flutter/material.dart' as material ListView, ClampingScrollPhysics, CallbackShortcuts, - SingleActivator; + SingleActivator, + AnimatedContainer; import 'package:flutter/services.dart' show Clipboard, ClipboardData, LogicalKeyboardKey; import 'package:querya_desktop/core/database/mongodb_service.dart'; @@ -155,11 +156,57 @@ material.Widget lazyConnectionTreeList({ ); } +/// Inherited scope providing the active connection and object selection down the connections tree. +class _ConnectionsTreeSelectionScope extends InheritedWidget { + const _ConnectionsTreeSelectionScope({ + required this.selectedConnectionId, + required this.selectedPostgresObject, + required this.selectedMysqlObject, + required this.selectedSqliteObject, + required this.selectedExtensionObject, + required this.selectedRedisDb, + required this.selectedMongoDb, + required super.child, + }); + + final int? selectedConnectionId; + final ({String database, String schema, String name, PostgresObjectKind kind})? + selectedPostgresObject; + final ({String database, String name, MysqlObjectKind kind})? + selectedMysqlObject; + final ({String name, SqliteObjectKind kind})? selectedSqliteObject; + final ({String database, String name})? selectedExtensionObject; + final int? selectedRedisDb; + final String? selectedMongoDb; + + static _ConnectionsTreeSelectionScope? of(material.BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_ConnectionsTreeSelectionScope>(); + } + + @override + bool updateShouldNotify(_ConnectionsTreeSelectionScope oldWidget) { + return selectedConnectionId != oldWidget.selectedConnectionId || + selectedPostgresObject != oldWidget.selectedPostgresObject || + selectedMysqlObject != oldWidget.selectedMysqlObject || + selectedSqliteObject != oldWidget.selectedSqliteObject || + selectedExtensionObject != oldWidget.selectedExtensionObject || + selectedRedisDb != oldWidget.selectedRedisDb || + selectedMongoDb != oldWidget.selectedMongoDb; + } +} + /// Left panel: Browser tree (pgAdmin-style). Uses shadcn layout widgets. class ConnectionsPanel extends StatefulWidget { const ConnectionsPanel({ super.key, this.selectedConnectionId, + this.selectedPostgresObject, + this.selectedMysqlObject, + this.selectedSqliteObject, + this.selectedExtensionObject, + this.selectedRedisDb, + this.selectedMongoDb, this.onConnectionSelected, this.onRedisDatabaseSelected, this.onMongoDBDatabaseSelected, @@ -181,6 +228,16 @@ class ConnectionsPanel extends StatefulWidget { /// Highlights the active connection row in the sidebar (workspace selection). final int? selectedConnectionId; + /// Currently selected database objects (table, view, routine, db). + final ({String database, String schema, String name, PostgresObjectKind kind})? + selectedPostgresObject; + final ({String database, String name, MysqlObjectKind kind})? + selectedMysqlObject; + final ({String name, SqliteObjectKind kind})? selectedSqliteObject; + final ({String database, String name})? selectedExtensionObject; + final int? selectedRedisDb; + final String? selectedMongoDb; + /// Called when the user taps a connection tile. final void Function(ConnectionRow connection)? onConnectionSelected; @@ -551,7 +608,15 @@ class ConnectionsPanelState extends State { final topLevelCount = _folders.length + rootConnections.length + (showEmptyState ? 1 : 0); - return material.Container( + return _ConnectionsTreeSelectionScope( + selectedConnectionId: widget.selectedConnectionId, + selectedPostgresObject: widget.selectedPostgresObject, + selectedMysqlObject: widget.selectedMysqlObject, + selectedSqliteObject: widget.selectedSqliteObject, + selectedExtensionObject: widget.selectedExtensionObject, + selectedRedisDb: widget.selectedRedisDb, + selectedMongoDb: widget.selectedMongoDb, + child: material.Container( decoration: material.BoxDecoration( color: theme.colorScheme.background, border: material.Border( @@ -689,6 +754,7 @@ class ConnectionsPanelState extends State { ), ], ), - ); - } + ), + ); +} } diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 4db6c55..5746839 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -407,10 +407,15 @@ class _MongoDatabaseNode extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final sel = _ConnectionsTreeSelectionScope.of(context); + final isSelected = sel != null && + sel.selectedConnectionId == connection.id && + sel.selectedMongoDb == name; return material.Padding( padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), child: _PgTreeRow( label: name, + isSelected: isSelected, icon: QueryaIcons.database, iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 3dbbd6e..9d538d3 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -687,11 +687,19 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; + final sel = _ConnectionsTreeSelectionScope.of(context); + final isSelected = sel != null && + sel.selectedConnectionId == widget.connection.id && + sel.selectedMysqlObject != null && + sel.selectedMysqlObject!.database == widget.databaseName && + sel.selectedMysqlObject!.name == item && + sel.selectedMysqlObject!.kind == widget.objectKind; return _PgTreeRow( key: material.ValueKey( 'mysql-${widget.objectKind.name}-${widget.databaseName}-$item', ), label: item, + isSelected: isSelected, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, iconColor: QueryaTreeTokens.leafIconColor( diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 68917cd..5282567 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -59,6 +59,7 @@ class _PgTreeRow extends material.StatelessWidget { const _PgTreeRow({ super.key, required this.label, + this.isSelected = false, this.leading, this.icon, this.iconSize = QueryaIconSizes.treeGroup, @@ -80,6 +81,7 @@ class _PgTreeRow extends material.StatelessWidget { }); final String label; + final bool isSelected; final material.Widget? leading; final material.IconData? icon; final double iconSize; @@ -115,43 +117,67 @@ class _PgTreeRow extends material.StatelessWidget { const material.SingleActivator(LogicalKeyboardKey.arrowLeft): () => onTap!(), }, - child: material.Material( - color: material.Colors.transparent, - child: material.InkWell( - onTap: onTap, - canRequestFocus: onTap != null, + child: material.AnimatedContainer( + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), + decoration: material.BoxDecoration( + color: isSelected + ? primary.withValues(alpha: 0.12) + : material.Colors.transparent, borderRadius: material.BorderRadius.circular(4), - hoverColor: primary.withValues(alpha: 0.07), - focusColor: primary.withValues(alpha: 0.14), - splashColor: primary.withValues(alpha: 0.10), - highlightColor: primary.withValues(alpha: 0.05), - mouseCursor: onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.Padding( - padding: material.EdgeInsets.symmetric( - horizontal: 4, - vertical: verticalPadding, - ), - child: material.Row( - children: [ - if (leading != null) ...[ - leading!, - const Gap(4), - ], - if (icon != null) ...[ - material.Icon( - icon, - size: iconSize, - color: iconColor ?? muted, + border: isSelected + ? material.Border.all( + color: primary.withValues(alpha: 0.35), + width: 1, + ) + : null, + ), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + canRequestFocus: onTap != null, + borderRadius: material.BorderRadius.circular(4), + hoverColor: primary.withValues(alpha: 0.07), + focusColor: primary.withValues(alpha: 0.14), + splashColor: primary.withValues(alpha: 0.10), + highlightColor: primary.withValues(alpha: 0.05), + mouseCursor: onTap != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.Padding( + padding: material.EdgeInsets.symmetric( + horizontal: 4, + vertical: verticalPadding, + ), + child: material.Row( + children: [ + if (leading != null) ...[ + leading!, + const Gap(4), + ], + if (icon != null) ...[ + material.Icon( + icon, + size: iconSize, + color: isSelected ? primary : (iconColor ?? muted), + ), + const Gap(6), + ], + material.Expanded( + child: _PgTreeRowLabel( + label: label, + textStyle: isSelected + ? textStyle.copyWith( + fontWeight: material.FontWeight.w600, + color: theme.colorScheme.foreground, + ) + : textStyle, + ), ), - const Gap(6), + if (trailing != null) trailing!, ], - material.Expanded( - child: _PgTreeRowLabel(label: label, textStyle: textStyle), - ), - if (trailing != null) trailing!, - ], + ), ), ), ), @@ -1119,11 +1145,21 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; + final sel = _ConnectionsTreeSelectionScope.of(context); + final isSelected = sel != null && + sel.selectedConnectionId == widget.connection.id && + sel.selectedPostgresObject != null && + sel.selectedPostgresObject!.database == + widget.databaseName && + sel.selectedPostgresObject!.schema == widget.schemaName && + sel.selectedPostgresObject!.name == item && + sel.selectedPostgresObject!.kind == widget.objectKind; return _PgTreeRow( key: material.ValueKey( 'pg-${widget.objectKind.name}-${widget.databaseName}-${widget.schemaName}-$item', ), label: item, + isSelected: isSelected, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, iconColor: QueryaTreeTokens.leafIconColor( diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 0ea0f9c..752bdfa 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -347,6 +347,7 @@ class _RedisDatabasesNode extends material.StatelessWidget { itemBuilder: (context, index) { final db = databases[index]; return _RedisDatabaseNode( + connection: connection, index: db.index, keys: db.keys, onTap: () => onDatabaseTap?.call(db.index), @@ -361,11 +362,13 @@ class _RedisDatabasesNode extends material.StatelessWidget { class _RedisDatabaseNode extends StatelessWidget { const _RedisDatabaseNode({ + required this.connection, required this.index, required this.keys, required this.onTap, }); + final ConnectionRow connection; final int index; final int keys; final VoidCallback onTap; @@ -373,10 +376,15 @@ class _RedisDatabaseNode extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final sel = _ConnectionsTreeSelectionScope.of(context); + final isSelected = sel != null && + sel.selectedConnectionId == connection.id && + sel.selectedRedisDb == index; return material.Padding( padding: const material.EdgeInsets.only(left: 16), child: _PgTreeRow( label: 'db$index', + isSelected: isSelected, icon: QueryaIcons.database, iconSize: QueryaIconSizes.treeConnection, iconColor: keys > 0 diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 033fabd..72fe509 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -415,11 +415,18 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; + final sel = _ConnectionsTreeSelectionScope.of(context); + final isSelected = sel != null && + sel.selectedConnectionId == widget.connection.id && + sel.selectedSqliteObject != null && + sel.selectedSqliteObject!.name == item && + sel.selectedSqliteObject!.kind == widget.objectKind; return _PgTreeRow( key: material.ValueKey( 'sqlite-${widget.objectKind.name}-$item', ), label: item, + isSelected: isSelected, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, iconColor: QueryaTreeTokens.leafIconColor( diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 043088f..553e839 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -1010,12 +1010,9 @@ class _ConnectionsPanelSlot extends StatefulWidget { } class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { - int? _selectedConnectionId; - @override void initState() { super.initState(); - _selectedConnectionId = widget.workspace.value.activeConnection?.id; widget.workspace.addListener(_onWorkspaceChanged); } @@ -1026,17 +1023,21 @@ class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { } void _onWorkspaceChanged() { - final next = widget.workspace.value.activeConnection?.id; - if (next != _selectedConnectionId) { - setState(() => _selectedConnectionId = next); - } + setState(() {}); } @override material.Widget build(material.BuildContext context) { + final ws = widget.workspace.value; return ConnectionsPanel( key: widget.connectionsPanelKey, - selectedConnectionId: _selectedConnectionId, + selectedConnectionId: ws.activeConnection?.id, + selectedPostgresObject: ws.selectedPostgresObject, + selectedMysqlObject: ws.selectedMysqlObject, + selectedSqliteObject: ws.selectedSqliteObject, + selectedExtensionObject: ws.selectedExtensionObject, + selectedRedisDb: ws.activeRedisDb, + selectedMongoDb: ws.activeMongoDB, onConnectionSelected: widget.onConnectionSelected, onRedisDatabaseSelected: widget.onRedisDatabaseSelected, onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, diff --git a/test/features/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart index 4a67b09..ab44b03 100644 --- a/test/features/connections/connections_panel_layout_test.dart +++ b/test/features/connections/connections_panel_layout_test.dart @@ -365,5 +365,30 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); expect(panelState.isConnectionExpanded(id1), true); }); + + testWidgets('ConnectionsPanel propagates selection scope to child tree', + (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(400, 600), + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: const material.SizedBox( + width: 400, + height: 600, + child: ConnectionsPanel( + skipInitialDbLoadForTest: true, + selectedConnectionId: 10, + selectedRedisDb: 0, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(ConnectionsPanel), findsOneWidget); + }); }); } From 1b4b97306533d4172340e14f81cc3377e5d2981b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 16:26:16 +0300 Subject: [PATCH 28/47] sec(extensions): add destructive SQL confirmation dialog for extension workspaces (#638) --- .../extensions/extension_sql_workspace.dart | 27 +++- .../extension_sql_workspace_test.dart | 139 ++++++++++++++++++ 2 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 test/features/extensions/extension_sql_workspace_test.dart diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index ff5a938..ca27a90 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -5,10 +5,12 @@ 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_command_bridge.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/extensions/extension_driver_session.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'; +import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; 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'; @@ -28,10 +30,12 @@ class ExtensionSqlWorkspace extends material.StatefulWidget { super.key, required this.connectionRow, this.selectedObject, + this.initialSql, }); final ConnectionRow connectionRow; final ExtensionSelectedObject? selectedObject; + final String? initialSql; @override material.State createState() => @@ -58,6 +62,9 @@ class _ExtensionSqlWorkspaceState @override void initState() { super.initState(); + if (widget.initialSql != null && widget.initialSql!.isNotEmpty) { + _sqlController.text = widget.initialSql!; + } material.WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_loadWorkspaceSettings()); _applySelectedObject(); @@ -136,6 +143,22 @@ class _ExtensionSqlWorkspaceState } if (userSql.isEmpty) return; + final confirmDestructive = + await AppSettings.instance.getConfirmDestructiveOperations(); + if (confirmDestructive) { + final inspection = DestructiveSqlDetector.inspect(userSql); + if (inspection.isDestructive) { + if (!mounted) return; + final confirmed = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: userSql, + connectionName: widget.connectionRow.name, + ); + if (confirmed != true) return; + } + } + setState(() { _running = true; _error = null; @@ -292,7 +315,7 @@ class _ExtensionSqlWorkspaceState children: [ _ExtensionSqlToolbar( connectionName: widget.connectionRow.name, - onExecute: _running ? null : _execute, + onExecute: _running ? null : () => unawaited(_execute()), running: _running, onOpenSqlFile: () => unawaited(_openSqlFile()), onSaveSqlFile: () => unawaited(_saveSqlFile()), @@ -357,7 +380,7 @@ class _ExtensionSqlToolbar extends material.StatelessWidget { }); final String connectionName; - final Future Function()? onExecute; + final VoidCallback? onExecute; final bool running; final VoidCallback onOpenSqlFile; final VoidCallback onSaveSqlFile; diff --git a/test/features/extensions/extension_sql_workspace_test.dart b/test/features/extensions/extension_sql_workspace_test.dart new file mode 100644 index 0000000..0e7fc16 --- /dev/null +++ b/test/features/extensions/extension_sql_workspace_test.dart @@ -0,0 +1,139 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/extensions/extension_sql_workspace.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; + + @override + Future getApplicationCachePath() async => _root; + + @override + Future getLibraryPath() async => _root; + + @override + Future getExternalStoragePath() async => _root; + + @override + Future?> getExternalCachePaths() async => [_root]; + + @override + Future?> getExternalStoragePaths({StorageDirectory? type}) async => + [_root]; + + @override + Future getDownloadsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_ext_sql_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + }); + + const testConn = ConnectionRow( + id: 99, + type: 'clickhouse-ext', + name: 'ClickHouse Analytics', + host: 'localhost', + port: 8123, + extensionId: 'querya.clickhouse', + createdAt: '2026-08-29T10:00:00Z', + ); + + group('ExtensionSqlWorkspace destructive SQL confirmation', () { + setUp(() async { + await AppSettings.instance.setConfirmDestructiveOperations(true); + }); + + tearDown(() async { + SqlEditorCommandBridge.instance.unregister(connectionId: 99); + await ExtensionDriverSession.instance.disconnectAll(); + }); + + test('DestructiveSqlDetector detects destructive statements in ClickHouse / extension SQL', () { + final dropTable = DestructiveSqlDetector.inspect('DROP TABLE analytics.events;'); + expect(dropTable.isDestructive, isTrue); + expect(dropTable.operations.any((o) => o.type == DestructiveSqlType.dropTable), isTrue); + + final truncateTable = DestructiveSqlDetector.inspect('TRUNCATE TABLE metrics;'); + expect(truncateTable.isDestructive, isTrue); + expect(truncateTable.operations.any((o) => o.type == DestructiveSqlType.truncateTable), isTrue); + + final selectQuery = DestructiveSqlDetector.inspect('SELECT * FROM analytics.events LIMIT 10;'); + expect(selectQuery.isDestructive, isFalse); + }); + + testWidgets('shows Destructive Operation Detected dialog when running DROP TABLE and dismisses on Cancel', (tester) async { + await tester.binding.setSurfaceSize(const material.Size(1024, 768)); + + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: ExtensionSqlWorkspace( + connectionRow: testConn, + initialSql: 'DROP TABLE analytics.raw_hits;', + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Find Execute button in SQL editor chrome + final executeBtn = find.widgetWithText(OutlineButton, 'Execute (F5)'); + expect(executeBtn, findsOneWidget); + + await tester.tap(executeBtn); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pumpAndSettle(); + + // Confirmation dialog must appear + expect(find.text('Destructive Operation Detected'), findsOneWidget); + expect(find.text('Target connection: ClickHouse Analytics'), findsOneWidget); + expect(find.text('DROP TABLE'), findsOneWidget); + expect(find.text('DROP TABLE analytics.raw_hits;'), findsWidgets); + expect(find.text('Cancel'), findsOneWidget); + + // Dismiss by clicking Cancel + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Destructive Operation Detected'), findsNothing); + material.FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }); + }); +} From 1b563cb7ab501284578a8d3e30b9bc212866df50 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 16:33:16 +0300 Subject: [PATCH 29/47] feat(tree): support active selection highlighting in SduiTreeBuilder for extension drivers (#639) --- lib/core/sdui/sdui_tree_builder.dart | 168 +++++++++++------- .../connections_panel_extension.dart | 28 +++ test/core/sdui/sdui_builders_test.dart | 89 ++++++++++ 3 files changed, 219 insertions(+), 66 deletions(-) diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index fafc245..219cdaf 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -21,12 +21,16 @@ class SduiTreeBuilder extends material.StatefulWidget { required this.schema, this.fetchChildren, this.onNodeSelected, + this.selectedNodeId, + this.isNodeSelected, this.maxHeight, }); final SduiTreeSchema schema; final SduiFetchTreeChildren? fetchChildren; final void Function(SduiTreeNode node)? onNodeSelected; + final String? selectedNodeId; + final bool Function(SduiTreeNode node)? isNodeSelected; /// When set, the tree scrolls inside a height cap (sidebar use). final double? maxHeight; @@ -199,89 +203,121 @@ class SduiTreeBuilderState extends material.State { material.Widget _buildNodeRow(SduiTreeNode node, {required int depth}) { final theme = Theme.of(context); + final primary = theme.colorScheme.primary; final muted = theme.colorScheme.mutedForeground; final canExpand = node.expandable || node.hasChildren; final isExpanded = _expanded.contains(node.id); final isLoading = _loading.contains(node.id); final nodeKind = _resolveNodeKind(node); final isBrowsable = nodeKind == 'table' || nodeKind == 'view'; + final isSelected = (widget.selectedNodeId != null && + node.id == widget.selectedNodeId) || + (widget.isNodeSelected != null && widget.isNodeSelected!(node)); + // Same hierarchy as native trees (#476 / #497) — no separate sduiNode size. final iconSize = canExpand ? QueryaIconSizes.treeGroup : QueryaIconSizes.treeLeaf; - final iconColor = isBrowsable - ? QueryaTreeTokens.leafIconColor(theme.colorScheme.primary) - : muted; + final iconColor = isSelected + ? primary + : (isBrowsable + ? QueryaTreeTokens.leafIconColor(primary) + : muted); final rowLeft = 8.0 + depth * QueryaTreeTokens.indent + (canExpand ? 0 : 4.0); - final row = material.InkWell( - onTap: () { - if (isBrowsable) { - widget.onNodeSelected?.call(node); - } else if (canExpand) { - _toggleExpand(node); - } - }, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: material.EdgeInsets.only( - left: rowLeft, - right: 8, - ), - child: material.Row( - children: [ - if (canExpand) - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onTap: () => _toggleExpand(node), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: isExpanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.treeExpand), - curve: context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.treeExpand, - color: muted, + final row = material.AnimatedContainer( + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), + decoration: material.BoxDecoration( + color: isSelected + ? primary.withValues(alpha: 0.12) + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(4), + border: isSelected + ? material.Border.all( + color: primary.withValues(alpha: 0.35), + width: 1, + ) + : null, + ), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: () { + if (isBrowsable) { + widget.onNodeSelected?.call(node); + } else if (canExpand) { + _toggleExpand(node); + } + }, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: material.EdgeInsets.only( + left: rowLeft, + right: 8, + ), + child: material.Row( + children: [ + if (canExpand) + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: () => _toggleExpand(node), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: isExpanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, + color: muted, + ), + ), ), ), + ) + else + const material.SizedBox(width: QueryaIconSizes.treeExpand + 4), + if (isLoading) + const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator(strokeWidth: 2), + ) + else + material.Icon( + QueryaIcons.sduiNodeIcon( + node.icon, + expandable: node.expandable, + ), + size: iconSize, + color: iconColor, + ), + const Gap(8), + material.Expanded( + child: material.Text( + node.label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: isSelected + ? primary + : (isBrowsable ? theme.colorScheme.foreground : muted), + fontWeight: (isSelected || isBrowsable) + ? material.FontWeight.w600 + : null, + ), ), ), - ) - else - const material.SizedBox(width: QueryaIconSizes.treeExpand + 4), - if (isLoading) - const material.SizedBox( - width: 14, - height: 14, - child: material.CircularProgressIndicator(strokeWidth: 2), - ) - else - material.Icon( - QueryaIcons.sduiNodeIcon( - node.icon, - expandable: node.expandable, - ), - size: iconSize, - color: iconColor, - ), - const Gap(8), - material.Expanded( - child: material.Text( - node.label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: isBrowsable ? theme.colorScheme.foreground : muted, - fontWeight: isBrowsable ? material.FontWeight.w600 : null, - ), - ), + ], ), - ], + ), ), ), ); diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 8f06118..99888de 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -139,6 +139,11 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final sel = _ConnectionsTreeSelectionScope.of(context); + final selectedExt = (sel != null && + sel.selectedConnectionId == widget.connection.id) + ? sel.selectedExtensionObject + : null; final material.Widget iconWidget; if (_iconFilePath != null) { iconWidget = DriverIconImage( @@ -322,6 +327,29 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { schema: _schema!, fetchChildren: _fetchChildren, onNodeSelected: _onNodeSelected, + isNodeSelected: selectedExt == null + ? null + : (node) { + final metaDb = node.meta['database'] ?? + node.meta['db']; + final metaName = node.meta['table'] ?? + node.meta['tableName'] ?? + node.meta['name']; + if (metaDb != null && metaName != null) { + if (metaDb == selectedExt.database && + metaName == selectedExt.name) { + return true; + } + } + final parts = node.id.split('.'); + if (parts.length >= 3) { + final db = parts[1]; + final name = parts.sublist(2).join('.'); + return db == selectedExt.database && + name == selectedExt.name; + } + return false; + }, maxHeight: kConnectionTreeMaxVisibleRows * kConnectionTreeRowExtent, ), diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index 1b85f76..3106c0b 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -359,5 +359,94 @@ void main() { expect(selected?.id, 'table.default.customers'); }); + + testWidgets('highlights active node visually when selectedNodeId is provided', + (tester) async { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'table.analytics.events', + 'label': 'events', + 'node_type': 'table', + }, + { + 'id': 'table.analytics.users', + 'label': 'users', + 'node_type': 'table', + }, + ], + }); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiTreeBuilder( + schema: schema, + selectedNodeId: 'table.analytics.events', + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Find the AnimatedContainers for the rows + final animatedContainers = tester.widgetList( + find.byType(material.AnimatedContainer), + ).toList(); + + expect(animatedContainers.length, greaterThanOrEqualTo(2)); + final selectedDecoration = animatedContainers[0].decoration as material.BoxDecoration?; + final unselectedDecoration = animatedContainers[1].decoration as material.BoxDecoration?; + + expect(selectedDecoration?.color, isNotNull); + expect(selectedDecoration?.border, isNotNull); + expect(unselectedDecoration?.border, isNull); + }); + + testWidgets('highlights active node visually when isNodeSelected predicate matches', + (tester) async { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'table.analytics.events', + 'label': 'events', + 'node_type': 'table', + }, + { + 'id': 'view.analytics.monthly_mv', + 'label': 'monthly_mv', + 'node_type': 'view', + }, + ], + }); + + String? activeId = 'view.analytics.monthly_mv'; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.StatefulBuilder( + builder: (context, setState) { + return material.Scaffold( + body: SduiTreeBuilder( + schema: schema, + isNodeSelected: (node) => node.id == activeId, + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final animatedContainers = tester.widgetList( + find.byType(material.AnimatedContainer), + ).toList(); + + final eventsDec = animatedContainers[0].decoration as material.BoxDecoration?; + final mvDec = animatedContainers[1].decoration as material.BoxDecoration?; + + expect(eventsDec?.border, isNull); + expect(mvDec?.border, isNotNull); + }); }); } From c9f59df514462646d07739861782754042f17881 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 16:38:33 +0300 Subject: [PATCH 30/47] fix(workspace): make restoreLastSelectedObject connection-type-aware (#640) --- .../main_screen_workspace_state.dart | 120 ++++++++++-------- .../main_screen_workspace_state_test.dart | 115 +++++++++++++++++ 2 files changed, 181 insertions(+), 54 deletions(-) diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart index f34bc65..f3c3532 100644 --- a/lib/features/main_screen/main_screen_workspace_state.dart +++ b/lib/features/main_screen/main_screen_workspace_state.dart @@ -176,40 +176,52 @@ class MainScreenWorkspaceState { MainScreenWorkspaceState restoreLastSelectedObject() { final conn = activeConnection; if (conn == null) return this; - if (lastSelectedPostgresObject != null) { - final obj = lastSelectedPostgresObject!; - return selectPostgresObject( - conn, - obj.database, - obj.schema, - obj.name, - obj.kind, - ); - } - if (lastSelectedMysqlObject != null) { - final obj = lastSelectedMysqlObject!; - return selectMysqlObject( - conn, - obj.database, - obj.name, - obj.kind, - ); - } - if (lastSelectedSqliteObject != null) { - final obj = lastSelectedSqliteObject!; - return selectSqliteObject( - conn, - obj.name, - obj.kind, - ); - } - if (lastSelectedExtensionObject != null) { - final obj = lastSelectedExtensionObject!; - return selectExtensionObject( - conn, - obj.database, - obj.name, - ); + final type = conn.type.toLowerCase(); + switch (type) { + case 'postgres': + case 'postgresql': + if (lastSelectedPostgresObject != null) { + final obj = lastSelectedPostgresObject!; + return selectPostgresObject( + conn, + obj.database, + obj.schema, + obj.name, + obj.kind, + ); + } + break; + case 'mysql': + if (lastSelectedMysqlObject != null) { + final obj = lastSelectedMysqlObject!; + return selectMysqlObject( + conn, + obj.database, + obj.name, + obj.kind, + ); + } + break; + case 'sqlite': + if (lastSelectedSqliteObject != null) { + final obj = lastSelectedSqliteObject!; + return selectSqliteObject( + conn, + obj.name, + obj.kind, + ); + } + break; + default: + if (lastSelectedExtensionObject != null) { + final obj = lastSelectedExtensionObject!; + return selectExtensionObject( + conn, + obj.database, + obj.name, + ); + } + break; } return this; } @@ -240,9 +252,9 @@ class MainScreenWorkspaceState { selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, lastSelectedPostgresObject: pg, - lastSelectedMysqlObject: lastSelectedMysqlObject, - lastSelectedSqliteObject: lastSelectedSqliteObject, - lastSelectedExtensionObject: lastSelectedExtensionObject, + lastSelectedMysqlObject: null, + lastSelectedSqliteObject: null, + lastSelectedExtensionObject: null, isReadOnly: isReadOnly, ); } @@ -270,10 +282,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, - lastSelectedPostgresObject: lastSelectedPostgresObject, + lastSelectedPostgresObject: null, lastSelectedMysqlObject: my, - lastSelectedSqliteObject: lastSelectedSqliteObject, - lastSelectedExtensionObject: lastSelectedExtensionObject, + lastSelectedSqliteObject: null, + lastSelectedExtensionObject: null, isReadOnly: isReadOnly, ); } @@ -299,10 +311,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: sq, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, - lastSelectedPostgresObject: lastSelectedPostgresObject, - lastSelectedMysqlObject: lastSelectedMysqlObject, + lastSelectedPostgresObject: null, + lastSelectedMysqlObject: null, lastSelectedSqliteObject: sq, - lastSelectedExtensionObject: lastSelectedExtensionObject, + lastSelectedExtensionObject: null, isReadOnly: isReadOnly, ); } @@ -329,9 +341,9 @@ class MainScreenWorkspaceState { selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, selectedExtensionObject: ext, - lastSelectedPostgresObject: lastSelectedPostgresObject, - lastSelectedMysqlObject: lastSelectedMysqlObject, - lastSelectedSqliteObject: lastSelectedSqliteObject, + lastSelectedPostgresObject: null, + lastSelectedMysqlObject: null, + lastSelectedSqliteObject: null, lastSelectedExtensionObject: ext, isReadOnly: isReadOnly, ); @@ -350,10 +362,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, - lastSelectedPostgresObject: lastSelectedPostgresObject, - lastSelectedMysqlObject: lastSelectedMysqlObject, - lastSelectedSqliteObject: lastSelectedSqliteObject, - lastSelectedExtensionObject: lastSelectedExtensionObject, + lastSelectedPostgresObject: null, + lastSelectedMysqlObject: null, + lastSelectedSqliteObject: null, + lastSelectedExtensionObject: null, isReadOnly: isReadOnly, ); } @@ -372,10 +384,10 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, - lastSelectedPostgresObject: lastSelectedPostgresObject, - lastSelectedMysqlObject: lastSelectedMysqlObject, - lastSelectedSqliteObject: lastSelectedSqliteObject, - lastSelectedExtensionObject: lastSelectedExtensionObject, + lastSelectedPostgresObject: null, + lastSelectedMysqlObject: null, + lastSelectedSqliteObject: null, + lastSelectedExtensionObject: null, isReadOnly: isReadOnly, ); } diff --git a/test/features/main_screen/main_screen_workspace_state_test.dart b/test/features/main_screen/main_screen_workspace_state_test.dart index dd33190..f780b58 100644 --- a/test/features/main_screen/main_screen_workspace_state_test.dart +++ b/test/features/main_screen/main_screen_workspace_state_test.dart @@ -1,5 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart' + show SqliteObjectKind; import 'package:querya_desktop/features/main_screen/main_screen_workspace_state.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; @@ -22,6 +24,21 @@ void main() { createdAt: createdAt, id: 11, ); + final sqliteConn = ConnectionRow( + type: 'sqlite', + name: 'lite', + databaseName: '/tmp/test.db', + createdAt: createdAt, + id: 12, + ); + final clickhouseConn = ConnectionRow( + type: 'clickhouse', + name: 'ch', + host: '127.0.0.1', + port: 8123, + createdAt: createdAt, + id: 13, + ); group('MainScreenWorkspaceState', () { test('empty has no selection', () { @@ -214,5 +231,103 @@ void main() { final diffConn = reselectedSame.selectConnection(mysqlConn); expect(diffConn.lastSelectedPostgresObject, isNull); }); + + test('restoreLastSelectedObject is connection-type-aware', () { + // 1. MySQL connection + final mysqlState = MainScreenWorkspaceState.empty + .selectMysqlObject(mysqlConn, 'app_db', 'users', MysqlObjectKind.table) + .unselectActiveObject(); + expect(mysqlState.selectedMysqlObject, isNull); + expect(mysqlState.lastSelectedMysqlObject?.name, 'users'); + final restoredMy = mysqlState.restoreLastSelectedObject(); + expect(restoredMy.selectedMysqlObject?.name, 'users'); + expect(restoredMy.selectedPostgresObject, isNull); + + // 2. SQLite connection + final sqliteState = MainScreenWorkspaceState.empty + .selectSqliteObject(sqliteConn, 'settings', SqliteObjectKind.table) + .unselectActiveObject(); + expect(sqliteState.selectedSqliteObject, isNull); + expect(sqliteState.lastSelectedSqliteObject?.name, 'settings'); + final restoredSq = sqliteState.restoreLastSelectedObject(); + expect(restoredSq.selectedSqliteObject?.name, 'settings'); + + // 3. Extension (ClickHouse) connection + final extState = MainScreenWorkspaceState.empty + .selectExtensionObject(clickhouseConn, 'analytics', 'hits') + .unselectActiveObject(); + expect(extState.selectedExtensionObject, isNull); + expect(extState.lastSelectedExtensionObject?.name, 'hits'); + final restoredExt = extState.restoreLastSelectedObject(); + expect(restoredExt.selectedExtensionObject?.name, 'hits'); + }); + + test('restoreLastSelectedObject ignores cached references from other drivers', () { + // Craft a state where active connection is MySQL but lastSelectedPostgresObject is non-null + const mismatchedState = MainScreenWorkspaceState( + activeConnection: ConnectionRow( + type: 'mysql', + name: 'my_db', + createdAt: '2025-01-01', + id: 50, + ), + lastSelectedPostgresObject: ( + database: 'pg_db', + schema: 'public', + name: 'pg_table', + kind: PostgresObjectKind.table, + ), + ); + + final restored = mismatchedState.restoreLastSelectedObject(); + // Must NOT invoke selectPostgresObject with a MySQL connection + expect(restored.selectedPostgresObject, isNull); + expect(restored.selectedMysqlObject, isNull); + }); + + test('select*Object clears cached object references of other drivers', () { + final statePg = MainScreenWorkspaceState.empty.selectPostgresObject( + pgConn, + 'db', + 'public', + 't1', + PostgresObjectKind.table, + ); + expect(statePg.lastSelectedPostgresObject?.name, 't1'); + expect(statePg.lastSelectedMysqlObject, isNull); + expect(statePg.lastSelectedSqliteObject, isNull); + expect(statePg.lastSelectedExtensionObject, isNull); + + final stateMy = statePg.selectMysqlObject( + mysqlConn, + 'db', + 't2', + MysqlObjectKind.table, + ); + expect(stateMy.lastSelectedPostgresObject, isNull); + expect(stateMy.lastSelectedMysqlObject?.name, 't2'); + expect(stateMy.lastSelectedSqliteObject, isNull); + expect(stateMy.lastSelectedExtensionObject, isNull); + + final stateSq = stateMy.selectSqliteObject( + sqliteConn, + 't3', + SqliteObjectKind.table, + ); + expect(stateSq.lastSelectedPostgresObject, isNull); + expect(stateSq.lastSelectedMysqlObject, isNull); + expect(stateSq.lastSelectedSqliteObject?.name, 't3'); + expect(stateSq.lastSelectedExtensionObject, isNull); + + final stateExt = stateSq.selectExtensionObject( + clickhouseConn, + 'db', + 't4', + ); + expect(stateExt.lastSelectedPostgresObject, isNull); + expect(stateExt.lastSelectedMysqlObject, isNull); + expect(stateExt.lastSelectedSqliteObject, isNull); + expect(stateExt.lastSelectedExtensionObject?.name, 't4'); + }); }); } From ffe749a52152ebd5aafeac97070ca0a74eb28fd0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 16:48:13 +0300 Subject: [PATCH 31/47] refactor(core): eliminate inverted core-to-feature imports and extract SSL and connection models to core - Move ssl_certificate_support.dart to lib/core/security/ - Extract ConnectionType and ConnectionTypeChoice models to lib/core/database/ - Decouple ExtensionDriverCatalog and low-level DB drivers from UI features - Eliminate circular imports between connection choice models and dialogs - Add comprehensive unit tests in test/core/security/ and test/core/database/ Closes #644 --- lib/core/database/connection_type.dart | 34 ++++ lib/core/database/connection_type_choice.dart | 72 ++++++++ lib/core/database/mongodb_connection.dart | 2 +- lib/core/database/mysql_connection.dart | 2 +- lib/core/database/redis_connection.dart | 2 +- .../extensions/extension_driver_catalog.dart | 4 +- .../security/ssl_certificate_support.dart | 172 +++++++++++++++++ .../connections/connection_creation_flow.dart | 1 - .../connections/connection_type_choice.dart | 74 +------- .../connections/new_connection_dialog.dart | 31 +--- .../connections/ssl_certificate_support.dart | 173 +----------------- .../widgets/ssl_certificate_fields.dart | 2 +- test/core/database/connection_type_test.dart | 73 ++++++++ .../extension_driver_catalog_test.dart | 4 +- .../ssl_certificate_support_test.dart | 46 +++++ 15 files changed, 411 insertions(+), 281 deletions(-) create mode 100644 lib/core/database/connection_type.dart create mode 100644 lib/core/database/connection_type_choice.dart create mode 100644 lib/core/security/ssl_certificate_support.dart create mode 100644 test/core/database/connection_type_test.dart create mode 100644 test/core/security/ssl_certificate_support_test.dart diff --git a/lib/core/database/connection_type.dart b/lib/core/database/connection_type.dart new file mode 100644 index 0000000..0156733 --- /dev/null +++ b/lib/core/database/connection_type.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/ui/querya_icons.dart'; + +/// Database type for built-in new connections. +enum ConnectionType { + postgresql, + mysql, + redis, + mongodb, + sqlite, +} + +extension ConnectionTypeX on ConnectionType { + String get label => switch (this) { + ConnectionType.postgresql => 'PostgreSQL', + ConnectionType.mysql => 'MySQL', + ConnectionType.redis => 'Redis', + ConnectionType.mongodb => 'MongoDB', + ConnectionType.sqlite => 'SQLite', + }; + + material.IconData get icon => QueryaIcons.connectionIcon(name); + + /// Asset path for custom icon (from Downloads). + String? get iconAsset => QueryaIcons.connectionAsset(name); + + bool get isSql => + this == ConnectionType.postgresql || + this == ConnectionType.mysql || + this == ConnectionType.sqlite; + + bool get isNoSql => + this == ConnectionType.redis || this == ConnectionType.mongodb; +} diff --git a/lib/core/database/connection_type_choice.dart b/lib/core/database/connection_type_choice.dart new file mode 100644 index 0000000..2c25005 --- /dev/null +++ b/lib/core/database/connection_type_choice.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/connection_type.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; + +/// Result of the New Connection type picker (built-in or extension driver). +sealed class ConnectionTypeChoice { + const ConnectionTypeChoice(); + + String get label; + material.IconData get icon; + String? get iconAsset; + + /// Absolute path to an icon file shipped by an extension package. + String? get iconFile => null; +} + +/// One of the five built-in Dart drivers. +final class BuiltInConnectionType extends ConnectionTypeChoice { + const BuiltInConnectionType(this.type); + + final ConnectionType type; + + @override + String get label => type.label; + + @override + material.IconData get icon => type.icon; + + @override + String? get iconAsset => type.iconAsset; + + @override + bool operator ==(Object other) => + other is BuiltInConnectionType && other.type == type; + + @override + int get hashCode => type.hashCode; +} + +/// A driver contributed by an installed `database_driver` extension. +final class ExtensionDriverChoice extends ConnectionTypeChoice { + const ExtensionDriverChoice({ + required this.manifest, + required this.driver, + }); + + final ExtensionManifest manifest; + final DriverContribution driver; + + @override + String get label => + driver.displayName.isNotEmpty ? driver.displayName : manifest.name; + + @override + material.IconData get icon => material.Icons.extension_rounded; + + @override + String? get iconAsset => null; + + @override + String? get iconFile => manifest.resolvedIconPath; + + @override + bool operator ==(Object other) => + other is ExtensionDriverChoice && + other.manifest.id == manifest.id && + other.driver.driverId == driver.driverId; + + @override + int get hashCode => Object.hash(manifest.id, driver.driverId); +} diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index e648292..1e5fb26 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; -import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; /// MongoDB connection configuration and state. class MongoConnection { diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index d974bf0..bd0782b 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -3,9 +3,9 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; /// Replaces the database in a `mysql://` / `mariadb://` URI (path or `database=`). String replaceDatabaseInMysqlConnectionString( diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index da4064b..5a0a4b4 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -1,9 +1,9 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:redis/redis.dart' as redis; /// Redis connection using the Dart redis package (no Java/JRE). diff --git a/lib/core/extensions/extension_driver_catalog.dart b/lib/core/extensions/extension_driver_catalog.dart index a240fe8..7643ee8 100644 --- a/lib/core/extensions/extension_driver_catalog.dart +++ b/lib/core/extensions/extension_driver_catalog.dart @@ -1,9 +1,9 @@ +import 'package:querya_desktop/core/database/connection_type.dart'; +import 'package:querya_desktop/core/database/connection_type_choice.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; 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/storage/local_db.dart'; -import 'package:querya_desktop/features/connections/connection_type_choice.dart'; -import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; /// Built-in + installed extension drivers for New Connection / Driver Manager. class ExtensionDriverCatalog { diff --git a/lib/core/security/ssl_certificate_support.dart b/lib/core/security/ssl_certificate_support.dart new file mode 100644 index 0000000..bcb9823 --- /dev/null +++ b/lib/core/security/ssl_certificate_support.dart @@ -0,0 +1,172 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; + +/// Querya-standard SSL certificate query parameters (aligned with PostgreSQL). +const kSslRootCertParam = 'sslrootcert'; +const kSslCertParam = 'sslcert'; +const kSslKeyParam = 'sslkey'; + +/// MongoDB driver-native TLS file parameters. +const kMongoTlsCaFileParam = 'tlsCAFile'; +const kMongoTlsCertificateKeyFileParam = 'tlsCertificateKeyFile'; + +class SslCertificatePaths { + const SslCertificatePaths({ + this.rootCert, + this.clientCert, + this.clientKey, + }); + + final String? rootCert; + final String? clientCert; + final String? clientKey; + + bool get hasAny => + _nonEmpty(rootCert) || _nonEmpty(clientCert) || _nonEmpty(clientKey); + + static bool _nonEmpty(String? value) => + value != null && value.trim().isNotEmpty; +} + +SslCertificatePaths extractSslCertificatePaths(Uri uri) { + return SslCertificatePaths( + rootCert: uri.queryParameters[kSslRootCertParam], + clientCert: uri.queryParameters[kSslCertParam], + clientKey: uri.queryParameters[kSslKeyParam], + ); +} + +SslCertificatePaths extractSslCertificatePathsFromString(String? raw) { + if (raw == null || raw.trim().isEmpty) return const SslCertificatePaths(); + final uri = Uri.tryParse(raw.trim()); + if (uri == null) return const SslCertificatePaths(); + return extractSslCertificatePaths(uri); +} + +Map sslCertificateQueryParams(SslCertificatePaths paths) { + final params = {}; + if (SslCertificatePaths._nonEmpty(paths.rootCert)) { + params[kSslRootCertParam] = paths.rootCert!.trim(); + } + if (SslCertificatePaths._nonEmpty(paths.clientCert)) { + params[kSslCertParam] = paths.clientCert!.trim(); + } + if (SslCertificatePaths._nonEmpty(paths.clientKey)) { + params[kSslKeyParam] = paths.clientKey!.trim(); + } + return params; +} + +Uri applySslCertificatePaths(Uri uri, SslCertificatePaths paths) { + final params = Map.from(uri.queryParameters); + for (final key in [kSslRootCertParam, kSslCertParam, kSslKeyParam]) { + params.remove(key); + } + params.addAll(sslCertificateQueryParams(paths)); + return uri.replace(queryParameters: params.isEmpty ? null : params); +} + +void setOrRemoveSslParam( + Map params, + String key, + String value, +) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + params.remove(key); + } else { + params[key] = trimmed; + } +} + +Uri syncSslParamsIntoUri(String uriText, SslCertificatePaths paths) { + final parsed = Uri.tryParse(uriText.trim()); + if (parsed == null) return Uri(); + return applySslCertificatePaths(parsed, paths); +} + +SecurityContext? buildSecurityContext(SslCertificatePaths paths) { + if (!paths.hasAny) return null; + final context = SecurityContext(); + if (SslCertificatePaths._nonEmpty(paths.clientCert)) { + context.useCertificateChain(paths.clientCert!.trim()); + } + if (SslCertificatePaths._nonEmpty(paths.clientKey)) { + context.usePrivateKey(paths.clientKey!.trim()); + } + if (SslCertificatePaths._nonEmpty(paths.rootCert)) { + context.setTrustedCertificates(paths.rootCert!.trim()); + } + return context; +} + +Future pickSslCertificateFile({ + required void Function(String path) onPicked, +}) async { + const typeGroup = XTypeGroup( + label: 'PEM files', + extensions: ['pem', 'crt', 'key', 'cer'], + ); + final file = await openFile(acceptedTypeGroups: const [typeGroup]); + if (file == null) return; + onPicked(file.path); +} + +/// Maps Querya [sslrootcert]/[sslcert]/[sslkey] params to mongo_dart URI params. +Uri translateQueryaSslParamsForMongo(Uri uri) { + final params = Map.from(uri.queryParameters); + final root = params.remove(kSslRootCertParam); + final cert = params.remove(kSslCertParam); + final key = params.remove(kSslKeyParam); + if (root != null && root.isNotEmpty) { + params[kMongoTlsCaFileParam] = root; + } + if (cert != null && cert.isNotEmpty) { + params[kMongoTlsCertificateKeyFileParam] = cert; + } + if (key != null && key.isNotEmpty) { + params[kSslKeyParam] = key; + } + return uri.replace(queryParameters: params.isEmpty ? null : params); +} + +/// Resolves a client PEM path for mongo_dart when cert and key are separate files. +Future resolveMongoTlsCertificateKeyFile({ + required String? clientCert, + required String? clientKey, +}) async { + final certPath = clientCert?.trim(); + final keyPath = clientKey?.trim(); + if (certPath == null || certPath.isEmpty) return null; + if (keyPath == null || keyPath.isEmpty) return certPath; + + final certBytes = await File(certPath).readAsString(); + final keyBytes = await File(keyPath).readAsString(); + final dir = await Directory.systemTemp.createTemp('querya_mongo_tls_'); + final merged = File('${dir.path}/client.pem'); + await merged.writeAsString('$certBytes\n$keyBytes\n'); + return merged.path; +} + +String buildRedisConnectionUri({ + required String host, + required int port, + String? username, + String? password, + bool useSSL = false, + SslCertificatePaths sslPaths = const SslCertificatePaths(), +}) { + final userInfoParts = [ + if (username != null && username.isNotEmpty) Uri.encodeComponent(username), + if (password != null && password.isNotEmpty) Uri.encodeComponent(password), + ]; + final queryParams = sslCertificateQueryParams(sslPaths); + return Uri( + scheme: useSSL ? 'rediss' : 'redis', + userInfo: userInfoParts.isEmpty ? null : userInfoParts.join(':'), + host: host, + port: port, + queryParameters: queryParams.isEmpty ? null : queryParams, + ).toString(); +} diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index 6e2b647..5d36f02 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/extension_connection_form.dart'; import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; diff --git a/lib/features/connections/connection_type_choice.dart b/lib/features/connections/connection_type_choice.dart index 3a16c06..778405b 100644 --- a/lib/features/connections/connection_type_choice.dart +++ b/lib/features/connections/connection_type_choice.dart @@ -1,72 +1,2 @@ -import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; -import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; -import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; - -/// Result of the New Connection type picker (built-in or extension driver). -sealed class ConnectionTypeChoice { - const ConnectionTypeChoice(); - - String get label; - material.IconData get icon; - String? get iconAsset; - - /// Absolute path to an icon file shipped by an extension package. - String? get iconFile => null; -} - -/// One of the five built-in Dart drivers. -final class BuiltInConnectionType extends ConnectionTypeChoice { - const BuiltInConnectionType(this.type); - - final ConnectionType type; - - @override - String get label => type.label; - - @override - material.IconData get icon => type.icon; - - @override - String? get iconAsset => type.iconAsset; - - @override - bool operator ==(Object other) => - other is BuiltInConnectionType && other.type == type; - - @override - int get hashCode => type.hashCode; -} - -/// A driver contributed by an installed `database_driver` extension. -final class ExtensionDriverChoice extends ConnectionTypeChoice { - const ExtensionDriverChoice({ - required this.manifest, - required this.driver, - }); - - final ExtensionManifest manifest; - final DriverContribution driver; - - @override - String get label => - driver.displayName.isNotEmpty ? driver.displayName : manifest.name; - - @override - material.IconData get icon => material.Icons.extension_rounded; - - @override - String? get iconAsset => null; - - @override - String? get iconFile => manifest.resolvedIconPath; - - @override - bool operator ==(Object other) => - other is ExtensionDriverChoice && - other.manifest.id == manifest.id && - other.driver.driverId == driver.driverId; - - @override - int get hashCode => Object.hash(manifest.id, driver.driverId); -} +export 'package:querya_desktop/core/database/connection_type.dart'; +export 'package:querya_desktop/core/database/connection_type_choice.dart'; diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index c908e3b..0aecc2d 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -1,41 +1,16 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/core/database/connection_type_choice.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/motion/querya_hover_surface.dart'; -import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Database type for built-in new connections. -enum ConnectionType { - postgresql, - mysql, - redis, - mongodb, - sqlite, -} - -extension ConnectionTypeX on ConnectionType { - String get label => switch (this) { - ConnectionType.postgresql => 'PostgreSQL', - ConnectionType.mysql => 'MySQL', - ConnectionType.redis => 'Redis', - ConnectionType.mongodb => 'MongoDB', - ConnectionType.sqlite => 'SQLite', - }; - material.IconData get icon => QueryaIcons.connectionIcon(name); - - /// Asset path for custom icon (from Downloads). - String? get iconAsset => QueryaIcons.connectionAsset(name); - bool get isSql => - this == ConnectionType.postgresql || - this == ConnectionType.mysql || - this == ConnectionType.sqlite; -} +export 'package:querya_desktop/core/database/connection_type.dart'; +export 'package:querya_desktop/core/database/connection_type_choice.dart'; enum _Category { all, sql, nosql } diff --git a/lib/features/connections/ssl_certificate_support.dart b/lib/features/connections/ssl_certificate_support.dart index bcb9823..adaea46 100644 --- a/lib/features/connections/ssl_certificate_support.dart +++ b/lib/features/connections/ssl_certificate_support.dart @@ -1,172 +1 @@ -import 'dart:io'; - -import 'package:file_selector/file_selector.dart'; - -/// Querya-standard SSL certificate query parameters (aligned with PostgreSQL). -const kSslRootCertParam = 'sslrootcert'; -const kSslCertParam = 'sslcert'; -const kSslKeyParam = 'sslkey'; - -/// MongoDB driver-native TLS file parameters. -const kMongoTlsCaFileParam = 'tlsCAFile'; -const kMongoTlsCertificateKeyFileParam = 'tlsCertificateKeyFile'; - -class SslCertificatePaths { - const SslCertificatePaths({ - this.rootCert, - this.clientCert, - this.clientKey, - }); - - final String? rootCert; - final String? clientCert; - final String? clientKey; - - bool get hasAny => - _nonEmpty(rootCert) || _nonEmpty(clientCert) || _nonEmpty(clientKey); - - static bool _nonEmpty(String? value) => - value != null && value.trim().isNotEmpty; -} - -SslCertificatePaths extractSslCertificatePaths(Uri uri) { - return SslCertificatePaths( - rootCert: uri.queryParameters[kSslRootCertParam], - clientCert: uri.queryParameters[kSslCertParam], - clientKey: uri.queryParameters[kSslKeyParam], - ); -} - -SslCertificatePaths extractSslCertificatePathsFromString(String? raw) { - if (raw == null || raw.trim().isEmpty) return const SslCertificatePaths(); - final uri = Uri.tryParse(raw.trim()); - if (uri == null) return const SslCertificatePaths(); - return extractSslCertificatePaths(uri); -} - -Map sslCertificateQueryParams(SslCertificatePaths paths) { - final params = {}; - if (SslCertificatePaths._nonEmpty(paths.rootCert)) { - params[kSslRootCertParam] = paths.rootCert!.trim(); - } - if (SslCertificatePaths._nonEmpty(paths.clientCert)) { - params[kSslCertParam] = paths.clientCert!.trim(); - } - if (SslCertificatePaths._nonEmpty(paths.clientKey)) { - params[kSslKeyParam] = paths.clientKey!.trim(); - } - return params; -} - -Uri applySslCertificatePaths(Uri uri, SslCertificatePaths paths) { - final params = Map.from(uri.queryParameters); - for (final key in [kSslRootCertParam, kSslCertParam, kSslKeyParam]) { - params.remove(key); - } - params.addAll(sslCertificateQueryParams(paths)); - return uri.replace(queryParameters: params.isEmpty ? null : params); -} - -void setOrRemoveSslParam( - Map params, - String key, - String value, -) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - params.remove(key); - } else { - params[key] = trimmed; - } -} - -Uri syncSslParamsIntoUri(String uriText, SslCertificatePaths paths) { - final parsed = Uri.tryParse(uriText.trim()); - if (parsed == null) return Uri(); - return applySslCertificatePaths(parsed, paths); -} - -SecurityContext? buildSecurityContext(SslCertificatePaths paths) { - if (!paths.hasAny) return null; - final context = SecurityContext(); - if (SslCertificatePaths._nonEmpty(paths.clientCert)) { - context.useCertificateChain(paths.clientCert!.trim()); - } - if (SslCertificatePaths._nonEmpty(paths.clientKey)) { - context.usePrivateKey(paths.clientKey!.trim()); - } - if (SslCertificatePaths._nonEmpty(paths.rootCert)) { - context.setTrustedCertificates(paths.rootCert!.trim()); - } - return context; -} - -Future pickSslCertificateFile({ - required void Function(String path) onPicked, -}) async { - const typeGroup = XTypeGroup( - label: 'PEM files', - extensions: ['pem', 'crt', 'key', 'cer'], - ); - final file = await openFile(acceptedTypeGroups: const [typeGroup]); - if (file == null) return; - onPicked(file.path); -} - -/// Maps Querya [sslrootcert]/[sslcert]/[sslkey] params to mongo_dart URI params. -Uri translateQueryaSslParamsForMongo(Uri uri) { - final params = Map.from(uri.queryParameters); - final root = params.remove(kSslRootCertParam); - final cert = params.remove(kSslCertParam); - final key = params.remove(kSslKeyParam); - if (root != null && root.isNotEmpty) { - params[kMongoTlsCaFileParam] = root; - } - if (cert != null && cert.isNotEmpty) { - params[kMongoTlsCertificateKeyFileParam] = cert; - } - if (key != null && key.isNotEmpty) { - params[kSslKeyParam] = key; - } - return uri.replace(queryParameters: params.isEmpty ? null : params); -} - -/// Resolves a client PEM path for mongo_dart when cert and key are separate files. -Future resolveMongoTlsCertificateKeyFile({ - required String? clientCert, - required String? clientKey, -}) async { - final certPath = clientCert?.trim(); - final keyPath = clientKey?.trim(); - if (certPath == null || certPath.isEmpty) return null; - if (keyPath == null || keyPath.isEmpty) return certPath; - - final certBytes = await File(certPath).readAsString(); - final keyBytes = await File(keyPath).readAsString(); - final dir = await Directory.systemTemp.createTemp('querya_mongo_tls_'); - final merged = File('${dir.path}/client.pem'); - await merged.writeAsString('$certBytes\n$keyBytes\n'); - return merged.path; -} - -String buildRedisConnectionUri({ - required String host, - required int port, - String? username, - String? password, - bool useSSL = false, - SslCertificatePaths sslPaths = const SslCertificatePaths(), -}) { - final userInfoParts = [ - if (username != null && username.isNotEmpty) Uri.encodeComponent(username), - if (password != null && password.isNotEmpty) Uri.encodeComponent(password), - ]; - final queryParams = sslCertificateQueryParams(sslPaths); - return Uri( - scheme: useSSL ? 'rediss' : 'redis', - userInfo: userInfoParts.isEmpty ? null : userInfoParts.join(':'), - host: host, - port: port, - queryParameters: queryParams.isEmpty ? null : queryParams, - ).toString(); -} +export 'package:querya_desktop/core/security/ssl_certificate_support.dart'; diff --git a/lib/shared/widgets/ssl_certificate_fields.dart b/lib/shared/widgets/ssl_certificate_fields.dart index cfadd96..492744a 100644 --- a/lib/shared/widgets/ssl_certificate_fields.dart +++ b/lib/shared/widgets/ssl_certificate_fields.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; +import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Optional SSL certificate path fields (Root CA, client cert, client key). diff --git a/test/core/database/connection_type_test.dart b/test/core/database/connection_type_test.dart new file mode 100644 index 0000000..2dc3a37 --- /dev/null +++ b/test/core/database/connection_type_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/connection_type.dart'; +import 'package:querya_desktop/core/database/connection_type_choice.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; + +void main() { + group('ConnectionType', () { + test('covers SQL and NoSQL classifications', () { + expect(ConnectionType.postgresql.isSql, isTrue); + expect(ConnectionType.mysql.isSql, isTrue); + expect(ConnectionType.sqlite.isSql, isTrue); + expect(ConnectionType.redis.isSql, isFalse); + expect(ConnectionType.mongodb.isSql, isFalse); + + expect(ConnectionType.redis.isNoSql, isTrue); + expect(ConnectionType.mongodb.isNoSql, isTrue); + expect(ConnectionType.postgresql.isNoSql, isFalse); + expect(ConnectionType.mysql.isNoSql, isFalse); + expect(ConnectionType.sqlite.isNoSql, isFalse); + }); + + test('provides human-readable labels', () { + expect(ConnectionType.postgresql.label, 'PostgreSQL'); + expect(ConnectionType.mysql.label, 'MySQL'); + expect(ConnectionType.sqlite.label, 'SQLite'); + expect(ConnectionType.redis.label, 'Redis'); + expect(ConnectionType.mongodb.label, 'MongoDB'); + }); + }); + + group('ConnectionTypeChoice', () { + test('BuiltInConnectionType equality and properties', () { + const a = BuiltInConnectionType(ConnectionType.postgresql); + const b = BuiltInConnectionType(ConnectionType.postgresql); + const c = BuiltInConnectionType(ConnectionType.mysql); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + expect(a, isNot(equals(c))); + expect(a.label, 'PostgreSQL'); + }); + + test('ExtensionDriverChoice equality and properties', () { + const m1 = ExtensionManifest( + id: 'pkg.clickhouse', + name: 'ClickHouse Plugin', + version: '1.0.0', + publisher: 'QueryaHub', + type: ExtensionType.databaseDriver, + engines: {}, + ); + const d1 = DriverContribution( + driverId: 'clickhouse', + displayName: 'ClickHouse', + ); + const d2 = DriverContribution( + driverId: 'clickhouse-cluster', + displayName: 'ClickHouse Cluster', + ); + + const choice1 = ExtensionDriverChoice(manifest: m1, driver: d1); + const choice2 = ExtensionDriverChoice(manifest: m1, driver: d1); + const choice3 = ExtensionDriverChoice(manifest: m1, driver: d2); + + expect(choice1, equals(choice2)); + expect(choice1.hashCode, equals(choice2.hashCode)); + expect(choice1, isNot(equals(choice3))); + expect(choice1.label, 'ClickHouse'); + }); + }); +} diff --git a/test/core/extensions/extension_driver_catalog_test.dart b/test/core/extensions/extension_driver_catalog_test.dart index 695ef80..d5d0ce8 100644 --- a/test/core/extensions/extension_driver_catalog_test.dart +++ b/test/core/extensions/extension_driver_catalog_test.dart @@ -1,11 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/connection_type.dart'; +import 'package:querya_desktop/core/database/connection_type_choice.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; -import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/extension_connection_form.dart'; -import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; void main() { group('ExtensionDriverCatalog', () { diff --git a/test/core/security/ssl_certificate_support_test.dart b/test/core/security/ssl_certificate_support_test.dart new file mode 100644 index 0000000..24c9c6b --- /dev/null +++ b/test/core/security/ssl_certificate_support_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; + +void main() { + group('ssl_certificate_support (core)', () { + test('extracts and applies Querya SSL params', () { + const paths = SslCertificatePaths( + rootCert: '/ca.pem', + clientCert: '/client.crt', + clientKey: '/client.key', + ); + final uri = applySslCertificatePaths( + Uri.parse('mongodb://localhost:27017/app'), + paths, + ); + expect(uri.queryParameters[kSslRootCertParam], '/ca.pem'); + expect(uri.queryParameters[kSslCertParam], '/client.crt'); + expect(uri.queryParameters[kSslKeyParam], '/client.key'); + final extracted = extractSslCertificatePaths(uri); + expect(extracted.rootCert, '/ca.pem'); + expect(extracted.clientCert, '/client.crt'); + expect(extracted.clientKey, '/client.key'); + }); + + test('buildRedisConnectionUri uses rediss scheme when SSL enabled', () { + final uri = buildRedisConnectionUri( + host: '127.0.0.1', + port: 6379, + useSSL: true, + ); + expect(uri, startsWith('rediss://127.0.0.1:6379')); + }); + + test('translateQueryaSslParamsForMongo maps params to mongo_dart format', () { + final input = Uri.parse( + 'mongodb://localhost:27017/app?sslrootcert=/ca.pem&sslcert=/client.crt&sslkey=/client.key', + ); + final translated = translateQueryaSslParamsForMongo(input); + expect(translated.queryParameters['tlsCAFile'], '/ca.pem'); + expect(translated.queryParameters['tlsCertificateKeyFile'], '/client.crt'); + expect(translated.queryParameters['sslkey'], '/client.key'); + expect(translated.queryParameters.containsKey('sslrootcert'), isFalse); + expect(translated.queryParameters.containsKey('sslcert'), isFalse); + }); + }); +} From 0a3eb1bb10b9d71f443573ec1e2c50358e897955 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 16:57:45 +0300 Subject: [PATCH 32/47] fix(storage): ensure single-flight thread-safe LocalDb._open initialization - Introduce _openFuture memoization to deduplicate concurrent database open attempts - Safely clear _openFuture on database open failure or when LocalDb.close() is called - Add concurrency tests in local_db_concurrency_test.dart for cold-start parallel queries Closes #645 --- lib/core/storage/local_db.dart | 21 +++++++++++++++++-- .../storage/local_db_concurrency_test.dart | 18 ++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 9815d63..848ce11 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -33,6 +33,7 @@ class LocalDb { Database? _db; String? _cachedDbPath; + Future? _openFuture; static Future initFfi() async { if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { @@ -42,6 +43,19 @@ class LocalDb { Future _open() async { if (_db != null && _db!.isOpen) return _db!; + if (_openFuture != null) return _openFuture!; + + final future = _doOpen(); + _openFuture = future; + try { + return await future; + } catch (_) { + _openFuture = null; + rethrow; + } + } + + Future _doOpen() async { await initFfi(); if (_cachedDbPath == null) { final dir = await AppDataRoot.applicationSupportDirectory(); @@ -49,7 +63,7 @@ class LocalDb { if (!await sub.exists()) await sub.create(recursive: true); _cachedDbPath = p.join(sub.path, _dbName); } - _db = await databaseFactoryFfi.openDatabase( + final db = await databaseFactoryFfi.openDatabase( _cachedDbPath!, options: OpenDatabaseOptions( version: _dbVersion, @@ -63,7 +77,9 @@ class LocalDb { }, ), ); - return _db!; + _db = db; + _openFuture = null; + return db; } /// Queries an active PRAGMA setting from the database for verification and diagnostic purposes. @@ -537,6 +553,7 @@ class LocalDb { } Future close() async { + _openFuture = null; await _db?.close(); _db = null; _cachedDbPath = null; diff --git a/test/core/storage/local_db_concurrency_test.dart b/test/core/storage/local_db_concurrency_test.dart index b40b5f6..e98b4e3 100644 --- a/test/core/storage/local_db_concurrency_test.dart +++ b/test/core/storage/local_db_concurrency_test.dart @@ -119,5 +119,23 @@ void main() { await LocalDb.instance.removeConnection(connId); await LocalDb.instance.removeFolder('Concurrency Test Folder'); }); + + test('deduplicates parallel cold-start open calls via single-flight memoization', () async { + // Close database to simulate completely cold state + await LocalDb.instance.close(); + + // Launch multiple simultaneous queries on a closed database + final parallelOperations = [ + LocalDb.instance.getFolders(), + LocalDb.instance.getConnections(), + LocalDb.instance.getAppSetting('theme_preset'), + LocalDb.instance.getPragma('journal_mode'), + ]; + + final results = await Future.wait(parallelOperations); + expect(results[0], isA>()); + expect(results[1], isA>()); + expect(results[3].toString().toLowerCase(), equals('wal')); + }); }); } From 51b59a5f4377fe00b95114f8930861cac5f040dc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 17:11:25 +0300 Subject: [PATCH 33/47] refactor(workspace): modularize shared SQL editor and result workbench components - Extract shared SQL editor, result grid, calculation, and DML staging components to lib/features/workspace/ - Create workspace.dart barrel file for clean, modular workbench exports - Add forwarding exports in lib/features/main_screen/ for backward compatibility - Update PostgreSQL, MySQL, SQLite, and Extensions workspaces to use workspace.dart - Move workspace unit and integration tests to test/features/workspace/ Closes #646 --- .../extensions/extension_sql_workspace.dart | 6 +- .../extensions/extension_table_view.dart | 3 +- .../main_screen/data_grid_calc_bar.dart | 171 +- .../main_screen/data_grid_filter_bar.dart | 388 +---- .../main_screen/data_grid_groupings_view.dart | 392 +---- .../main_screen/data_grid_staging_buffer.dart | 320 +--- .../data_grid_staging_toolbar.dart | 258 +-- .../main_screen/data_grid_value_panel.dart | 397 +---- .../main_screen/destructive_query_dialog.dart | 308 +--- .../main_screen/dml_preview_dialog.dart | 370 +---- .../main_screen/grid_cell_editor.dart | 205 +-- .../grid_cell_popover_inspector.dart | 262 +-- .../main_screen/grid_data_type_validator.dart | 105 +- .../main_screen/grid_filter_engine.dart | 632 +------- .../main_screen/grid_groupings_engine.dart | 240 +-- .../grid_selection_calc_engine.dart | 249 +-- .../main_screen/query_editor_tab.dart | 35 +- .../main_screen/result_grid_view.dart | 1434 +---------------- lib/features/main_screen/results_tab.dart | 417 +---- .../main_screen/sql_editor_chrome.dart | 109 +- .../main_screen/sql_query_history_dialog.dart | 260 +-- .../main_screen/xml_html_formatter.dart | 118 +- .../mysql/mysql_sql_editor_dialog.dart | 2 +- lib/features/mysql/mysql_sql_workspace.dart | 8 +- .../postgres_sql_editor_dialog.dart | 2 +- .../postgresql/postgres_sql_workspace.dart | 8 +- lib/features/sqlite/sqlite_sql_workspace.dart | 8 +- .../workspace/data_grid_calc_bar.dart | 170 ++ .../workspace/data_grid_filter_bar.dart | 387 +++++ .../workspace/data_grid_groupings_view.dart | 391 +++++ .../workspace/data_grid_staging_buffer.dart | 319 ++++ .../workspace/data_grid_staging_toolbar.dart | 257 +++ .../workspace/data_grid_value_panel.dart | 396 +++++ .../workspace/destructive_query_dialog.dart | 307 ++++ .../workspace/dml_preview_dialog.dart | 369 +++++ lib/features/workspace/grid_cell_editor.dart | 204 +++ .../grid_cell_popover_inspector.dart | 261 +++ .../workspace/grid_data_type_validator.dart | 104 ++ .../workspace/grid_filter_engine.dart | 631 ++++++++ .../workspace/grid_groupings_engine.dart | 239 +++ .../workspace/grid_selection_calc_engine.dart | 248 +++ lib/features/workspace/query_editor_tab.dart | 34 + lib/features/workspace/result_grid_view.dart | 1433 ++++++++++++++++ lib/features/workspace/results_tab.dart | 416 +++++ lib/features/workspace/sql_editor_chrome.dart | 108 ++ .../workspace/sql_query_history_dialog.dart | 259 +++ lib/features/workspace/workspace.dart | 20 + .../workspace/xml_html_formatter.dart | 117 ++ .../data_grid_e2e_integration_test.dart | 8 +- .../data_grid_engines_test.dart | 6 +- .../data_grid_filter_bar_test.dart | 2 +- .../data_grid_staging_buffer_test.dart | 2 +- .../data_grid_value_panel_test.dart | 4 +- .../destructive_query_dialog_test.dart | 2 +- .../dml_preview_dialog_test.dart | 2 +- .../grid_cell_editor_test.dart | 8 +- .../grid_data_type_validator_test.dart | 2 +- .../query_editor_tab_test.dart | 2 +- .../results_tab_test.dart | 8 +- .../sql_editor_chrome_test.dart | 2 +- 60 files changed, 6721 insertions(+), 6704 deletions(-) create mode 100644 lib/features/workspace/data_grid_calc_bar.dart create mode 100644 lib/features/workspace/data_grid_filter_bar.dart create mode 100644 lib/features/workspace/data_grid_groupings_view.dart create mode 100644 lib/features/workspace/data_grid_staging_buffer.dart create mode 100644 lib/features/workspace/data_grid_staging_toolbar.dart create mode 100644 lib/features/workspace/data_grid_value_panel.dart create mode 100644 lib/features/workspace/destructive_query_dialog.dart create mode 100644 lib/features/workspace/dml_preview_dialog.dart create mode 100644 lib/features/workspace/grid_cell_editor.dart create mode 100644 lib/features/workspace/grid_cell_popover_inspector.dart create mode 100644 lib/features/workspace/grid_data_type_validator.dart create mode 100644 lib/features/workspace/grid_filter_engine.dart create mode 100644 lib/features/workspace/grid_groupings_engine.dart create mode 100644 lib/features/workspace/grid_selection_calc_engine.dart create mode 100644 lib/features/workspace/query_editor_tab.dart create mode 100644 lib/features/workspace/result_grid_view.dart create mode 100644 lib/features/workspace/results_tab.dart create mode 100644 lib/features/workspace/sql_editor_chrome.dart create mode 100644 lib/features/workspace/sql_query_history_dialog.dart create mode 100644 lib/features/workspace/workspace.dart create mode 100644 lib/features/workspace/xml_html_formatter.dart rename test/features/{main_screen => workspace}/data_grid_e2e_integration_test.dart (94%) rename test/features/{main_screen => workspace}/data_grid_engines_test.dart (97%) rename test/features/{main_screen => workspace}/data_grid_filter_bar_test.dart (96%) rename test/features/{main_screen => workspace}/data_grid_staging_buffer_test.dart (98%) rename test/features/{main_screen => workspace}/data_grid_value_panel_test.dart (93%) rename test/features/{main_screen => workspace}/destructive_query_dialog_test.dart (97%) rename test/features/{main_screen => workspace}/dml_preview_dialog_test.dart (98%) rename test/features/{main_screen => workspace}/grid_cell_editor_test.dart (95%) rename test/features/{main_screen => workspace}/grid_data_type_validator_test.dart (97%) rename test/features/{main_screen => workspace}/query_editor_tab_test.dart (94%) rename test/features/{main_screen => workspace}/results_tab_test.dart (98%) rename test/features/{main_screen => workspace}/sql_editor_chrome_test.dart (98%) diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index ca27a90..c9d91ac 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -10,11 +10,7 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.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'; -import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; -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/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Table/view selected in the sidebar tree of an extension connection. diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index 0b84031..fc0aad6 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -6,8 +6,7 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/extensions/extension_table_toolbar.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; -import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; diff --git a/lib/features/main_screen/data_grid_calc_bar.dart b/lib/features/main_screen/data_grid_calc_bar.dart index 9a5324a..c5391ec 100644 --- a/lib/features/main_screen/data_grid_calc_bar.dart +++ b/lib/features/main_screen/data_grid_calc_bar.dart @@ -1,170 +1 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart'; -import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -/// Status bar footer for Data Grid displaying live selection statistics (Count, Distinct, Sum, Avg, Median, Min, Max). -class DataGridCalcBar extends StatelessWidget { - const DataGridCalcBar({ - super.key, - required this.stats, - }); - - final GridCalcStats stats; - - @override - Widget build(BuildContext context) { - if (stats.totalCount <= 1 && !stats.hasNumericStats) { - return const material.SizedBox.shrink(); - } - - final cs = Theme.of(context).colorScheme; - - return material.Container( - height: 26, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - decoration: material.BoxDecoration( - color: cs.card, - border: material.Border( - top: material.BorderSide( - color: cs.border.withValues(alpha: 0.35), - width: 1, - ), - ), - ), - child: material.Row( - children: [ - material.Expanded( - child: material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - _StatBadge( - label: 'Count', - value: '${stats.totalCount}', - ), - const Gap(8), - _StatBadge( - label: 'Distinct', - value: '${stats.distinctCount}', - ), - if (stats.nullCount > 0) ...[ - const Gap(8), - _StatBadge( - label: 'NULLs', - value: '${stats.nullCount}', - ), - ], - if (stats.hasNumericStats) ...[ - const Gap(8), - _StatBadge( - label: 'Sum', - value: GridSelectionCalcEngine.formatNum(stats.sum), - ), - const Gap(8), - _StatBadge( - label: 'Avg', - value: GridSelectionCalcEngine.formatNum(stats.average), - ), - if (stats.median != null) ...[ - const Gap(8), - _StatBadge( - label: 'Median', - value: GridSelectionCalcEngine.formatNum(stats.median), - ), - ], - const Gap(8), - _StatBadge( - label: 'Min', - value: GridSelectionCalcEngine.formatNum(stats.min), - ), - const Gap(8), - _StatBadge( - label: 'Max', - value: GridSelectionCalcEngine.formatNum(stats.max), - ), - ], - ], - ), - ), - ), - const Gap(6), - material.Tooltip( - message: 'Copy all stats summary', - child: material.InkWell( - onTap: () { - Clipboard.setData(ClipboardData(text: stats.toSummaryString())); - }, - borderRadius: material.BorderRadius.circular(3), - child: material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon( - material.Icons.copy_all_rounded, - size: 13, - color: cs.mutedForeground, - ), - const Gap(3), - Text('Copy Stats', style: TextStyle(fontSize: 10.5, color: cs.mutedForeground)), - ], - ), - ), - ), - ), - ], - ), - ); - } -} - -class _StatBadge extends StatelessWidget { - const _StatBadge({ - required this.label, - required this.value, - }); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - - return material.Tooltip( - message: 'Click to copy $label: $value', - child: material.InkWell( - onTap: () { - Clipboard.setData(ClipboardData(text: value)); - }, - borderRadius: material.BorderRadius.circular(3), - child: material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - Text( - '$label: ', - style: TextStyle( - fontSize: 11, - color: cs.mutedForeground, - ), - ), - Text( - value, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: cs.foreground, - fontFamily: 'monospace', - ), - ), - ], - ), - ), - ), - ); - } -} +export 'package:querya_desktop/features/workspace/data_grid_calc_bar.dart'; diff --git a/lib/features/main_screen/data_grid_filter_bar.dart b/lib/features/main_screen/data_grid_filter_bar.dart index 96da7aa..96906a2 100644 --- a/lib/features/main_screen/data_grid_filter_bar.dart +++ b/lib/features/main_screen/data_grid_filter_bar.dart @@ -1,387 +1 @@ -import 'package:flutter/material.dart' as material; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -/// Kind of filter autocomplete suggestion. -enum FilterSuggestionKind { - column, - operator, - keyword, -} - -/// Autocomplete suggestion model for the Data Grid filter bar. -class FilterSuggestion { - const FilterSuggestion({ - required this.text, - required this.kind, - this.description = '', - this.insertText, - }); - - final String text; - final FilterSuggestionKind kind; - final String description; - final String? insertText; -} - -/// Helper to compute context-aware syntax suggestions for the filter bar. -abstract final class FilterSuggestionEngine { - static const _operators = [ - FilterSuggestion(text: '=', kind: FilterSuggestionKind.operator, description: 'Equal'), - FilterSuggestion(text: '!=', kind: FilterSuggestionKind.operator, description: 'Not equal'), - FilterSuggestion(text: '>', kind: FilterSuggestionKind.operator, description: 'Greater than'), - FilterSuggestion(text: '>=', kind: FilterSuggestionKind.operator, description: 'Greater or equal'), - FilterSuggestion(text: '<', kind: FilterSuggestionKind.operator, description: 'Less than'), - FilterSuggestion(text: '<=', kind: FilterSuggestionKind.operator, description: 'Less or equal'), - FilterSuggestion(text: 'LIKE', kind: FilterSuggestionKind.operator, description: 'Wildcard match (%_)'), - FilterSuggestion(text: 'ILIKE', kind: FilterSuggestionKind.operator, description: 'Case-insensitive match'), - FilterSuggestion(text: 'IN (...)', kind: FilterSuggestionKind.operator, description: 'List inclusion', insertText: "IN ('')"), - FilterSuggestion(text: 'IS NULL', kind: FilterSuggestionKind.operator, description: 'Null check'), - FilterSuggestion(text: 'IS NOT NULL', kind: FilterSuggestionKind.operator, description: 'Not null check'), - FilterSuggestion(text: 'BETWEEN', kind: FilterSuggestionKind.operator, description: 'Range check', insertText: 'BETWEEN AND '), - ]; - - static const _keywords = [ - FilterSuggestion(text: 'AND', kind: FilterSuggestionKind.keyword, description: 'Logical AND'), - FilterSuggestion(text: 'OR', kind: FilterSuggestionKind.keyword, description: 'Logical OR'), - FilterSuggestion(text: 'NOT', kind: FilterSuggestionKind.keyword, description: 'Logical NOT'), - ]; - - /// Computes suggestions based on current [text] and available [columns]. - static List getSuggestions({ - required String text, - required List columns, - }) { - final trimmed = text.trim(); - if (trimmed.isEmpty) { - return [ - for (final col in columns) - FilterSuggestion( - text: col, - kind: FilterSuggestionKind.column, - description: 'Column', - ), - ]; - } - - final tokens = trimmed.split(RegExp(r'\s+')); - final lastToken = tokens.last; - - // Check if previous token was a column name - if (tokens.length >= 2) { - final prevToken = tokens[tokens.length - 2].toLowerCase(); - final isPrevCol = columns.any((c) => c.toLowerCase() == prevToken); - if (isPrevCol) { - final matches = _operators - .where((op) => op.text.toLowerCase().startsWith(lastToken.toLowerCase())) - .toList(); - if (matches.isNotEmpty) return matches; - } - } - - // If only one token and matches a known column exactly, suggest operators - final exactCol = columns.firstWhere( - (c) => c.toLowerCase() == lastToken.toLowerCase(), - orElse: () => '', - ); - if (exactCol.isNotEmpty) { - return _operators; - } - - // Partial column name match - final matchingCols = columns - .where((c) => c.toLowerCase().startsWith(lastToken.toLowerCase())) - .map( - (c) => FilterSuggestion( - text: c, - kind: FilterSuggestionKind.column, - description: 'Column', - ), - ) - .toList(); - - // Partial keyword match (AND, OR, NOT) - final matchingKw = _keywords - .where((k) => k.text.toLowerCase().startsWith(lastToken.toLowerCase())) - .toList(); - - return [...matchingCols, ...matchingKw]; - } -} - -/// Quick Filter Bar for Data Grid. -/// Allows live client-side row filtering with context-aware autocomplete suggestions. -class DataGridFilterBar extends material.StatefulWidget { - const DataGridFilterBar({ - super.key, - required this.filterText, - required this.onFilterChanged, - required this.totalRowCount, - required this.filteredRowCount, - this.columns = const [], - }); - - final String filterText; - final ValueChanged onFilterChanged; - final int totalRowCount; - final int filteredRowCount; - final List columns; - - @override - material.State createState() => _DataGridFilterBarState(); -} - -class _DataGridFilterBarState extends material.State { - late final material.TextEditingController _controller; - final _layerLink = material.LayerLink(); - material.OverlayEntry? _overlayEntry; - List _suggestions = []; - int _highlightedIndex = 0; - - @override - void initState() { - super.initState(); - _controller = material.TextEditingController(text: widget.filterText); - } - - @override - void didUpdateWidget(covariant DataGridFilterBar oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.filterText != widget.filterText && - _controller.text != widget.filterText) { - _controller.text = widget.filterText; - } - } - - @override - void dispose() { - _hideSuggestions(); - _controller.dispose(); - super.dispose(); - } - - void _onChanged(String val) { - widget.onFilterChanged(val); - _updateSuggestions(val); - } - - void _updateSuggestions(String val) { - final suggs = FilterSuggestionEngine.getSuggestions( - text: val, - columns: widget.columns, - ); - - if (suggs.isEmpty || val.trim().isEmpty) { - _hideSuggestions(); - } else { - _suggestions = suggs; - _highlightedIndex = 0; - _showSuggestions(); - } - } - - void _showSuggestions() { - _hideSuggestions(); - final overlay = material.Overlay.maybeOf(context); - if (overlay == null) return; - - _overlayEntry = material.OverlayEntry( - builder: (context) { - final cs = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - return material.Positioned( - width: 320, - child: material.CompositedTransformFollower( - link: _layerLink, - showWhenUnlinked: false, - offset: const material.Offset(24, 32), - child: material.Material( - elevation: 4, - borderRadius: material.BorderRadius.circular(6), - color: isDark - ? const material.Color(0xFF1E1E22) - : const material.Color(0xFFFFFFFF), - child: material.Container( - decoration: material.BoxDecoration( - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.6), - ), - ), - constraints: const material.BoxConstraints(maxHeight: 200), - child: material.ListView.builder( - shrinkWrap: true, - padding: const material.EdgeInsets.symmetric(vertical: 4), - itemCount: _suggestions.length, - itemBuilder: (ctx, i) { - final s = _suggestions[i]; - final isHighlighted = i == _highlightedIndex; - return material.InkWell( - onTap: () => _applySuggestion(s), - child: material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - color: isHighlighted - ? cs.primary.withValues(alpha: 0.12) - : material.Colors.transparent, - child: material.Row( - children: [ - _buildSuggestionIcon(s.kind, cs), - const Gap(8), - Text(s.text).semiBold().small(), - const Spacer(), - if (s.description.isNotEmpty) - Text(s.description).muted().xSmall(), - ], - ), - ), - ); - }, - ), - ), - ), - ), - ); - }, - ); - - overlay.insert(_overlayEntry!); - } - - material.Widget _buildSuggestionIcon(FilterSuggestionKind kind, ColorScheme cs) { - switch (kind) { - case FilterSuggestionKind.column: - return material.Icon( - material.Icons.table_chart_outlined, - size: 13, - color: cs.primary, - ); - case FilterSuggestionKind.operator: - return material.Icon( - material.Icons.code_rounded, - size: 13, - color: material.Colors.amber.shade700, - ); - case FilterSuggestionKind.keyword: - return material.Icon( - material.Icons.vpn_key_outlined, - size: 13, - color: material.Colors.green.shade600, - ); - } - } - - void _hideSuggestions() { - _overlayEntry?.remove(); - _overlayEntry = null; - } - - void _applySuggestion(FilterSuggestion s) { - final text = _controller.text; - final toInsert = s.insertText ?? s.text; - - final tokens = text.split(RegExp(r'\s+')); - if (tokens.isNotEmpty && s.kind == FilterSuggestionKind.column) { - tokens[tokens.length - 1] = toInsert; - final newText = '${tokens.join(' ')} '; - _controller.value = material.TextEditingValue( - text: newText, - selection: material.TextSelection.collapsed(offset: newText.length), - ); - _onChanged(newText); - } else { - final newText = '$text $toInsert '; - _controller.value = material.TextEditingValue( - text: newText, - selection: material.TextSelection.collapsed(offset: newText.length), - ); - _onChanged(newText); - } - _hideSuggestions(); - } - - @override - material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final isFiltered = widget.filterText.trim().isNotEmpty; - - return material.CompositedTransformTarget( - link: _layerLink, - child: material.Container( - height: 34, - padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 3), - decoration: material.BoxDecoration( - color: cs.card, - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.35), - width: 1, - ), - ), - ), - child: material.Row( - children: [ - material.Icon( - material.Icons.filter_alt_outlined, - size: 15, - color: isFiltered ? cs.primary : cs.mutedForeground, - ), - const Gap(6), - material.Expanded( - child: material.TextField( - controller: _controller, - onChanged: _onChanged, - style: TextStyle( - fontSize: 12, - color: cs.foreground, - ), - decoration: material.InputDecoration( - hintText: 'Filter results... (e.g. "active", "status = ACTIVE", "amount > 100")', - hintStyle: TextStyle( - fontSize: 12, - color: cs.mutedForeground.withValues(alpha: 0.7), - ), - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, - ), - ), - ), - if (isFiltered) ...[ - const Gap(6), - material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: material.BoxDecoration( - color: cs.primary.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(4), - ), - child: Text( - '${widget.filteredRowCount} / ${widget.totalRowCount}', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: cs.primary, - ), - ), - ), - const Gap(4), - material.IconButton( - icon: const material.Icon(material.Icons.close, size: 14), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 20, minHeight: 20), - color: cs.mutedForeground, - onPressed: () { - _controller.clear(); - _hideSuggestions(); - widget.onFilterChanged(''); - }, - ), - ], - ], - ), - ), - ); - } -} +export 'package:querya_desktop/features/workspace/data_grid_filter_bar.dart'; diff --git a/lib/features/main_screen/data_grid_groupings_view.dart b/lib/features/main_screen/data_grid_groupings_view.dart index 1dbf069..2e6bb8e 100644 --- a/lib/features/main_screen/data_grid_groupings_view.dart +++ b/lib/features/main_screen/data_grid_groupings_view.dart @@ -1,391 +1 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart'; -import 'package:querya_desktop/features/main_screen/grid_groupings_engine.dart'; -import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -/// Groupings / Pivot view tab for tabular data with hierarchical grouping and custom aggregations. -class DataGridGroupingsView extends material.StatefulWidget { - const DataGridGroupingsView({ - super.key, - required this.columns, - required this.rows, - }); - - final List columns; - final List> rows; - - @override - material.State createState() => - _DataGridGroupingsViewState(); -} - -class _DataGridGroupingsViewState - extends material.State { - late List _selectedColIndices; - GroupingAggType _aggType = GroupingAggType.count; - int? _aggTargetColIndex; - GroupSortBy _sortBy = GroupSortBy.count; - bool _sortAscending = false; - final Set _expandedKeys = {}; - - @override - void initState() { - super.initState(); - _selectedColIndices = widget.columns.isNotEmpty ? [0] : []; - if (widget.columns.length > 1) { - // Pick first numeric-looking column as default target for sum/avg if available - _aggTargetColIndex = 1; - } - } - - @override - void didUpdateWidget(covariant DataGridGroupingsView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.columns != widget.columns) { - if (_selectedColIndices.isEmpty && widget.columns.isNotEmpty) { - _selectedColIndices = [0]; - } else { - _selectedColIndices.removeWhere((idx) => idx >= widget.columns.length); - if (_selectedColIndices.isEmpty && widget.columns.isNotEmpty) { - _selectedColIndices = [0]; - } - } - } - } - - void _exportPivot() { - final groups = GridGroupingsEngine.buildGroups( - groupColIndices: _selectedColIndices, - rows: widget.rows, - aggConfig: GroupAggregationConfig( - aggType: _aggType, - targetColIndex: _aggTargetColIndex, - ), - sortBy: _sortBy, - sortAscending: _sortAscending, - ); - - final groupName = _selectedColIndices.isNotEmpty - ? widget.columns[_selectedColIndices.first] - : 'Group'; - final csv = GridGroupingsEngine.exportPivotToCsv( - groups: groups, - groupByColumnName: groupName, - aggConfig: GroupAggregationConfig( - aggType: _aggType, - targetColIndex: _aggTargetColIndex, - ), - ); - - Clipboard.setData(ClipboardData(text: csv)); - } - - @override - material.Widget build(material.BuildContext context) { - if (widget.columns.isEmpty || widget.rows.isEmpty) { - return material.Center( - child: const Text('No data available for grouping.').muted(), - ); - } - - final cs = Theme.of(context).colorScheme; - final groups = GridGroupingsEngine.buildGroups( - groupColIndices: _selectedColIndices, - rows: widget.rows, - aggConfig: GroupAggregationConfig( - aggType: _aggType, - targetColIndex: _aggTargetColIndex, - ), - sortBy: _sortBy, - sortAscending: _sortAscending, - ); - - return material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Top Toolbar for selecting Group By, Aggregation, and Sorting - material.Container( - height: 40, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - decoration: material.BoxDecoration( - color: cs.card, - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.35), - width: 1, - ), - ), - ), - child: material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.Row( - children: [ - material.Icon( - material.Icons.account_tree_outlined, - size: 15, - color: cs.primary, - ), - const Gap(6), - const Text('Group:').small().semiBold(), - const Gap(6), - material.DropdownButton( - value: _selectedColIndices.isNotEmpty && - _selectedColIndices.first < widget.columns.length - ? _selectedColIndices.first - : 0, - isDense: true, - underline: const material.SizedBox.shrink(), - style: TextStyle(fontSize: 12, color: cs.foreground), - items: List.generate(widget.columns.length, (i) { - return material.DropdownMenuItem( - value: i, - child: Text(widget.columns[i]), - ); - }), - onChanged: (idx) { - if (idx != null) { - setState(() { - _selectedColIndices = [idx]; - _expandedKeys.clear(); - }); - } - }, - ), - - const Gap(12), - material.VerticalDivider( - width: 1, - thickness: 1, - indent: 8, - endIndent: 8, - color: cs.border.withValues(alpha: 0.3), - ), - const Gap(12), - - // Aggregation Selector - const Text('Agg:').small().semiBold(), - const Gap(6), - material.DropdownButton( - value: _aggType, - isDense: true, - underline: const material.SizedBox.shrink(), - style: TextStyle(fontSize: 12, color: cs.foreground), - items: GroupingAggType.values.map((t) { - return material.DropdownMenuItem( - value: t, - child: Text(t.label), - ); - }).toList(), - onChanged: (val) { - if (val != null) { - setState(() => _aggType = val); - } - }, - ), - if (_aggType != GroupingAggType.count) ...[ - const Gap(4), - material.DropdownButton( - value: _aggTargetColIndex != null && - _aggTargetColIndex! < widget.columns.length - ? _aggTargetColIndex - : 0, - isDense: true, - underline: const material.SizedBox.shrink(), - style: TextStyle(fontSize: 12, color: cs.foreground), - items: List.generate(widget.columns.length, (i) { - return material.DropdownMenuItem( - value: i, - child: Text(widget.columns[i]), - ); - }), - onChanged: (idx) { - if (idx != null) { - setState(() => _aggTargetColIndex = idx); - } - }, - ), - ], - - const Gap(12), - material.VerticalDivider( - width: 1, - thickness: 1, - indent: 8, - endIndent: 8, - color: cs.border.withValues(alpha: 0.3), - ), - const Gap(12), - - // Sort Selector - const Text('Sort:').small().semiBold(), - const Gap(6), - material.DropdownButton( - value: _sortBy, - isDense: true, - underline: const material.SizedBox.shrink(), - style: TextStyle(fontSize: 12, color: cs.foreground), - items: GroupSortBy.values.map((s) { - return material.DropdownMenuItem( - value: s, - child: Text(s.label), - ); - }).toList(), - onChanged: (val) { - if (val != null) { - setState(() => _sortBy = val); - } - }, - ), - material.IconButton( - icon: material.Icon( - _sortAscending - ? material.Icons.arrow_upward_rounded - : material.Icons.arrow_downward_rounded, - size: 14, - ), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), - color: cs.mutedForeground, - onPressed: () => setState(() => _sortAscending = !_sortAscending), - ), - - const Gap(8), - material.Tooltip( - message: 'Copy Pivot CSV to Clipboard', - child: material.IconButton( - icon: const material.Icon(material.Icons.copy_rounded, size: 14), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), - color: cs.mutedForeground, - onPressed: _exportPivot, - ), - ), - ], - ), - ), - ), - - // Groupings List - material.Expanded( - child: material.ListView.separated( - itemCount: groups.length, - separatorBuilder: (_, __) => material.Divider( - height: 1, - color: cs.border.withValues(alpha: 0.2), - ), - itemBuilder: (context, idx) { - final group = groups[idx]; - final isExpanded = _expandedKeys.contains(group.groupKey); - - return material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.InkWell( - onTap: () { - setState(() { - if (isExpanded) { - _expandedKeys.remove(group.groupKey); - } else { - _expandedKeys.add(group.groupKey); - } - }); - }, - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), - child: material.Row( - children: [ - material.Icon( - isExpanded - ? material.Icons.keyboard_arrow_down_rounded - : material.Icons.keyboard_arrow_right_rounded, - size: 18, - color: cs.mutedForeground, - ), - const Gap(8), - material.Expanded( - child: Text( - group.groupKey, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - ), - ), - ), - if (group.aggValue != null && _aggType != GroupingAggType.count) ...[ - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - margin: const material.EdgeInsets.only(right: 6), - decoration: material.BoxDecoration( - color: cs.secondary.withValues(alpha: 0.3), - borderRadius: material.BorderRadius.circular(6), - ), - child: Text( - '${_aggType.label}: ${group.aggValue!.toStringAsFixed(2)}', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: cs.foreground, - ), - ), - ), - ], - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: material.BoxDecoration( - color: cs.primary.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(10), - ), - child: Text( - '${group.count} rows (${group.percentage.toStringAsFixed(1)}%)', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: cs.primary, - ), - ), - ), - ], - ), - ), - ), - - // Expanded sub-grid - if (isExpanded) - material.Container( - height: 220, - margin: const material.EdgeInsets.only( - left: 28, - right: 12, - bottom: 8, - ), - decoration: material.BoxDecoration( - border: material.Border.all( - color: cs.border.withValues(alpha: 0.4), - ), - borderRadius: material.BorderRadius.circular(6), - ), - child: VirtualResultGrid( - columns: widget.columns, - rows: group.rows, - ), - ), - ], - ); - }, - ), - ), - ], - ); - } -} +export 'package:querya_desktop/features/workspace/data_grid_groupings_view.dart'; diff --git a/lib/features/main_screen/data_grid_staging_buffer.dart b/lib/features/main_screen/data_grid_staging_buffer.dart index bae5c99..db5d692 100644 --- a/lib/features/main_screen/data_grid_staging_buffer.dart +++ b/lib/features/main_screen/data_grid_staging_buffer.dart @@ -1,319 +1 @@ -import 'package:flutter/foundation.dart'; -import 'package:querya_desktop/core/database/table_mutation_engine.dart'; - -/// Status of a row within the staging buffer. -enum StagedRowStatus { - unchanged, - modified, - inserted, - deleted, -} - -/// Status of an individual cell. -enum StagedCellStatus { - clean, - modified, -} - -/// In-memory staging buffer for interactive table data edits. -/// -/// Keeps original data intact and tracks staged changes (modified cells, -/// newly inserted rows, and rows marked for deletion). Notifies listeners -/// on any mutation so the UI (VirtualResultGrid, toolbar) updates reactively. -class DataGridStagingBuffer extends ChangeNotifier { - DataGridStagingBuffer({ - required List columns, - required List> rows, - }) : _originalColumns = List.unmodifiable(columns), - _originalRows = List.unmodifiable( - rows.map((r) => List.unmodifiable(r)).toList(), - ); - - final List _originalColumns; - final List> _originalRows; - - /// Map of `rowIndex -> (colIndex -> stagedValue)` for modified cells in baseline rows. - final Map> _modifiedCells = {}; - - /// Rows appended as new records. - final List> _insertedRows = []; - - /// Baseline row indices marked for deletion. - final Set _deletedRowIndices = {}; - - List get columns => _originalColumns; - List> get originalRows => _originalRows; - - /// Total number of visible rows (baseline + inserted). - int get totalRowCount => _originalRows.length + _insertedRows.length; - - /// True if there are any pending edits, insertions, or deletions. - bool get isDirty => - _modifiedCells.isNotEmpty || - _insertedRows.isNotEmpty || - _deletedRowIndices.isNotEmpty; - - /// Total count of staged modifications (modified cells + inserted rows + deleted rows). - int get changeCount { - var cellCount = 0; - for (final colMap in _modifiedCells.values) { - cellCount += colMap.length; - } - return cellCount + _insertedRows.length + _deletedRowIndices.length; - } - - /// Number of baseline rows marked for deletion. - int get deletedRowCount => _deletedRowIndices.length; - - /// Number of newly inserted rows. - int get insertedRowCount => _insertedRows.length; - - /// Number of modified cells in baseline rows. - int get modifiedCellCount { - var count = 0; - for (final colMap in _modifiedCells.values) { - count += colMap.length; - } - return count; - } - - /// Returns the current effective cell value. - String getCellValue(int row, int col) { - if (row < 0 || col < 0) return ''; - if (row < _originalRows.length) { - final staged = _modifiedCells[row]?[col]; - if (staged != null) { - return staged == TableMutationEngine.kNullSentinel ? 'NULL' : staged; - } - if (col < _originalRows[row].length) { - return _originalRows[row][col]; - } - return ''; - } - final insertIdx = row - _originalRows.length; - if (insertIdx < _insertedRows.length && col < _insertedRows[insertIdx].length) { - final ins = _insertedRows[insertIdx][col]; - return ins == TableMutationEngine.kNullSentinel ? 'NULL' : ins; - } - return ''; - } - - /// True if the specified cell is explicitly null or stores 'NULL'. - bool isCellNull(int row, int col) { - if (row < 0 || col < 0) return false; - if (row < _originalRows.length) { - final staged = _modifiedCells[row]?[col]; - if (staged != null) return staged == TableMutationEngine.kNullSentinel; - if (col < _originalRows[row].length) { - final orig = _originalRows[row][col]; - return orig == 'NULL' || orig == TableMutationEngine.kNullSentinel; - } - return false; - } - final insertIdx = row - _originalRows.length; - if (insertIdx < _insertedRows.length && col < _insertedRows[insertIdx].length) { - final ins = _insertedRows[insertIdx][col]; - return ins == 'NULL' || ins == TableMutationEngine.kNullSentinel; - } - return false; - } - - /// Returns original baseline cell value, or null if row is inserted. - String? getOriginalCellValue(int row, int col) { - if (row >= 0 && row < _originalRows.length && col >= 0 && col < _originalRows[row].length) { - return _originalRows[row][col]; - } - return null; - } - - /// Returns the status of the specified row. - StagedRowStatus getRowStatus(int row) { - if (row < 0) return StagedRowStatus.unchanged; - if (row >= _originalRows.length) { - return StagedRowStatus.inserted; - } - if (_deletedRowIndices.contains(row)) { - return StagedRowStatus.deleted; - } - if (_modifiedCells.containsKey(row) && _modifiedCells[row]!.isNotEmpty) { - return StagedRowStatus.modified; - } - return StagedRowStatus.unchanged; - } - - /// Returns the status of the specified cell. - StagedCellStatus getCellStatus(int row, int col) { - if (row >= 0 && row < _originalRows.length) { - if (_modifiedCells[row]?.containsKey(col) == true) { - return StagedCellStatus.modified; - } - } - return StagedCellStatus.clean; - } - - /// Explicitly sets the cell to SQL NULL. - void setCellNull(int row, int col) { - setCell(row, col, TableMutationEngine.kNullSentinel); - } - - /// Stages an edit for the specified cell. - /// If the new value equals original value, clears the modified flag. - void setCell(int row, int col, String value) { - if (row < 0 || col < 0) return; - - if (row < _originalRows.length) { - final orig = col < _originalRows[row].length ? _originalRows[row][col] : ''; - if (value == orig) { - if (_modifiedCells.containsKey(row)) { - _modifiedCells[row]!.remove(col); - if (_modifiedCells[row]!.isEmpty) { - _modifiedCells.remove(row); - } - notifyListeners(); - } - } else { - final rowMap = _modifiedCells.putIfAbsent(row, () => {}); - if (rowMap[col] != value) { - rowMap[col] = value; - notifyListeners(); - } - } - } else { - final insertIdx = row - _originalRows.length; - if (insertIdx < _insertedRows.length) { - while (_insertedRows[insertIdx].length <= col) { - _insertedRows[insertIdx].add(''); - } - if (_insertedRows[insertIdx][col] != value) { - _insertedRows[insertIdx][col] = value; - notifyListeners(); - } - } - } - } - - /// Appends a new empty or pre-filled row. - int addRow([List? initialValues]) { - final row = initialValues != null - ? List.from(initialValues) - : List.filled(_originalColumns.length, ''); - _insertedRows.add(row); - notifyListeners(); - return totalRowCount - 1; - } - - /// Removes an inserted row at the given absolute row index. - void removeInsertedRow(int row) { - final insertIdx = row - _originalRows.length; - if (insertIdx >= 0 && insertIdx < _insertedRows.length) { - _insertedRows.removeAt(insertIdx); - notifyListeners(); - } - } - - /// Marks a baseline row as deleted, or removes an inserted row. - void toggleDeleteRow(int row) { - if (row < 0) return; - if (row < _originalRows.length) { - if (_deletedRowIndices.contains(row)) { - _deletedRowIndices.remove(row); - } else { - _deletedRowIndices.add(row); - } - notifyListeners(); - } else { - removeInsertedRow(row); - } - } - - /// Reverts changes for a single cell back to baseline. - void revertCell(int row, int col) { - if (row < _originalRows.length && _modifiedCells.containsKey(row)) { - if (_modifiedCells[row]!.remove(col) != null) { - if (_modifiedCells[row]!.isEmpty) { - _modifiedCells.remove(row); - } - notifyListeners(); - } - } - } - - /// Reverts all modifications or deletion for a given row. - void revertRow(int row) { - if (row < 0) return; - if (row < _originalRows.length) { - var changed = false; - if (_modifiedCells.remove(row) != null) changed = true; - if (_deletedRowIndices.remove(row)) changed = true; - if (changed) notifyListeners(); - } else { - removeInsertedRow(row); - } - } - - /// Reverts all staged changes and resets buffer to clean baseline. - void revertAll() { - if (!isDirty) return; - _modifiedCells.clear(); - _insertedRows.clear(); - _deletedRowIndices.clear(); - notifyListeners(); - } - - /// Read-only snapshot of modified cells mapping. - Map> get modifiedCells => - Map>.unmodifiable( - _modifiedCells.map((k, v) => MapEntry(k, Map.unmodifiable(v))), - ); - - /// Read-only list of newly inserted rows. - List> get insertedRows => - List.unmodifiable(_insertedRows.map((r) => List.unmodifiable(r))); - - /// Read-only set of deleted row indices. - Set get deletedRowIndices => Set.unmodifiable(_deletedRowIndices); - - /// Generates an atomic [TableMutationPlan] for the current staged changes. - TableMutationPlan generateMutationPlan({ - required SqlDialect dialect, - required String tableName, - String? schema, - List primaryKeys = const [], - Map? columnDataTypes, - }) { - return TableMutationEngine.generatePlan( - dialect: dialect, - tableName: tableName, - schema: schema, - columns: _originalColumns, - primaryKeys: primaryKeys, - originalRows: _originalRows, - modifiedCells: _modifiedCells, - insertedRows: _insertedRows, - deletedRowIndices: _deletedRowIndices, - columnDataTypes: columnDataTypes, - ); - } - - /// Returns the full list of effective rows (original with modifications applied + inserted rows). - List> get effectiveRows { - final result = >[]; - for (var r = 0; r < _originalRows.length; r++) { - final row = List.from(_originalRows[r]); - final mods = _modifiedCells[r]; - if (mods != null) { - for (final entry in mods.entries) { - if (entry.key < row.length) { - row[entry.key] = entry.value; - } - } - } - result.add(row); - } - for (final ins in _insertedRows) { - result.add(List.from(ins)); - } - return result; - } -} - +export 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; diff --git a/lib/features/main_screen/data_grid_staging_toolbar.dart b/lib/features/main_screen/data_grid_staging_toolbar.dart index 50a42cf..6de8acd 100644 --- a/lib/features/main_screen/data_grid_staging_toolbar.dart +++ b/lib/features/main_screen/data_grid_staging_toolbar.dart @@ -1,257 +1 @@ -import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; - -/// Toolbar for managing staged data changes (Add, Delete, Revert, Save). -class DataGridStagingToolbar extends StatelessWidget { - const DataGridStagingToolbar({ - super.key, - required this.stagingBuffer, - this.selectedRowIndex, - this.onApplyChanges, - this.isSaving = false, - }); - - final DataGridStagingBuffer stagingBuffer; - final int? selectedRowIndex; - final VoidCallback? onApplyChanges; - final bool isSaving; - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - - return ListenableBuilder( - listenable: stagingBuffer, - builder: (context, _) { - final isDirty = stagingBuffer.isDirty; - final changeCount = stagingBuffer.changeCount; - final selectedRow = selectedRowIndex; - final hasSelectedRow = selectedRow != null && - selectedRow >= 0 && - selectedRow < stagingBuffer.totalRowCount; - - final isSelectedDeleted = hasSelectedRow && - stagingBuffer.getRowStatus(selectedRow) == StagedRowStatus.deleted; - - return material.Container( - height: 32, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - decoration: material.BoxDecoration( - color: cs.card, - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.35), - width: 1, - ), - ), - ), - child: material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - // Add Row - _ToolbarButton( - label: 'Add Row', - icon: material.Icons.add_rounded, - onPressed: isSaving ? null : () => stagingBuffer.addRow(), - ), - const Gap(4), - - // Delete / Restore Row - _ToolbarButton( - label: isSelectedDeleted ? 'Restore Row' : 'Delete Row', - icon: isSelectedDeleted - ? material.Icons.restore_from_trash_rounded - : material.Icons.remove_circle_outline_rounded, - color: isSelectedDeleted - ? cs.primary - : (hasSelectedRow ? cs.destructive : null), - onPressed: isSaving || !hasSelectedRow - ? null - : () => stagingBuffer.toggleDeleteRow(selectedRow), - ), - const Gap(4), - - // Revert - if (isDirty) ...[ - _ToolbarButton( - label: 'Revert All', - icon: material.Icons.undo_rounded, - color: cs.mutedForeground, - onPressed: isSaving ? null : () => stagingBuffer.revertAll(), - ), - const Gap(6), - ], - - // Badge - if (isDirty) - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: material.BoxDecoration( - color: cs.primary.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(10), - border: material.Border.all( - color: cs.primary.withValues(alpha: 0.3), - width: 1, - ), - ), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Container( - width: 5, - height: 5, - decoration: material.BoxDecoration( - color: cs.primary, - shape: material.BoxShape.circle, - ), - ), - const Gap(5), - Text( - '$changeCount pending ${changeCount == 1 ? 'change' : 'changes'}', - ).xSmall().semiBold(), - ], - ), - ) - else - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon( - material.Icons.check_circle_outline_rounded, - size: 13, - color: cs.mutedForeground, - ), - const Gap(4), - const Text('No changes').xSmall().muted(), - ], - ), - - const Gap(12), - - // Save Changes button - material.MouseRegion( - cursor: (isDirty && !isSaving) - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onTap: isDirty && !isSaving ? onApplyChanges : null, - child: material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: material.BoxDecoration( - color: isDirty - ? cs.primary - : cs.muted.withValues(alpha: 0.4), - borderRadius: material.BorderRadius.circular(4), - ), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - if (isSaving) - material.SizedBox( - width: 12, - height: 12, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: cs.primaryForeground, - ), - ) - else - material.Icon( - material.Icons.save_rounded, - size: 14, - color: isDirty - ? cs.primaryForeground - : cs.mutedForeground, - ), - const Gap(5), - material.Text( - isSaving ? 'Saving…' : 'Save Changes', - style: material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w500, - color: isDirty - ? cs.primaryForeground - : cs.mutedForeground, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ); - }, - ); - } -} - -class _ToolbarButton extends material.StatelessWidget { - const _ToolbarButton({ - required this.label, - required this.icon, - this.onPressed, - this.color, - }); - - final String label; - final material.IconData icon; - final material.VoidCallback? onPressed; - final material.Color? color; - - @override - material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final enabled = onPressed != null; - final fg = color ?? cs.foreground; - - return material.MouseRegion( - cursor: enabled - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.GestureDetector( - onTap: onPressed, - behavior: material.HitTestBehavior.opaque, - child: material.Opacity( - opacity: enabled ? 1.0 : 0.4, - child: material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: material.BoxDecoration( - borderRadius: material.BorderRadius.circular(4), - ), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon(icon, size: 14, color: fg), - const material.SizedBox(width: 4), - material.Text( - label, - style: material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w500, - color: fg, - ), - ), - ], - ), - ), - ), - ), - ); - } -} +export 'package:querya_desktop/features/workspace/data_grid_staging_toolbar.dart'; diff --git a/lib/features/main_screen/data_grid_value_panel.dart b/lib/features/main_screen/data_grid_value_panel.dart index 5b78d4f..296ab70 100644 --- a/lib/features/main_screen/data_grid_value_panel.dart +++ b/lib/features/main_screen/data_grid_value_panel.dart @@ -1,396 +1 @@ -import 'dart:convert'; -import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart'; -import 'package:querya_desktop/core/editor/querya_code_editor.dart'; -import 'package:querya_desktop/core/editor/querya_code_language.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'xml_html_formatter.dart'; - -/// Language mode for Value Panel inspector. -enum ValuePanelLanguage { - auto, - json, - xml, - sql, - text, -} - -/// Collapsible right-hand side panel for inspecting cell content in detail. -class DataGridValuePanel extends material.StatefulWidget { - const DataGridValuePanel({ - super.key, - required this.columnName, - required this.cellValue, - required this.rowIndex, - required this.onClose, - this.onUpdateValue, - }); - - final String columnName; - final String cellValue; - final int? rowIndex; - final material.VoidCallback onClose; - final ValueChanged? onUpdateValue; - - @override - material.State createState() => _DataGridValuePanelState(); -} - -class _DataGridValuePanelState extends material.State { - late final material.TextEditingController _controller; - ValuePanelLanguage _selectedLanguage = ValuePanelLanguage.auto; - String? _validationError; - bool _wordWrap = true; - - @override - void initState() { - super.initState(); - _controller = material.TextEditingController(text: _formatInitialValue(widget.cellValue)); - _controller.addListener(_validateContent); - _validateContent(); - } - - @override - void didUpdateWidget(covariant DataGridValuePanel oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.cellValue != widget.cellValue) { - _controller.text = _formatInitialValue(widget.cellValue); - _validateContent(); - } - } - - @override - void dispose() { - _controller.removeListener(_validateContent); - _controller.dispose(); - super.dispose(); - } - - String _formatInitialValue(String input) { - final trimmed = input.trim(); - if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || - (trimmed.startsWith('[') && trimmed.endsWith(']'))) { - try { - final parsed = jsonDecode(trimmed); - return const JsonEncoder.withIndent(' ').convert(parsed); - } catch (_) {} - } else if (trimmed.startsWith('<') && trimmed.endsWith('>')) { - try { - return XmlHtmlFormatter.format(trimmed); - } catch (_) {} - } - return input; - } - - ValuePanelLanguage get _effectiveLanguage { - if (_selectedLanguage != ValuePanelLanguage.auto) { - return _selectedLanguage; - } - final trimmed = _controller.text.trim(); - if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || - (trimmed.startsWith('[') && trimmed.endsWith(']'))) { - return ValuePanelLanguage.json; - } - if (trimmed.startsWith('<') && trimmed.endsWith('>')) { - return ValuePanelLanguage.xml; - } - final upper = trimmed.toUpperCase(); - if (upper.startsWith('SELECT ') || - upper.startsWith('INSERT ') || - upper.startsWith('UPDATE ') || - upper.startsWith('CREATE ') || - upper.startsWith('WITH ')) { - return ValuePanelLanguage.sql; - } - return ValuePanelLanguage.text; - } - - void _validateContent() { - final text = _controller.text.trim(); - if (text.isEmpty) { - if (_validationError != null) { - setState(() => _validationError = null); - } - return; - } - - final lang = _effectiveLanguage; - String? err; - - if (lang == ValuePanelLanguage.json) { - try { - jsonDecode(text); - } catch (e) { - err = 'Invalid JSON: $e'; - } - } else if (lang == ValuePanelLanguage.xml) { - err = XmlHtmlFormatter.validate(text); - } - - if (err != _validationError) { - setState(() => _validationError = err); - } - } - - void _formatCode() { - final lang = _effectiveLanguage; - if (lang == ValuePanelLanguage.json) { - try { - final parsed = jsonDecode(_controller.text); - final pretty = const JsonEncoder.withIndent(' ').convert(parsed); - setState(() => _controller.text = pretty); - } catch (_) {} - } else if (lang == ValuePanelLanguage.xml) { - final pretty = XmlHtmlFormatter.format(_controller.text); - setState(() => _controller.text = pretty); - } - } - - void _minifyCode() { - final lang = _effectiveLanguage; - if (lang == ValuePanelLanguage.json) { - try { - final parsed = jsonDecode(_controller.text); - final compact = jsonEncode(parsed); - setState(() => _controller.text = compact); - } catch (_) {} - } else if (lang == ValuePanelLanguage.xml) { - final compact = XmlHtmlFormatter.minify(_controller.text); - setState(() => _controller.text = compact); - } - } - - QueryaCodeLanguage _toQueryaLanguage(ValuePanelLanguage lang) { - switch (lang) { - case ValuePanelLanguage.json: - return QueryaCodeLanguage.json; - case ValuePanelLanguage.sql: - return QueryaCodeLanguage.sql; - default: - return QueryaCodeLanguage.plain; - } - } - - @override - material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final activeLang = _effectiveLanguage; - - return material.Container( - width: 340, - decoration: material.BoxDecoration( - color: cs.card, - border: material.Border( - left: material.BorderSide( - color: cs.border.withValues(alpha: 0.35), - width: 1, - ), - ), - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Panel Header - material.Container( - height: 36, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - decoration: material.BoxDecoration( - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.35), - width: 1, - ), - ), - ), - child: material.Row( - children: [ - material.Icon( - material.Icons.data_object_rounded, - size: 15, - color: cs.primary, - ), - const Gap(6), - material.Expanded( - child: Text( - '${widget.columnName}${widget.rowIndex != null ? ' [Row ${widget.rowIndex! + 1}]' : ''}', - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - ).small().semiBold(), - ), - material.IconButton( - icon: const material.Icon(material.Icons.close, size: 14), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), - color: cs.mutedForeground, - onPressed: widget.onClose, - ), - ], - ), - ), - - // Toolbar with language selector and actions - material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), - color: cs.background.withValues(alpha: 0.4), - child: material.Row( - children: [ - // Language Dropdown / Pill - material.DropdownButton( - value: _selectedLanguage, - isDense: true, - underline: const material.SizedBox(), - icon: const material.Icon(material.Icons.arrow_drop_down, size: 16), - style: TextStyle( - fontSize: 11, - color: cs.foreground, - fontWeight: FontWeight.w600, - ), - items: const [ - material.DropdownMenuItem( - value: ValuePanelLanguage.auto, - child: Text('Auto'), - ), - material.DropdownMenuItem( - value: ValuePanelLanguage.json, - child: Text('JSON'), - ), - material.DropdownMenuItem( - value: ValuePanelLanguage.xml, - child: Text('XML/HTML'), - ), - material.DropdownMenuItem( - value: ValuePanelLanguage.sql, - child: Text('SQL'), - ), - material.DropdownMenuItem( - value: ValuePanelLanguage.text, - child: Text('Plain Text'), - ), - ], - onChanged: (val) { - if (val != null) { - setState(() => _selectedLanguage = val); - _validateContent(); - } - }, - ), - const Gap(6), - if (activeLang == ValuePanelLanguage.json || activeLang == ValuePanelLanguage.xml) ...[ - material.TextButton( - onPressed: _formatCode, - style: material.TextButton.styleFrom( - padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), - minimumSize: material.Size.zero, - tapTargetSize: material.MaterialTapTargetSize.shrinkWrap, - ), - child: const Text('Format').small(), - ), - const Gap(4), - material.TextButton( - onPressed: _minifyCode, - style: material.TextButton.styleFrom( - padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), - minimumSize: material.Size.zero, - tapTargetSize: material.MaterialTapTargetSize.shrinkWrap, - ), - child: const Text('Minify').small(), - ), - ], - const material.Spacer(), - material.IconButton( - icon: material.Icon( - _wordWrap ? material.Icons.wrap_text : material.Icons.notes, - size: 14, - ), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), - color: _wordWrap ? cs.primary : cs.mutedForeground, - onPressed: () => setState(() => _wordWrap = !_wordWrap), - ), - material.IconButton( - icon: const material.Icon(material.Icons.copy_rounded, size: 14), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), - color: cs.mutedForeground, - onPressed: () { - Clipboard.setData(ClipboardData(text: _controller.text)); - }, - ), - ], - ), - ), - - // Validation Error Banner (if any) - if (_validationError != null) - material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), - color: cs.destructive.withValues(alpha: 0.12), - child: material.Row( - children: [ - material.Icon( - material.Icons.warning_amber_rounded, - size: 14, - color: cs.destructive, - ), - const Gap(6), - material.Expanded( - child: Text( - _validationError!, - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.5, - color: cs.destructive, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - - // Code Editor Area with Syntax Highlighting - material.Expanded( - child: material.Padding( - padding: const material.EdgeInsets.all(6), - child: QueryaCodeEditor( - controller: _controller, - language: _toQueryaLanguage(activeLang), - enableHighlighting: true, - fontSize: 12, - variant: QueryaCodeEditorVariant.material, - ), - ), - ), - - // Apply button if editable - if (widget.onUpdateValue != null) - material.Container( - padding: const material.EdgeInsets.all(8), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: cs.border.withValues(alpha: 0.35), - width: 1, - ), - ), - ), - child: material.ElevatedButton( - onPressed: () { - widget.onUpdateValue!(_controller.text); - }, - style: material.ElevatedButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.primaryForeground, - padding: const material.EdgeInsets.symmetric(vertical: 8), - minimumSize: material.Size.zero, - ), - child: const Text('Update Cell Value').small().bold(), - ), - ), - ], - ), - ); - } -} +export 'package:querya_desktop/features/workspace/data_grid_value_panel.dart'; diff --git a/lib/features/main_screen/destructive_query_dialog.dart b/lib/features/main_screen/destructive_query_dialog.dart index 30d59a3..426b627 100644 --- a/lib/features/main_screen/destructive_query_dialog.dart +++ b/lib/features/main_screen/destructive_query_dialog.dart @@ -1,307 +1 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart'; -import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; -import 'package:querya_desktop/shared/widgets/app_dialog.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -/// Opens a confirmation dialog when destructive SQL statements (DROP, TRUNCATE, etc.) -/// are detected before execution. -/// -/// Returns `true` if the user confirmed execution, or `false`/`null` if cancelled. -Future showDestructiveQueryDialog({ - required material.BuildContext context, - required DestructiveSqlInspectionResult result, - required String sql, - String? connectionName, -}) { - return showAppDialog( - context: context, - builder: (ctx) => _DestructiveQueryDialog( - result: result, - sql: sql, - connectionName: connectionName, - ), - ); -} - -class _DestructiveQueryDialog extends material.StatefulWidget { - const _DestructiveQueryDialog({ - required this.result, - required this.sql, - this.connectionName, - }); - - final DestructiveSqlInspectionResult result; - final String sql; - final String? connectionName; - - @override - material.State<_DestructiveQueryDialog> createState() => - _DestructiveQueryDialogState(); -} - -class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialog> { - bool _acknowledged = false; - bool _copied = false; - - Future _copySql() async { - await Clipboard.setData(ClipboardData(text: widget.sql)); - if (!mounted) return; - setState(() => _copied = true); - await Future.delayed(const Duration(seconds: 2)); - if (mounted) setState(() => _copied = false); - } - - @override - material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; - final isDark = theme.brightness == Brightness.dark; - final isCritical = widget.result.maxRiskLevel == 'CRITICAL'; - - return material.Dialog( - backgroundColor: cs.card, - shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(8), - side: material.BorderSide( - color: cs.destructive.withValues(alpha: isDark ? 0.6 : 0.4), - width: 1.5, - ), - ), - child: material.FocusTraversalGroup( - policy: material.WidgetOrderTraversalPolicy(), - child: material.ConstrainedBox( - constraints: const material.BoxConstraints( - minWidth: 540, - maxWidth: 680, - minHeight: 440, - maxHeight: 580, - ), - child: material.SizedBox( - height: 540, - child: material.Padding( - padding: const material.EdgeInsets.all(20), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Header - material.Row( - children: [ - material.Container( - padding: const material.EdgeInsets.all(8), - decoration: material.BoxDecoration( - color: cs.destructive.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(8), - ), - child: material.Icon( - material.Icons.warning_amber_rounded, - size: 24, - color: cs.destructive, - ), - ), - const Gap(12), - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - Text( - isCritical - ? 'Critical Destructive Operation' - : 'Destructive Operation Detected', - ).semiBold().large(), - const Gap(2), - if (widget.connectionName != null) - Text( - 'Target connection: ${widget.connectionName}', - ).muted().small() - else - const Text( - 'This statement will permanently alter or delete database objects.', - ).muted().small(), - ], - ), - ), - ], - ), - const Gap(16), - - // Detected operations list - material.Container( - padding: const material.EdgeInsets.all(12), - decoration: material.BoxDecoration( - color: cs.destructive.withValues( - alpha: isDark ? 0.12 : 0.06, - ), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.destructive.withValues( - alpha: isDark ? 0.35 : 0.25, - ), - ), - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - for (final op in widget.result.operations) ...[ - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: material.BoxDecoration( - color: cs.destructive, - borderRadius: material.BorderRadius.circular(4), - ), - child: Text( - op.type.label, - style: const TextStyle( - color: material.Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - const Gap(8), - material.Expanded( - child: Text( - op.description, - style: material.TextStyle( - fontSize: 12, - color: cs.foreground, - fontWeight: material.FontWeight.w500, - ), - ), - ), - ], - ), - if (op != widget.result.operations.last) const Gap(8), - ], - ], - ), - ), - const Gap(14), - - // SQL Script Preview Header - material.Row( - mainAxisAlignment: material.MainAxisAlignment.spaceBetween, - children: [ - const Text('QUERY PREVIEW').semiBold().xSmall().muted(), - material.InkWell( - onTap: _copySql, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon( - _copied - ? material.Icons.check_rounded - : material.Icons.copy_rounded, - size: 13, - color: _copied - ? material.Colors.green - : cs.mutedForeground, - ), - const Gap(4), - Text(_copied ? 'Copied' : 'Copy SQL').xSmall(), - ], - ), - ), - ), - ], - ), - const Gap(6), - - // SQL Code block container - material.Expanded( - child: material.Container( - padding: const material.EdgeInsets.all(12), - decoration: material.BoxDecoration( - color: isDark - ? const material.Color(0xFF141416) - : const material.Color(0xFFF4F4F6), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.6), - ), - ), - child: material.SingleChildScrollView( - child: material.SelectableText( - widget.sql, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12.5, - height: 1.45, - color: isDark - ? const material.Color(0xFFE2E8F0) - : const material.Color(0xFF1E293B), - ), - ), - ), - ), - ), - const Gap(14), - - // Confirmation Checkbox - material.Row( - children: [ - material.Checkbox( - value: _acknowledged, - onChanged: (v) => setState(() => _acknowledged = v ?? false), - ), - const Gap(8), - material.Expanded( - child: material.GestureDetector( - onTap: () => setState(() => _acknowledged = !_acknowledged), - child: const Text( - 'I understand that this query cannot be undone and may result in permanent data loss.', - ).small(), - ), - ), - ], - ), - const Gap(16), - - // Action buttons - material.FocusTraversalGroup( - policy: material.WidgetOrderTraversalPolicy(), - child: material.Wrap( - spacing: 12, - runSpacing: 8, - alignment: material.WrapAlignment.end, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - DestructiveButton( - onPressed: _acknowledged - ? () => material.Navigator.of(context).pop(true) - : null, - leading: const material.Icon( - material.Icons.delete_forever_rounded, - size: 16, - ), - child: const Text('Execute Destructive Statement'), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} +export 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; diff --git a/lib/features/main_screen/dml_preview_dialog.dart b/lib/features/main_screen/dml_preview_dialog.dart index 3db5185..748554e 100644 --- a/lib/features/main_screen/dml_preview_dialog.dart +++ b/lib/features/main_screen/dml_preview_dialog.dart @@ -1,369 +1 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart'; -import 'package:querya_desktop/core/database/table_mutation_engine.dart'; -import 'package:querya_desktop/shared/widgets/app_dialog.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -/// Opens the DML Preview and Confirmation dialog before executing staged changes. -/// -/// Returns `true` if user confirmed execution, or `false`/`null` if cancelled. -Future showDmlPreviewDialog({ - required material.BuildContext context, - required TableMutationPlan plan, -}) { - return showAppDialog( - context: context, - builder: (ctx) => _DmlPreviewDialog(plan: plan), - ); -} - -class _DmlPreviewDialog extends material.StatefulWidget { - const _DmlPreviewDialog({required this.plan}); - - final TableMutationPlan plan; - - @override - material.State<_DmlPreviewDialog> createState() => _DmlPreviewDialogState(); -} - -class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { - bool _copied = false; - - int get _updateCount => - widget.plan.statements.where((s) => s.type == MutationType.update).length; - - int get _insertCount => - widget.plan.statements.where((s) => s.type == MutationType.insert).length; - - int get _deleteCount => - widget.plan.statements.where((s) => s.type == MutationType.delete).length; - - String get _dialectName { - switch (widget.plan.dialect) { - case SqlDialect.postgres: - return 'PostgreSQL'; - case SqlDialect.mysql: - return 'MySQL'; - case SqlDialect.sqlite: - return 'SQLite'; - } - } - - Future _copySql() async { - final sql = widget.plan.toTransactionSql(); - await Clipboard.setData(ClipboardData(text: sql)); - if (!mounted) return; - setState(() => _copied = true); - await Future.delayed(const Duration(seconds: 2)); - if (mounted) setState(() => _copied = false); - } - - @override - material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; - final isDark = theme.brightness == Brightness.dark; - final sql = widget.plan.toTransactionSql(); - - return material.Dialog( - backgroundColor: cs.card, - shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(8), - side: material.BorderSide(color: cs.border, width: 1), - ), - child: material.ConstrainedBox( - constraints: const material.BoxConstraints( - minWidth: 540, - maxWidth: 680, - minHeight: 380, - maxHeight: 580, - ), - child: material.Padding( - padding: const material.EdgeInsets.all(18), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Header Row - material.Row( - children: [ - material.Icon( - material.Icons.save_as_rounded, - size: 20, - color: cs.primary, - ), - const Gap(8), - const Text('Confirm Data Changes').semiBold().large(), - ], - ), - const Gap(4), - const Text( - 'Review pending SQL mutations before applying them to the database.', - ).muted().small(), - const Gap(14), - - // Metadata badges row - material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - // Target Table Badge - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: material.BoxDecoration( - color: cs.muted, - borderRadius: material.BorderRadius.circular(6), - ), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon( - material.Icons.table_chart_outlined, - size: 14, - color: cs.foreground, - ), - const Gap(6), - Text( - widget.plan.schema != null && - widget.plan.schema!.isNotEmpty - ? '${widget.plan.schema}.${widget.plan.tableName}' - : widget.plan.tableName, - ).semiBold().small(), - ], - ), - ), - - // Dialect Badge - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: material.BoxDecoration( - color: cs.primary.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: cs.primary.withValues(alpha: 0.3), - ), - ), - child: Text( - _dialectName, - style: TextStyle( - color: cs.primary, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ), - - // Changes Breakdown Pills - if (_updateCount > 0) - _buildCountPill( - label: '$_updateCount UPDATE', - color: material.Colors.amber.shade700, - isDark: isDark, - ), - if (_insertCount > 0) - _buildCountPill( - label: '$_insertCount INSERT', - color: material.Colors.green.shade600, - isDark: isDark, - ), - if (_deleteCount > 0) - _buildCountPill( - label: '$_deleteCount DELETE', - color: material.Colors.red.shade600, - isDark: isDark, - ), - ], - ), - - // Warning banner if table lacks primary key and performs UPDATE/DELETE - if (!widget.plan.hasPrimaryKey && - widget.plan.statements.any( - (s) => - s.type == MutationType.update || - s.type == MutationType.delete, - )) ...[ - const Gap(10), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: material.BoxDecoration( - color: material.Colors.amber.withValues( - alpha: isDark ? 0.15 : 0.08, - ), - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: material.Colors.amber.withValues(alpha: 0.4), - ), - ), - child: material.Row( - children: [ - material.Icon( - material.Icons.warning_amber_rounded, - size: 15, - color: material.Colors.amber.shade700, - ), - const Gap(8), - material.Expanded( - child: const Text( - 'No Primary Key detected. WHERE clauses compare all columns (identical duplicate rows will be modified together).', - ).xSmall().muted(), - ), - ], - ), - ), - ], - const Gap(14), - - // SQL Preview code block header - material.Row( - mainAxisAlignment: material.MainAxisAlignment.spaceBetween, - children: [ - const Text('TRANSACTION SCRIPT').semiBold().xSmall(), - material.InkWell( - onTap: _copySql, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon( - _copied - ? material.Icons.check_rounded - : material.Icons.copy_rounded, - size: 13, - color: _copied - ? material.Colors.green - : cs.mutedForeground, - ), - const Gap(4), - Text(_copied ? 'Copied' : 'Copy SQL').xSmall(), - ], - ), - ), - ), - ], - ), - const Gap(6), - - // SQL Code Preview Container - material.Expanded( - child: material.Container( - padding: const material.EdgeInsets.all(12), - decoration: material.BoxDecoration( - color: isDark - ? const material.Color(0xFF141416) - : const material.Color(0xFFF4F4F6), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.6), - ), - ), - child: material.SingleChildScrollView( - child: material.SelectableText( - sql, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12.5, - height: 1.45, - color: isDark - ? const material.Color(0xFFE2E8F0) - : const material.Color(0xFF1E293B), - ), - ), - ), - ), - ), - const Gap(12), - - // Atomic Notice - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.4), - borderRadius: material.BorderRadius.circular(6), - ), - child: material.Row( - children: [ - material.Icon( - material.Icons.info_outline, - size: 15, - color: cs.mutedForeground, - ), - const Gap(8), - const material.Expanded( - child: Text( - 'All mutations will be executed atomically in a single transaction.', - ), - ), - ], - ), - ), - const Gap(16), - - // Actions - material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - OutlineButton( - onPressed: () => material.Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - const Gap(8), - PrimaryButton( - onPressed: () => material.Navigator.of(context).pop(true), - leading: const material.Icon( - material.Icons.save_outlined, - size: 16, - ), - child: const Text('Apply Changes'), - ), - ], - ), - ], - ), - ), - ), - ); - } - - material.Widget _buildCountPill({ - required String label, - required material.Color color, - required bool isDark, - }) { - return material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: material.BoxDecoration( - color: color.withValues(alpha: isDark ? 0.18 : 0.12), - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: color.withValues(alpha: isDark ? 0.45 : 0.3), - ), - ), - child: Text( - label, - style: TextStyle( - color: color, - fontWeight: FontWeight.w600, - fontSize: 11, - ), - ), - ); - } -} +export 'package:querya_desktop/features/workspace/dml_preview_dialog.dart'; diff --git a/lib/features/main_screen/grid_cell_editor.dart b/lib/features/main_screen/grid_cell_editor.dart index 3b714e1..e1696cd 100644 --- a/lib/features/main_screen/grid_cell_editor.dart +++ b/lib/features/main_screen/grid_cell_editor.dart @@ -1,204 +1 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart'; -import 'package:querya_desktop/features/main_screen/grid_data_type_validator.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -/// Active inline editor widget for a data grid cell. -class GridCellEditor extends material.StatefulWidget { - const GridCellEditor({ - super.key, - required this.initialValue, - required this.width, - required this.height, - required this.onCommit, - required this.onCancel, - this.dataTypeName, - this.onOpenInspector, - }); - - final String initialValue; - final double width; - final double height; - final String? dataTypeName; - final void Function( - String value, { - bool moveNextCol, - bool movePrevCol, - bool moveNextRow, - bool movePrevRow, - }) onCommit; - final material.VoidCallback onCancel; - final material.VoidCallback? onOpenInspector; - - @override - material.State createState() => _GridCellEditorState(); -} - -class _GridCellEditorState extends material.State { - late final material.TextEditingController _controller; - final _focusNode = material.FocusNode(); - String? _validationError; - - @override - void initState() { - super.initState(); - final isNull = widget.initialValue == 'NULL'; - _controller = material.TextEditingController( - text: isNull ? '' : widget.initialValue, - ); - _controller.selection = material.TextSelection( - baseOffset: 0, - extentOffset: _controller.text.length, - ); - - _validate(); - _controller.addListener(_validate); - } - - void _validate() { - final error = GridDataTypeValidator.validate( - _controller.text, - dataTypeName: widget.dataTypeName, - ); - if (error != _validationError) { - setState(() { - _validationError = error; - }); - } - } - - @override - void dispose() { - _controller.removeListener(_validate); - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - void _handleKeyEvent(KeyEvent event) { - if (event is! KeyDownEvent) return; - - final isShift = HardwareKeyboard.instance.isShiftPressed; - final isAlt = HardwareKeyboard.instance.isAltPressed; - final isControl = HardwareKeyboard.instance.isControlPressed || - HardwareKeyboard.instance.isMetaPressed; - - // Alt+N / Ctrl+Alt+N -> Set NULL - if (event.logicalKey == LogicalKeyboardKey.keyN && (isAlt || (isControl && isAlt))) { - widget.onCommit('NULL'); - return; - } - - // Alt+Enter or Ctrl+Enter -> Open Inspector - if ((event.logicalKey == LogicalKeyboardKey.enter || - event.logicalKey == LogicalKeyboardKey.numpadEnter) && - (isAlt || isControl)) { - widget.onOpenInspector?.call(); - return; - } - - // Enter / Shift+Enter -> Commit and navigate row - if (event.logicalKey == LogicalKeyboardKey.enter || - event.logicalKey == LogicalKeyboardKey.numpadEnter) { - if (isShift) { - widget.onCommit(_controller.text, movePrevRow: true); - } else { - widget.onCommit(_controller.text, moveNextRow: true); - } - return; - } - - // Tab / Shift+Tab -> Commit and navigate col - if (event.logicalKey == LogicalKeyboardKey.tab) { - if (isShift) { - widget.onCommit(_controller.text, movePrevCol: true); - } else { - widget.onCommit(_controller.text, moveNextCol: true); - } - return; - } - - // Escape -> Cancel - if (event.logicalKey == LogicalKeyboardKey.escape) { - widget.onCancel(); - return; - } - } - - @override - material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final hasError = _validationError != null; - - return material.Container( - width: widget.width, - height: widget.height, - decoration: material.BoxDecoration( - color: cs.card, - border: material.Border.all( - color: hasError ? material.Colors.red.shade500 : cs.primary, - width: 1.5, - ), - ), - padding: const material.EdgeInsets.symmetric(horizontal: 6), - alignment: material.Alignment.centerLeft, - child: material.Row( - children: [ - material.Expanded( - child: material.KeyboardListener( - focusNode: _focusNode, - onKeyEvent: _handleKeyEvent, - autofocus: true, - child: material.TextField( - controller: _controller, - autofocus: true, - maxLines: 1, - style: const material.TextStyle( - fontSize: 12, - fontFamily: 'monospace', - ), - decoration: const material.InputDecoration( - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, - ), - onSubmitted: (value) { - widget.onCommit(value, moveNextRow: true); - }, - ), - ), - ), - if (hasError) - material.Tooltip( - message: _validationError!, - child: material.Padding( - padding: const material.EdgeInsets.only(left: 4), - child: material.Icon( - material.Icons.error_outline_rounded, - size: 14, - color: material.Colors.red.shade500, - ), - ), - ), - if (widget.onOpenInspector != null) - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.GestureDetector( - onTap: () { - widget.onOpenInspector!(); - }, - child: material.Padding( - padding: const material.EdgeInsets.only(left: 4), - child: material.Icon( - material.Icons.open_in_full_rounded, - size: 13, - color: cs.mutedForeground, - ), - ), - ), - ), - ], - ), - ); - } -} +export 'package:querya_desktop/features/workspace/grid_cell_editor.dart'; diff --git a/lib/features/main_screen/grid_cell_popover_inspector.dart b/lib/features/main_screen/grid_cell_popover_inspector.dart index 8f0d744..50934ae 100644 --- a/lib/features/main_screen/grid_cell_popover_inspector.dart +++ b/lib/features/main_screen/grid_cell_popover_inspector.dart @@ -1,261 +1 @@ -import 'dart:convert'; -import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; - -/// Opens a rich modal inspector for viewing and editing large text or JSON values. -Future showGridCellInspectorDialog({ - required material.BuildContext context, - required String columnName, - required String initialValue, - int? rowIndex, -}) { - return showAppDialog( - context: context, - builder: (ctx) => _GridCellInspectorDialog( - columnName: columnName, - initialValue: initialValue, - rowIndex: rowIndex, - ), - ); -} - -class _GridCellInspectorDialog extends material.StatefulWidget { - const _GridCellInspectorDialog({ - required this.columnName, - required this.initialValue, - this.rowIndex, - }); - - final String columnName; - final String initialValue; - final int? rowIndex; - - @override - material.State<_GridCellInspectorDialog> createState() => - _GridCellInspectorDialogState(); -} - -class _GridCellInspectorDialogState - extends material.State<_GridCellInspectorDialog> { - late final material.TextEditingController _controller; - bool _isNull = false; - - @override - void initState() { - super.initState(); - _isNull = widget.initialValue == 'NULL'; - _controller = material.TextEditingController( - text: _isNull ? '' : widget.initialValue, - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _formatJson() { - try { - final parsed = jsonDecode(_controller.text); - final pretty = const JsonEncoder.withIndent(' ').convert(parsed); - setState(() { - _isNull = false; - _controller.text = pretty; - }); - } catch (_) { - // Not valid JSON, keep as is - } - } - - void _minifyJson() { - try { - final parsed = jsonDecode(_controller.text); - final compact = jsonEncode(parsed); - setState(() { - _isNull = false; - _controller.text = compact; - }); - } catch (_) { - // Not valid JSON, keep as is - } - } - - void _setNull() { - setState(() { - _isNull = true; - _controller.clear(); - }); - } - - bool _isJson() { - final text = _controller.text.trim(); - if ((text.startsWith('{') && text.endsWith('}')) || - (text.startsWith('[') && text.endsWith(']'))) { - try { - jsonDecode(text); - return true; - } catch (_) { - return false; - } - } - return false; - } - - @override - material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final rowLabel = - widget.rowIndex != null ? ' (Row ${widget.rowIndex! + 1})' : ''; - - return material.Dialog( - backgroundColor: cs.card, - shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(8), - side: material.BorderSide(color: cs.border, width: 1), - ), - child: material.ConstrainedBox( - constraints: const material.BoxConstraints( - minWidth: 500, - maxWidth: 720, - minHeight: 380, - maxHeight: 560, - ), - child: material.Padding( - padding: const material.EdgeInsets.all(16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Header - material.Row( - children: [ - material.Icon( - material.Icons.data_object_rounded, - size: 18, - color: cs.primary, - ), - const Gap(8), - material.Expanded( - child: Text( - 'Edit ${widget.columnName}$rowLabel', - ).semiBold(), - ), - if (_isJson()) ...[ - GhostButton( - density: ButtonDensity.compact, - onPressed: _formatJson, - leading: const material.Icon( - material.Icons.format_align_left_rounded, - size: 14, - ), - child: const Text('Format JSON'), - ), - const Gap(6), - GhostButton( - density: ButtonDensity.compact, - onPressed: _minifyJson, - leading: const material.Icon( - material.Icons.compress_rounded, - size: 14, - ), - child: const Text('Minify'), - ), - const Gap(6), - ], - GhostButton( - density: ButtonDensity.compact, - onPressed: _isNull ? null : _setNull, - child: const Text('Set NULL'), - ), - ], - ), - const Gap(12), - - // Editor Body - material.Expanded( - child: material.Container( - decoration: material.BoxDecoration( - color: cs.background, - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: _isNull - ? cs.primary.withValues(alpha: 0.5) - : cs.border, - width: 1, - ), - ), - child: _isNull - ? material.Center( - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Value is NULL').muted().semiBold(), - const Gap(8), - GhostButton( - density: ButtonDensity.compact, - onPressed: () => setState(() => _isNull = false), - child: const Text('Enter text value'), - ), - ], - ), - ) - : material.TextField( - controller: _controller, - maxLines: null, - expands: true, - autofocus: true, - style: const material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - ), - decoration: const material.InputDecoration( - border: material.InputBorder.none, - contentPadding: material.EdgeInsets.all(12), - hintText: 'Enter cell value…', - ), - ), - ), - ), - const Gap(12), - - // Footer - material.Row( - children: [ - GhostButton( - density: ButtonDensity.compact, - onPressed: () { - Clipboard.setData( - ClipboardData(text: _isNull ? 'NULL' : _controller.text), - ); - }, - leading: const material.Icon( - material.Icons.copy_rounded, - size: 14, - ), - child: const Text('Copy'), - ), - const material.Spacer(), - OutlineButton( - density: ButtonDensity.compact, - onPressed: () => material.Navigator.of(context).pop(null), - child: const Text('Cancel'), - ), - const Gap(8), - PrimaryButton( - density: ButtonDensity.compact, - onPressed: () { - final result = _isNull ? 'NULL' : _controller.text; - material.Navigator.of(context).pop(result); - }, - child: const Text('Apply'), - ), - ], - ), - ], - ), - ), - ), - ); - } -} +export 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; diff --git a/lib/features/main_screen/grid_data_type_validator.dart b/lib/features/main_screen/grid_data_type_validator.dart index ae999c4..763b5ea 100644 --- a/lib/features/main_screen/grid_data_type_validator.dart +++ b/lib/features/main_screen/grid_data_type_validator.dart @@ -1,104 +1 @@ -import 'dart:convert'; - -/// Helper utility for validating cell values against SQL data types. -abstract final class GridDataTypeValidator { - static final _uuidRegex = RegExp( - r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', - ); - static final _intRegex = RegExp(r'^-?\d+$'); - static final _numRegex = RegExp(r'^-?\d+(\.\d+)?$'); - static final _dateRegex = RegExp(r'^\d{4}-\d{2}-\d{2}$'); - static final _timestampRegex = RegExp( - r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}(:\d{2})?)?$', - ); - - /// Validates [value] against the column's [dataTypeName]. - /// Returns `null` if valid (or type is unknown), or an error description string if invalid. - static String? validate(String value, {String? dataTypeName}) { - if (value.isEmpty || value == 'NULL' || value == 'null') { - return null; - } - if (dataTypeName == null || dataTypeName.isEmpty) { - return null; - } - - final type = dataTypeName.toLowerCase().trim(); - - // Integer types - if (type.contains('int') || type == 'serial' || type == 'bigserial') { - if (!_intRegex.hasMatch(value.trim())) { - return 'Expected valid integer'; - } - return null; - } - - // Floating / Decimal / Numeric types - if (type.contains('num') || - type.contains('decimal') || - type.contains('float') || - type.contains('double') || - type == 'real') { - if (!_numRegex.hasMatch(value.trim())) { - return 'Expected valid number'; - } - return null; - } - - // Boolean types - if (type == 'bool' || type == 'boolean') { - final lower = value.toLowerCase().trim(); - if (lower != 'true' && - lower != 'false' && - lower != '1' && - lower != '0' && - lower != 't' && - lower != 'f') { - return 'Expected boolean (true/false/1/0)'; - } - return null; - } - - // UUID - if (type == 'uuid') { - if (!_uuidRegex.hasMatch(value.trim())) { - return 'Expected valid UUID (e.g. 123e4567-e89b-12d3-a456-426614174000)'; - } - return null; - } - - // JSON / JSONB - if (type.contains('json')) { - final trimmed = value.trim(); - if (!((trimmed.startsWith('{') && trimmed.endsWith('}')) || - (trimmed.startsWith('[') && trimmed.endsWith(']')))) { - return 'Expected valid JSON object or array'; - } - try { - jsonDecode(trimmed); - } catch (e) { - return 'Malformed JSON: $e'; - } - return null; - } - - // Date - if (type == 'date') { - if (!_dateRegex.hasMatch(value.trim())) { - return 'Expected date in YYYY-MM-DD format'; - } - return null; - } - - // Timestamp / DateTime - if (type.contains('timestamp') || - type.contains('datetime') || - type == 'timestamptz') { - if (!_timestampRegex.hasMatch(value.trim())) { - return 'Expected timestamp (YYYY-MM-DD HH:MM:SS)'; - } - return null; - } - - return null; - } -} +export 'package:querya_desktop/features/workspace/grid_data_type_validator.dart'; diff --git a/lib/features/main_screen/grid_filter_engine.dart b/lib/features/main_screen/grid_filter_engine.dart index 5860f11..6778311 100644 --- a/lib/features/main_screen/grid_filter_engine.dart +++ b/lib/features/main_screen/grid_filter_engine.dart @@ -1,631 +1 @@ -/// Client-side filter engine for Data Grid. -/// -/// Evaluates complex multi-clause expressions with AND / OR / NOT, parentheses, -/// column predicates (`col = val`, `col > 10`, `col LIKE '%test%'`, `col IN ('a', 'b')`, -/// `col IS NULL`, `col BETWEEN x AND y`), and free-text substring search. -abstract final class GridFilterEngine { - /// Evaluates [filterText] against [rows] with respect to [columns]. - /// Returns the list of matching row indices. - static List filterRowIndices({ - required String filterText, - required List columns, - required List> rows, - }) { - final trimmed = filterText.trim(); - if (trimmed.isEmpty || columns.isEmpty || rows.isEmpty) { - return List.generate(rows.length, (i) => i); - } - - final lowerColumns = columns.map((c) => c.toLowerCase()).toList(); - - try { - final tokens = _FilterLexer.tokenize(trimmed, lowerColumns); - if (tokens.isEmpty) { - return List.generate(rows.length, (i) => i); - } - - final parser = _FilterParser(tokens); - final ast = parser.parse(); - - if (ast == null) { - return _fallbackSubstringFilter(trimmed, rows); - } - - final matchingIndices = []; - for (var r = 0; r < rows.length; r++) { - final row = rows[r]; - if (ast.evaluate(row, lowerColumns)) { - matchingIndices.add(r); - } - } - return matchingIndices; - } catch (_) { - // Graceful fallback to multi-term substring match if syntax has parse errors - return _fallbackSubstringFilter(trimmed, rows); - } - } - - static List _fallbackSubstringFilter(String input, List> rows) { - final terms = input.toLowerCase().split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList(); - if (terms.isEmpty) { - return List.generate(rows.length, (i) => i); - } - - final result = []; - for (var r = 0; r < rows.length; r++) { - final row = rows[r]; - var matchAll = true; - for (final term in terms) { - var termMatch = false; - for (var c = 0; c < row.length; c++) { - if (row[c].toLowerCase().contains(term)) { - termMatch = true; - break; - } - } - if (!termMatch) { - matchAll = false; - break; - } - } - if (matchAll) { - result.add(r); - } - } - return result; - } -} - -// ----------------------------------------------------------------------------- -// AST Nodes -// ----------------------------------------------------------------------------- - -abstract class _FilterAstNode { - const _FilterAstNode(); - bool evaluate(List row, List lowerColumns); -} - -class _AndNode extends _FilterAstNode { - const _AndNode(this.left, this.right); - final _FilterAstNode left; - final _FilterAstNode right; - - @override - bool evaluate(List row, List lowerColumns) { - return left.evaluate(row, lowerColumns) && right.evaluate(row, lowerColumns); - } -} - -class _OrNode extends _FilterAstNode { - const _OrNode(this.left, this.right); - final _FilterAstNode left; - final _FilterAstNode right; - - @override - bool evaluate(List row, List lowerColumns) { - return left.evaluate(row, lowerColumns) || right.evaluate(row, lowerColumns); - } -} - -class _NotNode extends _FilterAstNode { - const _NotNode(this.child); - final _FilterAstNode child; - - @override - bool evaluate(List row, List lowerColumns) { - return !child.evaluate(row, lowerColumns); - } -} - -class _PredicateNode extends _FilterAstNode { - const _PredicateNode({ - required this.colIndex, - required this.op, - required this.targetValue, - this.inValues = const [], - this.betweenMin, - this.betweenMax, - }); - - final int colIndex; - final String op; - final String targetValue; - final List inValues; - final String? betweenMin; - final String? betweenMax; - - @override - bool evaluate(List row, List lowerColumns) { - if (colIndex < 0 || colIndex >= row.length) return false; - final cellValue = row[colIndex]; - final isNull = cellValue == 'NULL' || cellValue == 'null' || cellValue.isEmpty; - final upperOp = op.toUpperCase().trim(); - - // IS NULL / IS NOT NULL - if (upperOp == 'IS NULL') { - return isNull; - } - if (upperOp == 'IS NOT NULL') { - return !isNull; - } - - // IN / NOT IN - if (upperOp == 'IN') { - final lowerCell = cellValue.toLowerCase().trim(); - return inValues.any((v) => v.toLowerCase().trim() == lowerCell); - } - if (upperOp == 'NOT IN') { - final lowerCell = cellValue.toLowerCase().trim(); - return !inValues.any((v) => v.toLowerCase().trim() == lowerCell); - } - - // BETWEEN x AND y - if (upperOp == 'BETWEEN' && betweenMin != null && betweenMax != null) { - final numCell = double.tryParse(cellValue.trim()); - final numMin = double.tryParse(betweenMin!.trim()); - final numMax = double.tryParse(betweenMax!.trim()); - if (numCell != null && numMin != null && numMax != null) { - return numCell >= numMin && numCell <= numMax; - } - return cellValue.compareTo(betweenMin!) >= 0 && cellValue.compareTo(betweenMax!) <= 0; - } - - // LIKE / NOT LIKE - if (upperOp == 'LIKE') { - final regex = _likeToRegExp(targetValue, caseSensitive: true); - return regex.hasMatch(cellValue); - } - if (upperOp == 'NOT LIKE') { - final regex = _likeToRegExp(targetValue, caseSensitive: true); - return !regex.hasMatch(cellValue); - } - - // ILIKE / NOT ILIKE - if (upperOp == 'ILIKE') { - final regex = _likeToRegExp(targetValue, caseSensitive: false); - return regex.hasMatch(cellValue); - } - if (upperOp == 'NOT ILIKE') { - final regex = _likeToRegExp(targetValue, caseSensitive: false); - return !regex.hasMatch(cellValue); - } - - final lowerCell = cellValue.toLowerCase(); - final lowerTarget = targetValue.toLowerCase(); - - // Numeric comparison if both values can be parsed as numbers - final numCell = double.tryParse(cellValue.trim()); - final numTarget = double.tryParse(targetValue.trim()); - - if (numCell != null && numTarget != null) { - switch (op) { - case '=': - case '==': - case ':': - return (numCell - numTarget).abs() < 1e-9; - case '!=': - case '<>': - return (numCell - numTarget).abs() >= 1e-9; - case '>': - return numCell > numTarget; - case '>=': - return numCell >= numTarget; - case '<': - return numCell < numTarget; - case '<=': - return numCell <= numTarget; - } - } - - // String / Lexicographic comparison - switch (op) { - case '=': - case '==': - return lowerCell == lowerTarget; - case ':': - return lowerCell.contains(lowerTarget); - case '!=': - case '<>': - return lowerCell != lowerTarget; - case '>': - return lowerCell.compareTo(lowerTarget) > 0; - case '>=': - return lowerCell.compareTo(lowerTarget) >= 0; - case '<': - return lowerCell.compareTo(lowerTarget) < 0; - case '<=': - return lowerCell.compareTo(lowerTarget) <= 0; - default: - return lowerCell.contains(lowerTarget); - } - } - - static RegExp _likeToRegExp(String pattern, {required bool caseSensitive}) { - final buffer = StringBuffer('^'); - for (var i = 0; i < pattern.length; i++) { - final char = pattern[i]; - if (char == '%') { - buffer.write('.*'); - } else if (char == '_') { - buffer.write('.'); - } else { - buffer.write(RegExp.escape(char)); - } - } - buffer.write(r'$'); - return RegExp(buffer.toString(), caseSensitive: caseSensitive); - } -} - -class _FreeTextNode extends _FilterAstNode { - const _FreeTextNode(this.term); - final String term; - - @override - bool evaluate(List row, List lowerColumns) { - final lowerTerm = term.toLowerCase(); - for (var c = 0; c < row.length; c++) { - if (row[c].toLowerCase().contains(lowerTerm)) { - return true; - } - } - return false; - } -} - -// ----------------------------------------------------------------------------- -// Lexer -// ----------------------------------------------------------------------------- - -enum _TokenType { - and, - or, - not, - lparen, - rparen, - predicate, - text, -} - -class _FilterToken { - const _FilterToken(this.type, {this.value = '', this.predicate}); - final _TokenType type; - final String value; - final _PredicateNode? predicate; -} - -abstract final class _FilterLexer { - static List<_FilterToken> tokenize(String input, List lowerColumns) { - final tokens = <_FilterToken>[]; - var i = 0; - - while (i < input.length) { - // Skip whitespace - if (input[i].trim().isEmpty) { - i++; - continue; - } - - // Check for extended predicates with keywords (IS NULL, IS NOT NULL, LIKE, ILIKE, IN, BETWEEN) - final remaining = input.substring(i); - final kwPredicate = _tryMatchKeywordPredicate(remaining, lowerColumns); - if (kwPredicate != null) { - tokens.add(_FilterToken(_TokenType.predicate, predicate: kwPredicate.node)); - i += kwPredicate.consumedChars; - continue; - } - - // Parentheses - if (input[i] == '(') { - tokens.add(const _FilterToken(_TokenType.lparen, value: '(')); - i++; - continue; - } - if (input[i] == ')') { - tokens.add(const _FilterToken(_TokenType.rparen, value: ')')); - i++; - continue; - } - - // Read next chunk/word until whitespace or parenthesis - final start = i; - while (i < input.length && - input[i].trim().isNotEmpty && - input[i] != '(' && - input[i] != ')') { - // Handle quoted literals inside words - if (input[i] == '\'' || input[i] == '"') { - final quote = input[i]; - i++; - while (i < input.length) { - if (input[i] == '\\' && i + 1 < input.length) { - i += 2; - } else if (input[i] == quote) { - if (i + 1 < input.length && input[i + 1] == quote) { - // SQL-style doubled quote escape: '' - i += 2; - } else { - i++; // closing quote - break; - } - } else { - i++; - } - } - } else { - i++; - } - } - - var word = input.substring(start, i).trim(); - if (word.isEmpty) continue; - - // Check logical operators - final upper = word.toUpperCase(); - if (upper == 'AND' || word == '&&') { - tokens.add(const _FilterToken(_TokenType.and, value: 'AND')); - continue; - } - if (upper == 'OR' || word == '||') { - tokens.add(const _FilterToken(_TokenType.or, value: 'OR')); - continue; - } - if (upper == 'NOT' || word == '!') { - tokens.add(const _FilterToken(_TokenType.not, value: 'NOT')); - continue; - } - - // Check if this token or upcoming sequence forms a predicate: col OP val - final predicate = _tryExtractPredicate(word, lowerColumns); - if (predicate != null) { - tokens.add(_FilterToken(_TokenType.predicate, predicate: predicate)); - continue; - } - - // If word is just a column name and the NEXT word is an operator (e.g. "amount", ">", "100") - final colIdx = lowerColumns.indexOf(word.toLowerCase()); - if (colIdx != -1) { - final rem = input.substring(i).trimLeft(); - final opMatch = RegExp(r'^(>=|<=|!=|<>|==|=|>|<|:)\s*([^\s()]+)') - .firstMatch(rem); - if (opMatch != null) { - final op = opMatch.group(1)!; - var val = opMatch.group(2)!; - val = _stripQuotes(val); - tokens.add( - _FilterToken( - _TokenType.predicate, - predicate: _PredicateNode( - colIndex: colIdx, - op: op, - targetValue: val, - ), - ), - ); - i += input.substring(i).indexOf(opMatch.group(0)!) + - opMatch.group(0)!.length; - continue; - } - } - - word = _stripQuotes(word); - tokens.add(_FilterToken(_TokenType.text, value: word)); - } - - return tokens; - } - - static ({_PredicateNode node, int consumedChars})? _tryMatchKeywordPredicate( - String remaining, - List lowerColumns, - ) { - // 1. IS NULL / IS NOT NULL (e.g. "status IS NULL", "email IS NOT NULL") - final isNullMatch = RegExp(r'^([a-zA-Z_]\w*)\s+IS\s+(NOT\s+)?NULL\b', caseSensitive: false) - .firstMatch(remaining); - if (isNullMatch != null) { - final colName = isNullMatch.group(1)!.toLowerCase(); - final colIdx = lowerColumns.indexOf(colName); - if (colIdx != -1) { - final isNot = isNullMatch.group(2) != null; - return ( - node: _PredicateNode( - colIndex: colIdx, - op: isNot ? 'IS NOT NULL' : 'IS NULL', - targetValue: '', - ), - consumedChars: isNullMatch.group(0)!.length, - ); - } - } - - // 2. IN / NOT IN (e.g. "status IN ('ACTIVE', 'PENDING')", "id NOT IN (1, 2, 3)") - final inMatch = RegExp(r'^([a-zA-Z_]\w*)\s+(NOT\s+)?IN\s*\(([^)]+)\)', caseSensitive: false) - .firstMatch(remaining); - if (inMatch != null) { - final colName = inMatch.group(1)!.toLowerCase(); - final colIdx = lowerColumns.indexOf(colName); - if (colIdx != -1) { - final isNot = inMatch.group(2) != null; - final listStr = inMatch.group(3)!; - final items = listStr - .split(',') - .map((s) => _stripQuotes(s.trim())) - .where((s) => s.isNotEmpty) - .toList(); - return ( - node: _PredicateNode( - colIndex: colIdx, - op: isNot ? 'NOT IN' : 'IN', - targetValue: '', - inValues: items, - ), - consumedChars: inMatch.group(0)!.length, - ); - } - } - - // 3. BETWEEN x AND y (e.g. "amount BETWEEN 10 AND 100") - final betweenMatch = RegExp(r'^([a-zA-Z_]\w*)\s+BETWEEN\s+([^\s]+)\s+AND\s+([^\s()]+)', caseSensitive: false) - .firstMatch(remaining); - if (betweenMatch != null) { - final colName = betweenMatch.group(1)!.toLowerCase(); - final colIdx = lowerColumns.indexOf(colName); - if (colIdx != -1) { - final minVal = _stripQuotes(betweenMatch.group(2)!.trim()); - final maxVal = _stripQuotes(betweenMatch.group(3)!.trim()); - return ( - node: _PredicateNode( - colIndex: colIdx, - op: 'BETWEEN', - targetValue: '', - betweenMin: minVal, - betweenMax: maxVal, - ), - consumedChars: betweenMatch.group(0)!.length, - ); - } - } - - // 4. LIKE / ILIKE / NOT LIKE / NOT ILIKE (e.g. "name LIKE '%John%'", "email ILIKE '%.org'") - final likeMatch = RegExp(r'^([a-zA-Z_]\w*)\s+(NOT\s+)?(ILIKE|LIKE)\s+([^\s()]+)', caseSensitive: false) - .firstMatch(remaining); - if (likeMatch != null) { - final colName = likeMatch.group(1)!.toLowerCase(); - final colIdx = lowerColumns.indexOf(colName); - if (colIdx != -1) { - final isNot = likeMatch.group(2) != null; - final likeType = likeMatch.group(3)!.toUpperCase(); - final pattern = _stripQuotes(likeMatch.group(4)!.trim()); - final op = isNot ? 'NOT $likeType' : likeType; - return ( - node: _PredicateNode( - colIndex: colIdx, - op: op, - targetValue: pattern, - ), - consumedChars: likeMatch.group(0)!.length, - ); - } - } - - return null; - } - - static _PredicateNode? _tryExtractPredicate( - String token, - List lowerColumns, - ) { - const ops = ['>=', '<=', '!=', '<>', '==', '=', '>', '<', ':']; - for (final op in ops) { - final parts = token.split(op); - if (parts.length == 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { - final colCandidate = parts[0].trim().toLowerCase(); - final colIdx = lowerColumns.indexOf(colCandidate); - if (colIdx != -1) { - final val = _stripQuotes(parts[1].trim()); - return _PredicateNode( - colIndex: colIdx, - op: op, - targetValue: val, - ); - } - } - } - return null; - } - - static String _stripQuotes(String s) { - if ((s.startsWith("'") && s.endsWith("'")) || - (s.startsWith('"') && s.endsWith('"'))) { - if (s.length >= 2) { - return s - .substring(1, s.length - 1) - .replaceAll("''", "'") - .replaceAll(r"\'", "'") - .replaceAll(r'\"', '"'); - } - } - return s; - } -} - -// ----------------------------------------------------------------------------- -// Parser -// ----------------------------------------------------------------------------- - -class _FilterParser { - _FilterParser(this.tokens); - final List<_FilterToken> tokens; - int _pos = 0; - - _FilterAstNode? parse() { - if (tokens.isEmpty) return null; - return _parseOr(); - } - - _FilterAstNode _parseOr() { - var node = _parseAnd(); - while (_match(_TokenType.or)) { - final right = _parseAnd(); - node = _OrNode(node, right); - } - return node; - } - - _FilterAstNode _parseAnd() { - var node = _parseUnary(); - while (_match(_TokenType.and) || _isImplicitAnd()) { - final right = _parseUnary(); - node = _AndNode(node, right); - } - return node; - } - - bool _isImplicitAnd() { - if (_pos >= tokens.length) return false; - final type = tokens[_pos].type; - return type == _TokenType.predicate || - type == _TokenType.text || - type == _TokenType.lparen || - type == _TokenType.not; - } - - _FilterAstNode _parseUnary() { - if (_match(_TokenType.not)) { - return _NotNode(_parseUnary()); - } - return _parsePrimary(); - } - - _FilterAstNode _parsePrimary() { - if (_match(_TokenType.lparen)) { - final node = _parseOr(); - _consume(_TokenType.rparen); - return node; - } - - if (_pos < tokens.length) { - final token = tokens[_pos++]; - if (token.type == _TokenType.predicate && token.predicate != null) { - return token.predicate!; - } - return _FreeTextNode(token.value); - } - - return const _FreeTextNode(''); - } - - bool _match(_TokenType type) { - if (_pos < tokens.length && tokens[_pos].type == type) { - _pos++; - return true; - } - return false; - } - - void _consume(_TokenType type) { - if (_pos < tokens.length && tokens[_pos].type == type) { - _pos++; - } - } -} +export 'package:querya_desktop/features/workspace/grid_filter_engine.dart'; diff --git a/lib/features/main_screen/grid_groupings_engine.dart b/lib/features/main_screen/grid_groupings_engine.dart index e117df1..dc48315 100644 --- a/lib/features/main_screen/grid_groupings_engine.dart +++ b/lib/features/main_screen/grid_groupings_engine.dart @@ -1,239 +1 @@ -import 'package:flutter/foundation.dart'; - -/// Aggregation operation to perform on groups. -enum GroupingAggType { - count('COUNT'), - sum('SUM'), - avg('AVG'), - min('MIN'), - max('MAX'); - - const GroupingAggType(this.label); - final String label; -} - -/// Sort criteria for grouping categories. -enum GroupSortBy { - count('Count'), - key('Group Key'), - aggregate('Aggregate'); - - const GroupSortBy(this.label); - final String label; -} - -/// Configuration for group aggregations. -@immutable -class GroupAggregationConfig { - const GroupAggregationConfig({ - this.aggType = GroupingAggType.count, - this.targetColIndex, - }); - - final GroupingAggType aggType; - final int? targetColIndex; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is GroupAggregationConfig && - aggType == other.aggType && - targetColIndex == other.targetColIndex; - - @override - int get hashCode => Object.hash(aggType, targetColIndex); -} - -/// Represents an aggregated group in Groupings / Pivot View (supports nested sub-groups). -@immutable -class GroupedCategory { - const GroupedCategory({ - required this.groupKey, - required this.count, - required this.percentage, - required this.rows, - this.aggValue, - this.subGroups = const [], - this.level = 0, - }); - - final String groupKey; - final int count; - final double percentage; - final List> rows; - final double? aggValue; - final List subGroups; - final int level; - - bool get hasSubGroups => subGroups.isNotEmpty; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is GroupedCategory && - groupKey == other.groupKey && - count == other.count && - percentage == other.percentage && - aggValue == other.aggValue && - level == other.level; - - @override - int get hashCode => Object.hash(groupKey, count, percentage, aggValue, level); -} - -/// Engine to construct multi-column pivot / hierarchical grouping breakdown tables. -abstract final class GridGroupingsEngine { - /// Builds multi-level groups by [groupColIndices] with optional aggregation and sorting. - static List buildGroups({ - required List groupColIndices, - required List> rows, - GroupAggregationConfig aggConfig = const GroupAggregationConfig(), - GroupSortBy sortBy = GroupSortBy.count, - bool sortAscending = false, - }) { - if (rows.isEmpty || groupColIndices.isEmpty) return const []; - - return _buildSubGroups( - groupColIndices: groupColIndices, - levelIndex: 0, - rows: rows, - totalRootRows: rows.length, - aggConfig: aggConfig, - sortBy: sortBy, - sortAscending: sortAscending, - ); - } - - static List _buildSubGroups({ - required List groupColIndices, - required int levelIndex, - required List> rows, - required int totalRootRows, - required GroupAggregationConfig aggConfig, - required GroupSortBy sortBy, - required bool sortAscending, - }) { - if (levelIndex >= groupColIndices.length || rows.isEmpty) return const []; - - final colIndex = groupColIndices[levelIndex]; - final map = >>{}; - - for (final row in rows) { - final key = colIndex < row.length ? row[colIndex] : 'NULL'; - final effectiveKey = key.isEmpty ? '(Empty)' : key; - map.putIfAbsent(effectiveKey, () => []).add(row); - } - - final categories = []; - final hasNextLevel = levelIndex + 1 < groupColIndices.length; - - map.forEach((key, categoryRows) { - final count = categoryRows.length; - final pct = totalRootRows > 0 ? (count / totalRootRows) * 100 : 0.0; - final agg = _computeAggregation(categoryRows, aggConfig); - - List subGroups = const []; - if (hasNextLevel) { - subGroups = _buildSubGroups( - groupColIndices: groupColIndices, - levelIndex: levelIndex + 1, - rows: categoryRows, - totalRootRows: totalRootRows, - aggConfig: aggConfig, - sortBy: sortBy, - sortAscending: sortAscending, - ); - } - - categories.add( - GroupedCategory( - groupKey: key, - count: count, - percentage: pct, - rows: categoryRows, - aggValue: agg, - subGroups: subGroups, - level: levelIndex, - ), - ); - }); - - // Sorting - categories.sort((a, b) { - int cmp; - switch (sortBy) { - case GroupSortBy.count: - cmp = a.count.compareTo(b.count); - break; - case GroupSortBy.key: - cmp = a.groupKey.compareTo(b.groupKey); - break; - case GroupSortBy.aggregate: - final aVal = a.aggValue ?? (a.count.toDouble()); - final bVal = b.aggValue ?? (b.count.toDouble()); - cmp = aVal.compareTo(bVal); - break; - } - return sortAscending ? cmp : -cmp; - }); - - return categories; - } - - static double? _computeAggregation( - List> rows, - GroupAggregationConfig config, - ) { - if (config.aggType == GroupingAggType.count) { - return rows.length.toDouble(); - } - if (config.targetColIndex == null) return null; - - final targetCol = config.targetColIndex!; - final numbers = []; - - for (final row in rows) { - if (targetCol < row.length) { - final val = row[targetCol].replaceAll(',', '').trim(); - final parsed = double.tryParse(val); - if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { - numbers.add(parsed); - } - } - } - - if (numbers.isEmpty) return null; - - switch (config.aggType) { - case GroupingAggType.count: - return numbers.length.toDouble(); - case GroupingAggType.sum: - return numbers.reduce((a, b) => a + b); - case GroupingAggType.avg: - return numbers.reduce((a, b) => a + b) / numbers.length; - case GroupingAggType.min: - return numbers.reduce((a, b) => a < b ? a : b); - case GroupingAggType.max: - return numbers.reduce((a, b) => a > b ? a : b); - } - } - - /// Exports pivot summary to CSV format. - static String exportPivotToCsv({ - required List groups, - required String groupByColumnName, - GroupAggregationConfig aggConfig = const GroupAggregationConfig(), - }) { - final buffer = StringBuffer(); - buffer.writeln('Group Key,Count,Percentage,Aggregate'); - - for (final g in groups) { - final aggStr = g.aggValue != null ? g.aggValue!.toStringAsFixed(2) : '-'; - buffer.writeln( - '"${g.groupKey.replaceAll('"', '""')}",${g.count},${g.percentage.toStringAsFixed(2)}%,$aggStr', - ); - } - - return buffer.toString(); - } -} +export 'package:querya_desktop/features/workspace/grid_groupings_engine.dart'; diff --git a/lib/features/main_screen/grid_selection_calc_engine.dart b/lib/features/main_screen/grid_selection_calc_engine.dart index c7a111f..84c8d26 100644 --- a/lib/features/main_screen/grid_selection_calc_engine.dart +++ b/lib/features/main_screen/grid_selection_calc_engine.dart @@ -1,248 +1 @@ -import 'dart:math' as math; -import 'package:flutter/foundation.dart'; - -/// Aggregated statistical results for a selection of grid cell values. -@immutable -class GridCalcStats { - const GridCalcStats({ - required this.totalCount, - required this.distinctCount, - required this.numericCount, - required this.nullCount, - this.sum, - this.average, - this.median, - this.min, - this.max, - this.stdDev, - }); - - static const empty = GridCalcStats( - totalCount: 0, - distinctCount: 0, - numericCount: 0, - nullCount: 0, - ); - - final int totalCount; - final int distinctCount; - final int numericCount; - final int nullCount; - final double? sum; - final double? average; - final double? median; - final double? min; - final double? max; - final double? stdDev; - - bool get hasNumericStats => numericCount > 0 && sum != null; - - /// Formats all available statistics into a single copyable summary string. - String toSummaryString() { - final parts = [ - 'Count: $totalCount', - 'Distinct: $distinctCount', - ]; - if (nullCount > 0) { - parts.add('NULLs: $nullCount'); - } - if (hasNumericStats) { - parts.add('Sum: ${GridSelectionCalcEngine.formatNum(sum)}'); - parts.add('Avg: ${GridSelectionCalcEngine.formatNum(average)}'); - if (median != null) { - parts.add('Median: ${GridSelectionCalcEngine.formatNum(median)}'); - } - parts.add('Min: ${GridSelectionCalcEngine.formatNum(min)}'); - parts.add('Max: ${GridSelectionCalcEngine.formatNum(max)}'); - if (stdDev != null) { - parts.add('StdDev: ${GridSelectionCalcEngine.formatNum(stdDev)}'); - } - } - return parts.join(' | '); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is GridCalcStats && - totalCount == other.totalCount && - distinctCount == other.distinctCount && - numericCount == other.numericCount && - nullCount == other.nullCount && - sum == other.sum && - average == other.average && - median == other.median && - min == other.min && - max == other.max && - stdDev == other.stdDev; - - @override - int get hashCode => Object.hash( - totalCount, - distinctCount, - numericCount, - nullCount, - sum, - average, - median, - min, - max, - stdDev, - ); -} - -/// Calculation engine for computing stats (Count, Distinct, Sum, Avg, Median, Min, Max, StdDev) on grid selections. -abstract final class GridSelectionCalcEngine { - /// Computes statistics for a list of string cell values. - static GridCalcStats compute(List values) { - if (values.isEmpty) return GridCalcStats.empty; - - final total = values.length; - var nulls = 0; - final distinctSet = {}; - final numericList = []; - var sum = 0.0; - double? minVal; - double? maxVal; - - for (final raw in values) { - final trimmed = raw.trim(); - if (trimmed == 'NULL' || trimmed.isEmpty) { - nulls++; - continue; - } - - distinctSet.add(trimmed); - - // Try parsing numeric values (stripping commas if present) - final normalized = trimmed.replaceAll(',', ''); - final parsed = double.tryParse(normalized); - if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { - numericList.add(parsed); - sum += parsed; - if (minVal == null || parsed < minVal) { - minVal = parsed; - } - if (maxVal == null || parsed > maxVal) { - maxVal = parsed; - } - } - } - - final numericCount = numericList.length; - final avg = numericCount > 0 ? sum / numericCount : null; - - // Calculate median using QuickSelect (O(N)) for large datasets (> 500 elements) or fast sort (<= 500) - double? median; - if (numericCount > 0) { - final mid = numericCount ~/ 2; - if (numericCount <= 500) { - numericList.sort(); - if (numericCount.isOdd) { - median = numericList[mid]; - } else { - median = (numericList[mid - 1] + numericList[mid]) / 2.0; - } - } else { - if (numericCount.isOdd) { - median = _quickSelect(numericList, 0, numericCount - 1, mid); - } else { - final m1 = _quickSelect(numericList, 0, numericCount - 1, mid - 1); - final m2 = _quickSelect(numericList, mid, numericCount - 1, mid); - median = (m1 + m2) / 2.0; - } - } - } - - // Calculate standard deviation - double? stdDev; - if (numericCount > 1 && avg != null) { - var varianceSum = 0.0; - for (final n in numericList) { - varianceSum += math.pow(n - avg, 2); - } - stdDev = math.sqrt(varianceSum / (numericCount - 1)); - } - - return GridCalcStats( - totalCount: total, - distinctCount: distinctSet.length, - numericCount: numericCount, - nullCount: nulls, - sum: numericCount > 0 ? sum : null, - average: avg, - median: median, - min: minVal, - max: maxVal, - stdDev: stdDev, - ); - } - - /// Linear-time QuickSelect algorithm to find the k-th smallest element. - static double _quickSelect(List list, int left, int right, int k) { - while (left < right) { - if (right - left < 10) { - // Insertion sort for small sub-arrays - for (var i = left + 1; i <= right; i++) { - final temp = list[i]; - var j = i - 1; - while (j >= left && list[j] > temp) { - list[j + 1] = list[j]; - j--; - } - list[j + 1] = temp; - } - return list[k]; - } - - final pivotIndex = _partition(list, left, right); - if (pivotIndex == k) { - return list[k]; - } else if (pivotIndex > k) { - right = pivotIndex - 1; - } else { - left = pivotIndex + 1; - } - } - return list[left]; - } - - static int _partition(List list, int left, int right) { - // Median-of-three pivot selection for optimal partitioning - final mid = left + ((right - left) >> 1); - if (list[left] > list[mid]) _swap(list, left, mid); - if (list[left] > list[right]) _swap(list, left, right); - if (list[mid] > list[right]) _swap(list, mid, right); - - final pivotValue = list[mid]; - _swap(list, mid, right - 1); - var i = left; - var j = right - 1; - - while (true) { - while (list[++i] < pivotValue) {} - while (list[--j] > pivotValue) {} - if (i >= j) break; - _swap(list, i, j); - } - _swap(list, i, right - 1); - return i; - } - - static void _swap(List list, int i, int j) { - final temp = list[i]; - list[i] = list[j]; - list[j] = temp; - } - - /// Formats a numeric stat cleanly for UI display. - static String formatNum(double? val) { - if (val == null) return '-'; - if (val == val.roundToDouble()) { - return val.toInt().toString(); - } - // Limit decimal precision to 4 decimal places - final formatted = val.toStringAsFixed(4); - return formatted.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); - } -} +export 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; diff --git a/lib/features/main_screen/query_editor_tab.dart b/lib/features/main_screen/query_editor_tab.dart index 1a0f78a..92d50b0 100644 --- a/lib/features/main_screen/query_editor_tab.dart +++ b/lib/features/main_screen/query_editor_tab.dart @@ -1,34 +1 @@ -import 'package:flutter/material.dart' as material - show EdgeInsets, Padding, TextEditingController; -import 'package:querya_desktop/core/editor/querya_code_editor.dart'; -import 'package:querya_desktop/core/editor/querya_code_language.dart'; -import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -class QueryEditorTab extends StatelessWidget { - const QueryEditorTab({ - super.key, - this.controller, - this.fontSize = 13, - }); - - /// When null, an internal controller is used (standalone workspace without PG). - final material.TextEditingController? controller; - - /// Monospace font size in logical pixels. - final double fontSize; - - @override - Widget build(BuildContext context) { - return material.Padding( - padding: const material.EdgeInsets.all(12), - child: SqlEditorChrome( - child: QueryaCodeEditor( - controller: controller, - language: QueryaCodeLanguage.sql, - fontSize: fontSize, - ), - ), - ); - } -} +export 'package:querya_desktop/features/workspace/query_editor_tab.dart'; diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index b478fbd..969f8eb 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -1,1433 +1 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' - show Clipboard, ClipboardData, HardwareKeyboard, LogicalKeyboardKey; -import 'package:querya_desktop/core/layout/ui_scale.dart'; -import 'package:querya_desktop/core/ui/querya_tooltip.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; -import 'package:querya_desktop/features/main_screen/grid_cell_editor.dart'; -import 'package:querya_desktop/features/main_screen/grid_cell_popover_inspector.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -/// Layout metrics for [VirtualResultGrid]. -abstract final class ResultGridMetrics { - static const double rowHeight = 36; - static const double headerHeight = 36; - static const double minColumnWidth = 120; - 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]. -List computeResultGridColumnWidths({ - required List columns, - required List> rows, - double minWidth = ResultGridMetrics.minColumnWidth, - double maxWidth = ResultGridMetrics.maxColumnWidth, - int sampleRowCount = ResultGridMetrics.columnWidthSampleRows, -}) { - if (columns.isEmpty) return const []; - - final widths = List.filled(columns.length, minWidth); - final sample = rows.length < sampleRowCount ? rows.length : sampleRowCount; - - for (var c = 0; c < columns.length; c++) { - var maxChars = columns[c].length; - for (var r = 0; r < sample; r++) { - if (c < rows[r].length && rows[r][c].length > maxChars) { - maxChars = rows[r][c].length; - } - } - widths[c] = (maxChars * 7.5 + 24).clamp(minWidth, maxWidth); - } - return widths; -} - -/// 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]: smallest index where columnOffsets[first + 1] > start - var first = 0; - var low = 0; - var high = n - 1; - while (low <= high) { - final mid = (low + high) ~/ 2; - if (columnOffsets[mid + 1] > start) { - first = mid; - high = mid - 1; - } else { - low = mid + 1; - } - } - - // Last column with any pixel before [end]: largest index where columnOffsets[last] < end - var last = n - 1; - low = 0; - high = n - 1; - while (low <= high) { - final mid = (low + high) ~/ 2; - if (columnOffsets[mid] < end) { - last = mid; - low = mid + 1; - } else { - high = mid - 1; - } - } - - 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], - ); -} - -/// Coordinate of a cell in [VirtualResultGrid]. -@immutable -class ResultGridCellCoordinate { - const ResultGridCellCoordinate(this.row, this.column); - - final int row; - final int column; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ResultGridCellCoordinate && - row == other.row && - column == other.column; - - @override - int get hashCode => Object.hash(row, column); -} - -/// Rectangular cell selection range in [VirtualResultGrid]. -@immutable -class ResultGridSelection { - const ResultGridSelection({ - required this.startRow, - required this.startColumn, - required this.endRow, - required this.endColumn, - }); - - factory ResultGridSelection.fromPoints({ - required ResultGridCellCoordinate anchor, - required ResultGridCellCoordinate focus, - }) { - final minR = anchor.row < focus.row ? anchor.row : focus.row; - final maxR = anchor.row > focus.row ? anchor.row : focus.row; - final minC = anchor.column < focus.column ? anchor.column : focus.column; - final maxC = anchor.column > focus.column ? anchor.column : focus.column; - return ResultGridSelection( - startRow: minR, - startColumn: minC, - endRow: maxR, - endColumn: maxC, - ); - } - - final int startRow; - final int startColumn; - final int endRow; - final int endColumn; - - bool contains(int row, int column) => - row >= startRow && - row <= endRow && - column >= startColumn && - column <= endColumn; - - int get rowCount => endRow - startRow + 1; - int get columnCount => endColumn - startColumn + 1; - - /// Formats selected cell values as a Tab-Separated Values (TSV) string. - String toTsv(List> rows) { - if (rows.isEmpty) return ''; - final buffer = StringBuffer(); - for (var r = startRow; r <= endRow; r++) { - if (r < 0 || r >= rows.length) continue; - final rowData = rows[r]; - final cells = []; - for (var c = startColumn; c <= endColumn; c++) { - cells.add(c < rowData.length ? rowData[c] : ''); - } - buffer.writeln(cells.join('\t')); - } - return buffer.toString().trimRight(); - } - - /// Formats selected cell values as a CSV string. - String toCsv(List> rows) { - if (rows.isEmpty) return ''; - final buffer = StringBuffer(); - for (var r = startRow; r <= endRow; r++) { - if (r < 0 || r >= rows.length) continue; - final rowData = rows[r]; - final cells = []; - for (var c = startColumn; c <= endColumn; c++) { - final val = c < rowData.length ? rowData[c] : ''; - if (val.contains(',') || val.contains('"') || val.contains('\n')) { - cells.add('"${val.replaceAll('"', '""')}"'); - } else { - cells.add(val); - } - } - buffer.writeln(cells.join(',')); - } - return buffer.toString().trimRight(); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ResultGridSelection && - startRow == other.startRow && - startColumn == other.startColumn && - endRow == other.endRow && - endColumn == other.endColumn; - - @override - int get hashCode => Object.hash(startRow, startColumn, endRow, endColumn); -} - -/// Sorting direction for [VirtualResultGrid]. -enum ResultGridSortOrder { - ascending, - descending, -} - -enum _SortKeyType { nullOrEmpty, numeric, dateTime, string } - -class _SortKey implements Comparable<_SortKey> { - final _SortKeyType type; - final num? numVal; - final DateTime? dtVal; - final String strLower; - final String strRaw; - - _SortKey._({ - required this.type, - this.numVal, - this.dtVal, - this.strLower = '', - this.strRaw = '', - }); - - factory _SortKey.parse(String val) { - if (val == 'NULL' || val.isEmpty) { - return _SortKey._(type: _SortKeyType.nullOrEmpty); - } - final n = num.tryParse(val); - if (n != null) { - return _SortKey._(type: _SortKeyType.numeric, numVal: n, strRaw: val); - } - final dt = DateTime.tryParse(val); - if (dt != null) { - return _SortKey._(type: _SortKeyType.dateTime, dtVal: dt, strRaw: val); - } - return _SortKey._( - type: _SortKeyType.string, - strLower: val.toLowerCase(), - strRaw: val, - ); - } - - @override - int compareTo(_SortKey other) { - if (type == _SortKeyType.nullOrEmpty && other.type == _SortKeyType.nullOrEmpty) { - return 0; - } - if (type == _SortKeyType.nullOrEmpty) return 1; - if (other.type == _SortKeyType.nullOrEmpty) return -1; - - if (type == _SortKeyType.numeric && other.type == _SortKeyType.numeric) { - return numVal!.compareTo(other.numVal!); - } - if (type == _SortKeyType.dateTime && other.type == _SortKeyType.dateTime) { - return dtVal!.compareTo(other.dtVal!); - } - - final aLower = type == _SortKeyType.string ? strLower : strRaw.toLowerCase(); - final bLower = other.type == _SortKeyType.string ? other.strLower : other.strRaw.toLowerCase(); - final cmp = aLower.compareTo(bLower); - if (cmp != 0) return cmp; - - return strRaw.compareTo(other.strRaw); - } -} - -/// Sorts rows by the specified column index with natural numeric / temporal / lexicographic comparison. -/// Uses Schwartzian transform (Decorate-Sort-Undecorate) to precompute sort keys in O(N) time. -List> sortResultGridRows({ - required List> rows, - required int columnIndex, - required ResultGridSortOrder order, -}) { - if (rows.isEmpty || columnIndex < 0) return rows; - final n = rows.length; - - final keys = List<_SortKey>.generate(n, (i) { - final row = rows[i]; - final val = columnIndex < row.length ? row[columnIndex] : ''; - return _SortKey.parse(val); - }, growable: false); - - final indices = List.generate(n, (i) => i, growable: false); - - indices.sort((a, b) { - final cmp = keys[a].compareTo(keys[b]); - return order == ResultGridSortOrder.ascending ? cmp : -cmp; - }); - - return List>.generate(n, (i) => rows[indices[i]], growable: false); -} - -/// Virtualized read-only or interactive grid for SQL query results (rows + columns). -class VirtualResultGrid extends material.StatefulWidget { - const VirtualResultGrid({ - super.key, - required this.columns, - required this.rows, - this.stagingBuffer, - this.onRowSelected, - this.onSelectionValuesChanged, - this.onCellFocused, - }); - - final List columns; - final List> rows; - final DataGridStagingBuffer? stagingBuffer; - final material.ValueChanged? onRowSelected; - final material.ValueChanged>? onSelectionValuesChanged; - final void Function(String columnName, String cellValue, int rowIndex)? onCellFocused; - - @override - material.State createState() => _VirtualResultGridState(); -} - -class _VirtualResultGridState extends material.State { - final _horizontalController = material.ScrollController(); - final _verticalController = material.ScrollController(); - final _focusNode = material.FocusNode(); - - List _columnWidths = const []; - List _columnOffsets = const [0]; - bool _widthsNeedUpdate = true; - bool _userHasResized = false; - double _scrollOffset = 0; - - int? _sortColumnIndex; - ResultGridSortOrder? _sortOrder; - List> _sortedRows = const []; - - ResultGridCellCoordinate? _selectionAnchor; - ResultGridSelection? _selection; - ResultGridCellCoordinate? _editingCell; - - @override - void initState() { - super.initState(); - _horizontalController.addListener(_onHorizontalScroll); - widget.stagingBuffer?.addListener(_onStagingBufferChanged); - _updateSortedRows(); - } - - void _onStagingBufferChanged() { - if (!mounted) return; - setState(() { - _updateSortedRows(); - _widthsNeedUpdate = true; - }); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _widthsNeedUpdate = true; - } - - @override - void didUpdateWidget(VirtualResultGrid oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.stagingBuffer != widget.stagingBuffer) { - oldWidget.stagingBuffer?.removeListener(_onStagingBufferChanged); - widget.stagingBuffer?.addListener(_onStagingBufferChanged); - } - if (oldWidget.columns != widget.columns || - oldWidget.rows != widget.rows || - oldWidget.stagingBuffer != widget.stagingBuffer) { - _widthsNeedUpdate = true; - if (oldWidget.columns != widget.columns) { - _userHasResized = false; - _sortColumnIndex = null; - _sortOrder = null; - _selectionAnchor = null; - _selection = null; - _editingCell = null; - widget.onRowSelected?.call(null); - } - _updateSortedRows(); - } - } - - @override - void dispose() { - widget.stagingBuffer?.removeListener(_onStagingBufferChanged); - _horizontalController.removeListener(_onHorizontalScroll); - _horizontalController.dispose(); - _verticalController.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - void _startEditing(int row, int column) { - if (widget.stagingBuffer == null) return; - if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; - setState(() { - _editingCell = ResultGridCellCoordinate(row, column); - _selectionAnchor = _editingCell; - _selection = ResultGridSelection( - startRow: row, - startColumn: column, - endRow: row, - endColumn: column, - ); - }); - } - - void _commitEdit( - int row, - int column, - String value, { - bool moveNextCol = false, - bool movePrevCol = false, - bool moveNextRow = false, - bool movePrevRow = false, - }) { - if (widget.stagingBuffer != null) { - widget.stagingBuffer!.setCell(row, column, value); - } - setState(() { - if (moveNextCol) { - if (column + 1 < widget.columns.length) { - _editingCell = ResultGridCellCoordinate(row, column + 1); - _selection = ResultGridSelection( - startRow: row, - startColumn: column + 1, - endRow: row, - endColumn: column + 1, - ); - } else if (row + 1 < _sortedRows.length) { - _editingCell = ResultGridCellCoordinate(row + 1, 0); - _selection = ResultGridSelection( - startRow: row + 1, - startColumn: 0, - endRow: row + 1, - endColumn: 0, - ); - } else { - _editingCell = null; - } - } else if (movePrevCol) { - if (column > 0) { - _editingCell = ResultGridCellCoordinate(row, column - 1); - _selection = ResultGridSelection( - startRow: row, - startColumn: column - 1, - endRow: row, - endColumn: column - 1, - ); - } else if (row > 0) { - _editingCell = ResultGridCellCoordinate(row - 1, widget.columns.length - 1); - _selection = ResultGridSelection( - startRow: row - 1, - startColumn: widget.columns.length - 1, - endRow: row - 1, - endColumn: widget.columns.length - 1, - ); - } else { - _editingCell = null; - } - } else if (moveNextRow) { - if (row + 1 < _sortedRows.length) { - _editingCell = ResultGridCellCoordinate(row + 1, column); - _selection = ResultGridSelection( - startRow: row + 1, - startColumn: column, - endRow: row + 1, - endColumn: column, - ); - } else { - _editingCell = null; - } - } else if (movePrevRow) { - if (row > 0) { - _editingCell = ResultGridCellCoordinate(row - 1, column); - _selection = ResultGridSelection( - startRow: row - 1, - startColumn: column, - endRow: row - 1, - endColumn: column, - ); - } else { - _editingCell = null; - } - } else { - _editingCell = null; - } - }); - } - - void _cancelEdit() { - setState(() { - _editingCell = null; - }); - } - - Future _openInspector(int row, int column) async { - if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; - final colName = widget.columns[column]; - final currentVal = column < _sortedRows[row].length ? _sortedRows[row][column] : ''; - final result = await showGridCellInspectorDialog( - context: context, - columnName: colName, - initialValue: currentVal, - rowIndex: row, - ); - if (result != null && widget.stagingBuffer != null) { - widget.stagingBuffer!.setCell(row, column, result); - } - } - - void _onHorizontalScroll() { - if (!_horizontalController.hasClients) return; - final offset = _horizontalController.offset; - if ((offset - _scrollOffset).abs() < 0.5) return; - setState(() => _scrollOffset = offset); - } - - void _onColumnResize(int index, double delta) { - if (index < 0 || index >= _columnWidths.length) return; - setState(() { - _userHasResized = true; - final minWidth = context.scaled(ResultGridMetrics.minColumnWidth); - final maxWidth = context.scaled(ResultGridMetrics.maxColumnWidth * 3); - final newWidth = (_columnWidths[index] + delta).clamp(minWidth, maxWidth); - _columnWidths = List.from(_columnWidths); - _columnWidths[index] = newWidth; - _columnOffsets = computeResultGridColumnOffsets(_columnWidths); - _widthsNeedUpdate = false; - }); - } - - void _toggleSort(int columnIndex) { - if (columnIndex < 0 || columnIndex >= widget.columns.length) return; - setState(() { - if (_sortColumnIndex == columnIndex) { - if (_sortOrder == ResultGridSortOrder.ascending) { - _sortOrder = ResultGridSortOrder.descending; - } else { - _sortColumnIndex = null; - _sortOrder = null; - } - } else { - _sortColumnIndex = columnIndex; - _sortOrder = ResultGridSortOrder.ascending; - } - _updateSortedRows(); - }); - } - - List> get _baseRows => - widget.stagingBuffer?.effectiveRows ?? widget.rows; - - void _updateSortedRows() { - final rows = _baseRows; - if (_sortColumnIndex == null || _sortOrder == null) { - _sortedRows = rows; - } else { - _sortedRows = sortResultGridRows( - rows: rows, - columnIndex: _sortColumnIndex!, - order: _sortOrder!, - ); - } - } - - void _notifySelectionAndFocus() { - if (widget.onSelectionValuesChanged != null) { - if (_selection == null) { - widget.onSelectionValuesChanged!(const []); - } else { - final rows = _sortedRows; - final values = []; - for (var r = _selection!.startRow; r <= _selection!.endRow; r++) { - if (r >= 0 && r < rows.length) { - for (var c = _selection!.startColumn; c <= _selection!.endColumn; c++) { - if (c >= 0 && c < rows[r].length) { - values.add(rows[r][c]); - } - } - } - } - widget.onSelectionValuesChanged!(values); - } - } - - if (widget.onCellFocused != null && _selectionAnchor != null) { - final r = _selectionAnchor!.row; - final c = _selectionAnchor!.column; - final rows = _sortedRows; - if (r >= 0 && r < rows.length && c >= 0 && c < widget.columns.length) { - final colName = widget.columns[c]; - final val = c < rows[r].length ? rows[r][c] : ''; - widget.onCellFocused!(colName, val, r); - } - } - } - - void _onCellTap(int row, int column, {bool isShift = false}) { - _focusNode.requestFocus(); - widget.onRowSelected?.call(row); - setState(() { - final coord = ResultGridCellCoordinate(row, column); - if (isShift && _selectionAnchor != null) { - _selection = ResultGridSelection.fromPoints( - anchor: _selectionAnchor!, - focus: coord, - ); - } else { - _selectionAnchor = coord; - _selection = ResultGridSelection( - startRow: row, - startColumn: column, - endRow: row, - endColumn: column, - ); - } - }); - _notifySelectionAndFocus(); - } - - void _onCellSecondaryTap(int row, int column) { - _focusNode.requestFocus(); - widget.onRowSelected?.call(row); - if (_selection != null && _selection!.contains(row, column)) { - _copySelection(); - } else { - setState(() { - _selectionAnchor = ResultGridCellCoordinate(row, column); - _selection = ResultGridSelection( - startRow: row, - startColumn: column, - endRow: row, - endColumn: column, - ); - }); - _notifySelectionAndFocus(); - _copySelection(); - } - } - - void _copySelection({bool asCsv = false}) { - if (_selection == null) return; - final text = asCsv - ? _selection!.toCsv(_sortedRows) - : _selection!.toTsv(_sortedRows); - if (text.isNotEmpty) { - Clipboard.setData(ClipboardData(text: text)); - } - } - - List _computeColumnWidths() { - return computeResultGridColumnWidths( - columns: widget.columns, - rows: _baseRows, - minWidth: context.scaled(ResultGridMetrics.minColumnWidth), - maxWidth: context.scaled(ResultGridMetrics.maxColumnWidth), - ); - } - - double get _tableWidth { - if (_columnWidths.isEmpty) return 0; - return _columnOffsets[_columnWidths.length]; - } - - double _scaledRowHeight(material.BuildContext context) => - context.scaled(ResultGridMetrics.rowHeight); - - 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 && !_userHasResized) { - _columnWidths = _computeColumnWidths(); - _columnOffsets = computeResultGridColumnOffsets(_columnWidths); - _widthsNeedUpdate = false; - } - final cs = Theme.of(context).colorScheme; - final rowHeight = _scaledRowHeight(context); - final headerHeight = _scaledHeaderHeight(context); - - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator( - LogicalKeyboardKey.keyC, - meta: true, - ): () => _copySelection(), - const material.SingleActivator( - LogicalKeyboardKey.keyC, - control: true, - ): () => _copySelection(), - const material.SingleActivator( - LogicalKeyboardKey.insert, - control: true, - ): () => widget.stagingBuffer?.addRow(), - const material.SingleActivator( - LogicalKeyboardKey.keyN, - meta: true, - ): () { - if (widget.stagingBuffer != null) { - widget.stagingBuffer!.addRow(); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.delete, - control: true, - ): () { - if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer!.toggleDeleteRow(_selection!.startRow); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.backspace, - meta: true, - ): () { - if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer!.toggleDeleteRow(_selection!.startRow); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyZ, - control: true, - ): () { - if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer!.revertRow(_selection!.startRow); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyZ, - meta: true, - ): () { - if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer!.revertRow(_selection!.startRow); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.escape, - ): () { - if (_editingCell != null) { - _cancelEdit(); - } else { - widget.onRowSelected?.call(null); - setState(() { - _selection = null; - _selectionAnchor = null; - }); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.f2, - ): () { - if (_selection != null && _editingCell == null) { - _startEditing(_selection!.startRow, _selection!.startColumn); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.enter, - ): () { - if (_selection != null && _editingCell == null) { - _startEditing(_selection!.startRow, _selection!.startColumn); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.numpadEnter, - ): () { - if (_selection != null && _editingCell == null) { - _startEditing(_selection!.startRow, _selection!.startColumn); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.space, - ): () { - if (_selection != null && _editingCell == null) { - _openInspector(_selection!.startRow, _selection!.startColumn); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyN, - alt: true, - ): () { - if (_selection != null && widget.stagingBuffer != null) { - widget.stagingBuffer!.setCell( - _selection!.startRow, - _selection!.startColumn, - 'NULL', - ); - } - }, - }, - child: material.Focus( - focusNode: _focusNode, - child: material.RepaintBoundary( - child: material.LayoutBuilder( - builder: (context, constraints) { - final availableWidth = constraints.maxWidth; - - var displayWidths = _columnWidths; - var tableWidth = _tableWidth; - if (!_userHasResized && - tableWidth < availableWidth && - _columnWidths.isNotEmpty) { - final extraPerCol = - (availableWidth - tableWidth) / _columnWidths.length; - displayWidths = [for (final w in _columnWidths) w + extraPerCol]; - tableWidth = availableWidth; - } else if (tableWidth < availableWidth) { - tableWidth = availableWidth; - } - - final window = _columnWindow(displayWidths, availableWidth); - - return material.Scrollbar( - controller: _horizontalController, - thumbVisibility: true, - notificationPredicate: (_) => true, - child: material.SingleChildScrollView( - controller: _horizontalController, - scrollDirection: material.Axis.horizontal, - child: material.SizedBox( - width: tableWidth, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _HeaderRow( - columns: widget.columns, - columnWidths: displayWidths, - window: window, - height: headerHeight, - colorScheme: cs, - sortColumnIndex: _sortColumnIndex, - sortOrder: _sortOrder, - onSortColumn: _toggleSort, - onResizeColumn: _onColumnResize, - ), - material.Expanded( - child: material.Scrollbar( - controller: _verticalController, - thumbVisibility: true, - child: material.ListView.builder( - controller: _verticalController, - itemCount: _sortedRows.length, - itemExtent: rowHeight, - itemBuilder: (context, rowIndex) { - final row = _sortedRows[rowIndex]; - final isEven = rowIndex.isEven; - return _DataRow( - key: ValueKey('result-row-$rowIndex'), - rowIndex: rowIndex, - row: row, - columnWidths: displayWidths, - window: window, - height: rowHeight, - colorScheme: cs, - striped: !isEven, - selection: _selection, - stagingBuffer: widget.stagingBuffer, - editingCell: _editingCell, - onCellTap: _onCellTap, - onCellDoubleTap: _startEditing, - onCellSecondaryTap: _onCellSecondaryTap, - onCommitEdit: _commitEdit, - onCancelEdit: _cancelEdit, - onOpenInspector: _openInspector, - ); - }, - ), - ), - ), - ], - ), - ), - ), - ); - }, - ), - ), - ), - ); - } -} - -class _HeaderRow extends material.StatelessWidget { - const _HeaderRow({ - required this.columns, - required this.columnWidths, - required this.window, - required this.height, - required this.colorScheme, - this.sortColumnIndex, - this.sortOrder, - this.onSortColumn, - this.onResizeColumn, - }); - - final List columns; - final List columnWidths; - final ResultGridColumnWindow window; - final double height; - final ColorScheme colorScheme; - final int? sortColumnIndex; - final ResultGridSortOrder? sortOrder; - final material.ValueChanged? onSortColumn; - final void Function(int index, double delta)? onResizeColumn; - - @override - material.Widget build(material.BuildContext context) { - return material.Container( - height: height, - decoration: material.BoxDecoration( - color: colorScheme.muted.withValues(alpha: 0.35), - border: material.Border( - bottom: material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.5), - ), - ), - ), - child: material.Row( - children: [ - if (window.leadingWidth > 0) - material.SizedBox(width: window.leadingWidth), - for (var i = window.first; i <= window.last; i++) - _HeaderCell( - text: columns[i], - width: columnWidths[i], - colorScheme: colorScheme, - sortOrder: sortColumnIndex == i ? sortOrder : null, - onSort: onSortColumn != null ? () => onSortColumn!(i) : null, - onResize: onResizeColumn != null - ? (delta) => onResizeColumn!(i, delta) - : null, - ), - if (window.trailingWidth > 0) - material.SizedBox(width: window.trailingWidth), - ], - ), - ); - } -} - -class _HeaderCell extends material.StatelessWidget { - const _HeaderCell({ - required this.text, - required this.width, - required this.colorScheme, - this.sortOrder, - this.onSort, - this.onResize, - }); - - final String text; - final double width; - final ColorScheme colorScheme; - final ResultGridSortOrder? sortOrder; - final material.VoidCallback? onSort; - final material.ValueChanged? onResize; - - @override - material.Widget build(material.BuildContext context) { - final isSorted = sortOrder != null; - final style = material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w600, - color: isSorted ? colorScheme.primary : colorScheme.foreground, - ); - - return material.Container( - width: width, - height: double.infinity, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.3), - ), - ), - ), - child: material.Stack( - clipBehavior: material.Clip.none, - children: [ - material.Positioned.fill( - child: material.MouseRegion( - cursor: onSort != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onTap: onSort, - child: material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 10), - child: material.Row( - children: [ - material.Expanded( - child: material.Text( - text, - style: style, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - ), - ), - if (sortOrder != null) ...[ - const Gap(4), - material.Icon( - sortOrder == ResultGridSortOrder.ascending - ? material.Icons.arrow_upward_rounded - : material.Icons.arrow_downward_rounded, - size: 14, - color: colorScheme.primary, - ), - ], - ], - ), - ), - ), - ), - ), - if (onResize != null) - material.Positioned( - right: -4, - top: 0, - bottom: 0, - width: 10, - child: material.MouseRegion( - cursor: material.SystemMouseCursors.resizeColumn, - child: material.GestureDetector( - behavior: material.HitTestBehavior.translucent, - onHorizontalDragUpdate: (details) { - onResize!(details.delta.dx); - }, - ), - ), - ), - ], - ), - ); - } -} - -class _DataRow extends material.StatelessWidget { - const _DataRow({ - super.key, - required this.rowIndex, - required this.row, - required this.columnWidths, - required this.window, - required this.height, - required this.colorScheme, - required this.striped, - this.selection, - this.stagingBuffer, - this.editingCell, - this.onCellTap, - this.onCellDoubleTap, - this.onCellSecondaryTap, - this.onCommitEdit, - this.onCancelEdit, - this.onOpenInspector, - }); - - final int rowIndex; - final List row; - final List columnWidths; - final ResultGridColumnWindow window; - final double height; - final ColorScheme colorScheme; - final bool striped; - final ResultGridSelection? selection; - final DataGridStagingBuffer? stagingBuffer; - final ResultGridCellCoordinate? editingCell; - final void Function(int row, int col, {bool isShift})? onCellTap; - final void Function(int row, int col)? onCellDoubleTap; - final void Function(int row, int col)? onCellSecondaryTap; - final void Function( - int row, - int col, - String val, { - bool moveNextCol, - bool movePrevCol, - bool moveNextRow, - bool movePrevRow, - })? onCommitEdit; - final material.VoidCallback? onCancelEdit; - final void Function(int row, int col)? onOpenInspector; - - @override - material.Widget build(material.BuildContext context) { - final rowStatus = stagingBuffer?.getRowStatus(rowIndex) ?? StagedRowStatus.unchanged; - - return material.RepaintBoundary( - child: material.SizedBox( - height: height, - child: material.Row( - children: [ - if (window.leadingWidth > 0) - material.SizedBox(width: window.leadingWidth), - for (var c = window.first; c <= window.last; c++) - _GridCell( - row: rowIndex, - column: c, - text: c < row.length ? row[c] : '', - width: columnWidths[c], - colorScheme: colorScheme, - striped: striped, - rowStatus: rowStatus, - cellStatus: stagingBuffer?.getCellStatus(rowIndex, c) ?? StagedCellStatus.clean, - isSelected: selection?.contains(rowIndex, c) ?? false, - isEditing: editingCell?.row == rowIndex && editingCell?.column == c, - isSelectionTop: selection != null && - selection!.contains(rowIndex, c) && - rowIndex == selection!.startRow, - isSelectionBottom: selection != null && - selection!.contains(rowIndex, c) && - rowIndex == selection!.endRow, - isSelectionLeft: selection != null && - selection!.contains(rowIndex, c) && - c == selection!.startColumn, - isSelectionRight: selection != null && - selection!.contains(rowIndex, c) && - c == selection!.endColumn, - onTap: onCellTap, - onDoubleTap: onCellDoubleTap, - onSecondaryTap: onCellSecondaryTap, - onCommitEdit: onCommitEdit, - onCancelEdit: onCancelEdit, - onOpenInspector: onOpenInspector, - ), - if (window.trailingWidth > 0) - material.SizedBox(width: window.trailingWidth), - ], - ), - ), - ); - } -} - -class _GridCell extends material.StatelessWidget { - const _GridCell({ - required this.row, - required this.column, - required this.text, - required this.width, - required this.colorScheme, - required this.striped, - this.rowStatus = StagedRowStatus.unchanged, - this.cellStatus = StagedCellStatus.clean, - this.isSelected = false, - this.isEditing = false, - this.isSelectionTop = false, - this.isSelectionBottom = false, - this.isSelectionLeft = false, - this.isSelectionRight = false, - this.onTap, - this.onDoubleTap, - this.onSecondaryTap, - this.onCommitEdit, - this.onCancelEdit, - this.onOpenInspector, - }); - - final int row; - final int column; - final String text; - final double width; - final ColorScheme colorScheme; - final bool striped; - final StagedRowStatus rowStatus; - final StagedCellStatus cellStatus; - final bool isSelected; - final bool isEditing; - final bool isSelectionTop; - final bool isSelectionBottom; - final bool isSelectionLeft; - final bool isSelectionRight; - final void Function(int row, int col, {bool isShift})? onTap; - final void Function(int row, int col)? onDoubleTap; - final void Function(int row, int col)? onSecondaryTap; - final void Function( - int row, - int col, - String val, { - bool moveNextCol, - bool movePrevCol, - bool moveNextRow, - bool movePrevRow, - })? onCommitEdit; - final material.VoidCallback? onCancelEdit; - final void Function(int row, int col)? onOpenInspector; - - @override - material.Widget build(material.BuildContext context) { - if (isEditing) { - return GridCellEditor( - initialValue: text, - width: width, - height: double.infinity, - onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) { - onCommitEdit?.call( - row, - column, - val, - moveNextCol: moveNextCol, - movePrevCol: movePrevCol, - moveNextRow: moveNextRow, - movePrevRow: movePrevRow, - ); - }, - onCancel: () => onCancelEdit?.call(), - onOpenInspector: () => onOpenInspector?.call(row, column), - ); - } - - final isNull = text == 'NULL'; - final isDeleted = rowStatus == StagedRowStatus.deleted; - final isInserted = rowStatus == StagedRowStatus.inserted; - final isModified = cellStatus == StagedCellStatus.modified; - - var style = material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.normal, - fontFamily: 'monospace', - color: isDeleted - ? colorScheme.destructive.withValues(alpha: 0.7) - : (isNull - ? colorScheme.mutedForeground.withValues(alpha: 0.5) - : colorScheme.foreground), - decoration: isDeleted ? material.TextDecoration.lineThrough : null, - fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, - ); - - var bg = isSelected - ? colorScheme.primary.withValues(alpha: 0.18) - : (isDeleted - ? colorScheme.destructive.withValues(alpha: 0.08) - : (isModified - ? colorScheme.primary.withValues(alpha: 0.14) - : (isInserted - ? colorScheme.primary.withValues(alpha: 0.08) - : (striped - ? colorScheme.muted.withValues(alpha: 0.12) - : material.Colors.transparent)))); - - final cell = material.Container( - width: width, - height: double.infinity, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - decoration: material.BoxDecoration( - color: bg, - border: material.Border( - right: material.BorderSide( - color: isSelectionRight - ? colorScheme.primary - : colorScheme.border.withValues(alpha: 0.3), - width: isSelectionRight ? 1.5 : 1.0, - ), - left: isSelectionLeft - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide.none, - top: isSelectionTop - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide.none, - bottom: isSelectionBottom - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.15), - ), - ), - ), - child: material.Stack( - clipBehavior: material.Clip.none, - alignment: material.Alignment.centerLeft, - children: [ - material.Text( - text, - style: style, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - ), - if (isModified) - material.Positioned( - top: -8, - right: -8, - child: material.CustomPaint( - size: const material.Size(6, 6), - painter: _TriangleCornerPainter(color: colorScheme.primary), - ), - ), - ], - ), - ); - - final interactiveCell = material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onTap: () { - final isShift = HardwareKeyboard.instance.isShiftPressed; - onTap?.call(row, column, isShift: isShift); - }, - onDoubleTap: () { - onDoubleTap?.call(row, column); - }, - onSecondaryTap: () { - onSecondaryTap?.call(row, column); - }, - child: cell, - ); - - if (text.length < ResultGridMetrics.tooltipMinLength) { - return interactiveCell; - } - - return material.Tooltip( - message: text, - waitDuration: kQueryaTooltipWait, - child: interactiveCell, - ); - } -} - -class _TriangleCornerPainter extends material.CustomPainter { - const _TriangleCornerPainter({required this.color}); - final material.Color color; - - @override - void paint(material.Canvas canvas, material.Size size) { - final paint = material.Paint()..color = color; - final path = material.Path() - ..moveTo(0, 0) - ..lineTo(size.width, 0) - ..lineTo(size.width, size.height) - ..close(); - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant _TriangleCornerPainter oldDelegate) => - oldDelegate.color != color; -} - +export 'package:querya_desktop/features/workspace/result_grid_view.dart'; diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index 118628f..959961e 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -1,416 +1 @@ -import 'dart:async' show unawaited; - -import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart'; -import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; -import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_calc_bar.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_filter_bar.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_groupings_view.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_toolbar.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_value_panel.dart'; -import 'package:querya_desktop/features/main_screen/grid_filter_engine.dart'; -import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; -import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; -import 'package:querya_desktop/shared/services/data_export_service.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; - -enum ResultViewMode { - grid, - groupings, -} - -/// Query output: grid, loading, error, or placeholder. -class ResultsTab extends material.StatefulWidget { - const ResultsTab({ - super.key, - this.columns = const [], - this.rows = const [], - this.errorMessage, - this.isLoading = false, - this.affectedRows, - this.statusLine, - this.showExportToolbar = true, - this.stagingBuffer, - this.onApplyChanges, - this.isSaving = false, - }); - - final List columns; - final List> rows; - final String? errorMessage; - final bool isLoading; - final int? affectedRows; - final String? statusLine; - final bool showExportToolbar; - final DataGridStagingBuffer? stagingBuffer; - final material.VoidCallback? onApplyChanges; - final bool isSaving; - - @override - material.State createState() => _ResultsTabState(); -} - -class _ResultsTabState extends material.State { - int? _selectedRowIndex; - ResultViewMode _viewMode = ResultViewMode.grid; - - bool _showFilterBar = false; - String _filterText = ''; - - bool _showValuePanel = false; - String? _focusedColumnName; - String? _focusedCellValue; - int? _focusedRowIndex; - - GridCalcStats _selectionStats = GridCalcStats.empty; - - @override - Widget build(BuildContext context) { - return QueryaFadeSlide( - alignment: material.Alignment.center, - offset: const material.Offset(0, 0.015), - child: material.RepaintBoundary(child: _buildBody(context)), - ); - } - - material.Widget _buildBody(material.BuildContext context) { - if (widget.isLoading) { - return const material.Center( - key: material.ValueKey('results_mode_loading'), - child: material.CircularProgressIndicator(), - ); - } - if (widget.errorMessage != null && widget.errorMessage!.isNotEmpty) { - return material.KeyedSubtree( - key: const material.ValueKey('results_mode_error'), - child: VirtualSelectableTextView( - text: widget.errorMessage!, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: Theme.of(context).colorScheme.destructive, - ), - ), - ); - } - if (widget.columns.isEmpty && widget.rows.isEmpty && widget.stagingBuffer == null) { - if (widget.statusLine != null) { - return material.Padding( - key: const material.ValueKey('results_mode_status'), - padding: const material.EdgeInsets.all(16), - child: Align( - alignment: material.Alignment.topLeft, - child: Text(widget.statusLine!).muted().small(), - ), - ); - } - if (widget.affectedRows != null) { - return material.Center( - key: const material.ValueKey('results_mode_affected'), - child: Text('Rows affected: ${widget.affectedRows}').muted(), - ); - } - return material.Center( - key: const material.ValueKey('results_mode_idle'), - child: const Text('Run a query to see results here.').muted(), - ); - } - - final effectiveRows = widget.stagingBuffer != null - ? widget.stagingBuffer!.effectiveRows - : widget.rows; - - final filteredIndices = GridFilterEngine.filterRowIndices( - filterText: _filterText, - columns: widget.columns, - rows: effectiveRows, - ); - - final filteredRows = filteredIndices.length == effectiveRows.length - ? effectiveRows - : filteredIndices.map((i) => effectiveRows[i]).toList(); - - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator( - LogicalKeyboardKey.keyF, - meta: true, - ): () => setState(() => _showFilterBar = !_showFilterBar), - const material.SingleActivator( - LogicalKeyboardKey.keyF, - control: true, - ): () => setState(() => _showFilterBar = !_showFilterBar), - const material.SingleActivator( - LogicalKeyboardKey.keyS, - meta: true, - ): () { - if (widget.stagingBuffer?.isDirty == true && !widget.isSaving) { - widget.onApplyChanges?.call(); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyS, - control: true, - ): () { - if (widget.stagingBuffer?.isDirty == true && !widget.isSaving) { - widget.onApplyChanges?.call(); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyG, - meta: true, - ): () => setState(() { - _viewMode = _viewMode == ResultViewMode.grid - ? ResultViewMode.groupings - : ResultViewMode.grid; - }), - const material.SingleActivator( - LogicalKeyboardKey.keyG, - control: true, - ): () => setState(() { - _viewMode = _viewMode == ResultViewMode.grid - ? ResultViewMode.groupings - : ResultViewMode.grid; - }), - }, - child: material.Column( - key: const material.ValueKey('results_mode_grid'), - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - if (widget.stagingBuffer != null) - DataGridStagingToolbar( - stagingBuffer: widget.stagingBuffer!, - selectedRowIndex: _selectedRowIndex, - onApplyChanges: widget.onApplyChanges, - isSaving: widget.isSaving, - ), - if (widget.showExportToolbar && widget.columns.isNotEmpty) - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: material.BoxDecoration( - color: Theme.of(context).colorScheme.card, - border: material.Border( - bottom: material.BorderSide( - color: Theme.of(context) - .colorScheme - .border - .withValues(alpha: 0.5), - ), - ), - ), - child: material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - // Grid / Groupings View Selector - material.SizedBox( - height: 28, - child: material.SegmentedButton( - segments: const [ - material.ButtonSegment( - value: ResultViewMode.grid, - label: Text('Grid'), - icon: material.Icon(material.Icons.table_chart_outlined, size: 14), - ), - material.ButtonSegment( - value: ResultViewMode.groupings, - label: Text('Groupings'), - icon: material.Icon(material.Icons.grid_view_rounded, size: 14), - ), - ], - selected: {_viewMode}, - onSelectionChanged: (selected) { - setState(() => _viewMode = selected.first); - }, - showSelectedIcon: false, - style: material.SegmentedButton.styleFrom( - padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 0), - visualDensity: material.VisualDensity.compact, - ), - ), - ), - const Gap(10), - Text( - widget.statusLine ?? - (widget.affectedRows != null - ? 'Rows affected: ${widget.affectedRows}' - : '${filteredRows.length}${_filterText.isNotEmpty ? ' of ${effectiveRows.length}' : ''} rows'), - ).small().semiBold(), - - const Gap(16), - - // Toggle Quick Filter - material.IconButton( - icon: material.Icon( - _showFilterBar ? material.Icons.filter_alt : material.Icons.filter_alt_outlined, - size: 15, - ), - tooltip: 'Toggle Quick Filter', - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), - color: _showFilterBar || _filterText.isNotEmpty - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.mutedForeground, - onPressed: () { - setState(() => _showFilterBar = !_showFilterBar); - }, - ), - const Gap(4), - - // Toggle Value Side Panel - material.IconButton( - icon: material.Icon( - _showValuePanel ? material.Icons.dock : material.Icons.data_object_rounded, - size: 15, - ), - tooltip: 'Inspect Cell Panel', - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), - color: _showValuePanel - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.mutedForeground, - onPressed: () { - setState(() => _showValuePanel = !_showValuePanel); - }, - ), - const Gap(8), - - ExportMenuButton( - label: 'Copy ▾', - icon: material.Icons.copy_rounded, - isSave: false, - onSelected: (format) { - unawaited(() async { - await DataExportService.copyToClipboard( - format, - columns: widget.columns, - rows: filteredRows, - ); - }()); - }, - ), - const Gap(6), - ExportMenuButton( - label: 'Save ▾', - icon: material.Icons.save_alt_rounded, - isSave: true, - onSelected: (format) { - unawaited(() async { - final outcome = await DataExportService.saveToFile( - format, - columns: widget.columns, - rows: filteredRows, - ); - if (!context.mounted) return; - if (outcome == SaveExportOutcome.error) { - await _showSaveFileErrorDialog(context); - } - }()); - }, - ), - ], - ), - ), - ), - - // Quick Filter Bar - if (_showFilterBar || _filterText.isNotEmpty) - DataGridFilterBar( - filterText: _filterText, - onFilterChanged: (text) => setState(() => _filterText = text), - totalRowCount: effectiveRows.length, - filteredRowCount: filteredRows.length, - columns: widget.columns, - ), - - // Main Grid Body / Groupings View + Side Panel - material.Expanded( - child: _viewMode == ResultViewMode.groupings - ? DataGridGroupingsView( - columns: widget.columns, - rows: filteredRows, - ) - : material.Row( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Expanded( - child: VirtualResultGrid( - columns: widget.columns, - rows: filteredRows, - stagingBuffer: widget.stagingBuffer, - onRowSelected: (row) => setState(() => _selectedRowIndex = row), - onSelectionValuesChanged: (values) { - setState(() { - _selectionStats = GridSelectionCalcEngine.compute(values); - }); - }, - onCellFocused: (colName, cellVal, rowIdx) { - setState(() { - _focusedColumnName = colName; - _focusedCellValue = cellVal; - _focusedRowIndex = rowIdx; - }); - }, - ), - ), - - // Value Inspector Panel - if (_showValuePanel && - _focusedColumnName != null && - _focusedCellValue != null) - DataGridValuePanel( - columnName: _focusedColumnName!, - cellValue: _focusedCellValue!, - rowIndex: _focusedRowIndex, - onClose: () => setState(() => _showValuePanel = false), - onUpdateValue: widget.stagingBuffer != null && - _focusedRowIndex != null && - _focusedColumnName != null - ? (newVal) { - final colIdx = widget.columns.indexOf(_focusedColumnName!); - if (colIdx != -1) { - widget.stagingBuffer!.setCell( - _focusedRowIndex!, - colIdx, - newVal, - ); - } - } - : null, - ), - ], - ), - ), - - // Calc Bar Footer - if (_viewMode == ResultViewMode.grid) - DataGridCalcBar(stats: _selectionStats), - ], - ), - ); - } -} - -Future _showSaveFileErrorDialog(material.BuildContext context) { - return showAppDialog( - context: context, - builder: (ctx) => material.AlertDialog( - title: const material.Text('Could not save file'), - content: const material.Text( - 'Check folder permissions or disk space.', - ), - actions: [ - material.TextButton( - onPressed: () => material.Navigator.of(ctx).pop(), - child: const material.Text('OK'), - ), - ], - ), - ); -} +export 'package:querya_desktop/features/workspace/results_tab.dart'; diff --git a/lib/features/main_screen/sql_editor_chrome.dart b/lib/features/main_screen/sql_editor_chrome.dart index 7dcd62d..d608d2e 100644 --- a/lib/features/main_screen/sql_editor_chrome.dart +++ b/lib/features/main_screen/sql_editor_chrome.dart @@ -1,108 +1 @@ -import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; -import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -/// Outer chrome for SQL editors: border, surface, brand accent glow. -class SqlEditorChrome extends StatelessWidget { - const SqlEditorChrome({super.key, required this.child}); - - final Widget child; - - static const double outerRadius = 14; - static const double innerRadius = 10; - - /// Accent glow strength; slightly softer on light themes. - static double chromeGlowAlpha(Brightness brightness) => - brightness == Brightness.light ? 0.08 : 0.1; - - static double inlineGlowAlpha(Brightness brightness) => - brightness == Brightness.light ? 0.05 : 0.07; - - /// Toolbar strip above SQL editor (Postgres/MySQL workspaces). - static material.BoxDecoration sqlToolbarDecoration( - BuildContext context, - ) { - final workbench = context.workbench; - return material.BoxDecoration( - color: workbench.surface.withValues(alpha: 0.85), - border: material.Border( - bottom: material.BorderSide( - color: workbench.borderSubtle.withValues(alpha: 0.35), - ), - ), - ); - } - - /// Decoration for compact SQL fields (dialogs) from theme tokens. - static material.BoxDecoration inlineFieldDecoration( - QueryaEditorTheme editor, - QueryaWorkbenchTheme workbench, { - Brightness brightness = Brightness.dark, - }) { - final border = editor.widgetBorder ?? workbench.borderSubtle; - return material.BoxDecoration( - color: editor.background, - borderRadius: material.BorderRadius.circular(innerRadius), - border: material.Border.all( - color: border.withValues(alpha: 0.45), - ), - boxShadow: [ - material.BoxShadow( - color: workbench.accent.withValues( - alpha: inlineGlowAlpha(brightness), - ), - blurRadius: 18, - offset: const material.Offset(0, 6), - ), - ], - ); - } - - static material.BoxDecoration inlineFieldDecorationFromContext( - BuildContext context, - ) { - return inlineFieldDecoration( - context.editorTheme, - context.workbench, - brightness: Theme.of(context).brightness, - ); - } - - @override - Widget build(BuildContext context) { - final editor = context.editorTheme; - final workbench = context.workbench; - final brightness = Theme.of(context).brightness; - final border = editor.widgetBorder ?? workbench.borderSubtle; - final glow = workbench.accent.withValues( - alpha: chromeGlowAlpha(brightness), - ); - - return material.Container( - decoration: material.BoxDecoration( - borderRadius: material.BorderRadius.circular(outerRadius), - boxShadow: [ - material.BoxShadow( - color: glow, - blurRadius: 28, - spreadRadius: 0, - offset: const material.Offset(0, 10), - ), - ], - ), - child: material.Container( - decoration: material.BoxDecoration( - color: editor.background, - borderRadius: material.BorderRadius.circular(outerRadius), - border: material.Border.all( - color: border.withValues(alpha: 0.5), - ), - ), - clipBehavior: material.Clip.antiAlias, - child: child, - ), - ); - } -} +export 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; diff --git a/lib/features/main_screen/sql_query_history_dialog.dart b/lib/features/main_screen/sql_query_history_dialog.dart index 123cdf7..8b9a2c7 100644 --- a/lib/features/main_screen/sql_query_history_dialog.dart +++ b/lib/features/main_screen/sql_query_history_dialog.dart @@ -1,259 +1 @@ -import 'dart:async' show unawaited; - -import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/layout/window_layout.dart'; -import 'package:querya_desktop/core/storage/app_settings.dart'; -import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_typography.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; - -/// Shows recent SQL for this connection + database; choosing a row replaces the editor text. -void showSqlQueryHistoryDialog({ - required BuildContext context, - required int connectionId, - String? databaseName, - required material.TextEditingController sqlController, -}) { - showAppDialog( - context: context, - builder: (ctx) => material.Dialog( - backgroundColor: material.Colors.transparent, - insetPadding: WindowLayout.dialogSymmetricInsets(ctx), - child: _SqlQueryHistoryDialogContent( - connectionId: connectionId, - databaseName: databaseName, - sqlController: sqlController, - ), - ), - ); -} - -class _SqlQueryHistoryDialogContent extends material.StatefulWidget { - const _SqlQueryHistoryDialogContent({ - required this.connectionId, - required this.databaseName, - required this.sqlController, - }); - - final int connectionId; - final String? databaseName; - final material.TextEditingController sqlController; - - @override - material.State<_SqlQueryHistoryDialogContent> createState() => - _SqlQueryHistoryDialogContentState(); -} - -class _SqlQueryHistoryDialogContentState - extends material.State<_SqlQueryHistoryDialogContent> { - late Future> _future; - - @override - void initState() { - super.initState(); - _future = _load(); - } - - Future> _load() async { - final cap = await AppSettings.instance.getSqlHistoryMaxEntries(); - return LocalDb.instance.listSqlQueryHistory( - connectionId: widget.connectionId, - databaseName: widget.databaseName, - limit: cap, - ); - } - - void _reload() { - setState(() { - _future = _load(); - }); - } - - static final _whitespacePattern = RegExp(r'\s+'); - - static String _previewOneLine(String sql) { - final collapsed = sql.replaceAll(_whitespacePattern, ' ').trim(); - if (collapsed.length <= 96) return collapsed; - return '${collapsed.substring(0, 93)}…'; - } - - static String? _formatWhen(String iso) { - final t = DateTime.tryParse(iso)?.toLocal(); - if (t == null) return null; - String z(int n) => n.toString().padLeft(2, '0'); - return '${t.year}-${z(t.month)}-${z(t.day)} ${z(t.hour)}:${z(t.minute)}'; - } - - Future _confirmClear() async { - final ok = await showAppDialog( - context: context, - builder: (ctx) => material.AlertDialog( - title: const material.Text('Clear query history?'), - content: const material.Text( - 'Removes saved SQL for this connection and database. This cannot be undone.', - ), - actions: [ - material.TextButton( - onPressed: () => material.Navigator.of(ctx).pop(false), - child: const material.Text('Cancel'), - ), - material.TextButton( - onPressed: () => material.Navigator.of(ctx).pop(true), - child: const material.Text('Clear'), - ), - ], - ), - ); - if (ok != true || !mounted) return; - await LocalDb.instance.clearSqlQueryHistoryBucket( - connectionId: widget.connectionId, - databaseName: widget.databaseName, - ); - if (!mounted) return; - _reload(); - } - - void _apply(SqlQueryHistoryEntry e) { - final text = e.sqlText; - widget.sqlController.value = material.TextEditingValue( - text: text, - selection: material.TextSelection.collapsed(offset: text.length), - ); - material.Navigator.of(context).pop(); - } - - @override - material.Widget build(material.BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( - constraints: WindowLayout.dialogConstraints( - context, - maxWidth: 520, - minWidth: 320, - maxHeight: 440, - ), - decoration: material.BoxDecoration( - color: scheme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: scheme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(20, 20, 20, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Query history').large().semiBold(), - const material.SizedBox(height: 4), - const Text( - 'Successful runs from this workspace (newest first).', - ).muted().small(), - ], - ), - ), - material.Expanded( - child: material.FutureBuilder>( - future: _future, - builder: (context, snap) { - if (snap.connectionState != material.ConnectionState.done) { - return const material.Center( - child: material.Padding( - padding: material.EdgeInsets.all(24), - child: material.CircularProgressIndicator(), - ), - ); - } - if (snap.hasError) { - return material.Padding( - padding: const material.EdgeInsets.all(20), - child: Text( - 'Could not load history: ${snap.error}', - style: material.TextStyle(color: scheme.destructive), - ).small(), - ); - } - final items = snap.data ?? []; - if (items.isEmpty) { - return material.Center( - child: const Text( - 'No queries yet. Run SQL to build history.', - ).muted().small(), - ); - } - return material.Scrollbar( - child: material.ListView.separated( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - itemCount: items.length, - separatorBuilder: (_, __) => - material.Divider(height: 1, color: scheme.border), - itemBuilder: (context, i) { - final e = items[i]; - final when = _formatWhen(e.recordedAt); - return material.Material( - color: material.Colors.transparent, - child: material.InkWell( - onTap: () => _apply(e), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - material.Text( - _previewOneLine(e.sqlText), - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontFamily: QueryaTypography.mono, - fontSize: 12, - color: scheme.foreground, - ), - ), - if (when != null) ...[ - const material.SizedBox(height: 4), - Text(when).muted().xSmall(), - ], - ], - ), - ), - ), - ); - }, - ), - ); - }, - ), - ), - material.Padding( - padding: const material.EdgeInsets.fromLTRB(16, 8, 16, 16), - child: material.Row( - children: [ - GhostButton( - onPressed: () => unawaited(_confirmClear()), - child: const Text('Clear history'), - ), - const Spacer(), - OutlineButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ), - ), - ], - ), - ), - ); - } -} +export 'package:querya_desktop/features/workspace/sql_query_history_dialog.dart'; diff --git a/lib/features/main_screen/xml_html_formatter.dart b/lib/features/main_screen/xml_html_formatter.dart index f170036..4d20b8b 100644 --- a/lib/features/main_screen/xml_html_formatter.dart +++ b/lib/features/main_screen/xml_html_formatter.dart @@ -1,117 +1 @@ -/// Formatter and validator for XML and HTML strings. -abstract final class XmlHtmlFormatter { - /// Validates [xml] string and returns null if valid, or an error message if invalid. - static String? validate(String xml) { - final trimmed = xml.trim(); - if (trimmed.isEmpty) return null; - - final tagStack = []; - final tagRegex = RegExp(r'<(/)?([a-zA-Z0-9_\-:]+)([^>]*)>'); - final matches = tagRegex.allMatches(trimmed); - - if (matches.isEmpty) { - if (trimmed.contains('<') || trimmed.contains('>')) { - return 'Malformed XML/HTML tags'; - } - return null; - } - - for (final match in matches) { - final fullMatch = match.group(0)!; - final isClosing = match.group(1) != null; - final tagName = match.group(2)!; - final rest = match.group(3) ?? ''; - - // Check for self-closing tag: or XML declaration or comment - if (fullMatch.startsWith(''; - } - final last = tagStack.removeLast(); - if (last.toLowerCase() != tagName.toLowerCase()) { - return 'Mismatched closing tag: expected , got '; - } - } else { - tagStack.add(tagName); - } - } - - if (tagStack.isNotEmpty) { - return 'Unclosed tag: <${tagStack.last}>'; - } - - return null; - } - - /// Formats / pretty-prints [xml] with [indent] spaces per level. - static String format(String xml, {int indent = 2}) { - final trimmed = xml.trim(); - if (trimmed.isEmpty) return xml; - - final indentStr = ' ' * indent; - final buffer = StringBuffer(); - var level = 0; - - final tokenRegex = RegExp(r'(|<\?[^>]*\?>|]*>|<[^>]+>|[^<]+)'); - final matches = tokenRegex.allMatches(trimmed); - - for (final match in matches) { - var token = match.group(0)!.trim(); - if (token.isEmpty) continue; - - if (token.startsWith(' 0) level--; - if (buffer.isNotEmpty) buffer.writeln(); - buffer.write(indentStr * level); - buffer.write(token); - } else if (token.startsWith('<') && token.endsWith('/>')) { - // Self closing tag - if (buffer.isNotEmpty) buffer.writeln(); - buffer.write(indentStr * level); - buffer.write(token); - } else if (token.startsWith('')) { - // Opening tag - if (buffer.isNotEmpty) buffer.writeln(); - buffer.write(indentStr * level); - buffer.write(token); - level++; - } else { - // Text node - if (buffer.isNotEmpty) buffer.writeln(); - buffer.write(indentStr * level); - buffer.write(token); - } - } - - return buffer.toString(); - } - - /// Minifies [xml] by removing newlines and extraneous spaces between tags. - static String minify(String xml) { - return xml - .replaceAll(RegExp(r'>\s+<'), '><') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - } -} +export 'package:querya_desktop/features/workspace/xml_html_formatter.dart'; diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index d18b62b..b4f0a9e 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; -import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; +import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 6107e38..bfbd55c 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -16,13 +16,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; -import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; -import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; -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/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Ad-hoc SQL editor + results for MySQL / MariaDB. diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index 49177e7..d8a5160 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; -import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; +import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Returns true if [sql] is allowed to run (read-only: SELECT / WITH). diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index c889142..b732363 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -20,13 +20,7 @@ import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; -import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; -import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; -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/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Database used for this SQL workspace session (matches [PostgresService.acquire]). diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 0f6e2dd..737f708 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -15,13 +15,7 @@ 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'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; -import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; -import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; -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/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Ad-hoc SQL editor + results for SQLite. diff --git a/lib/features/workspace/data_grid_calc_bar.dart b/lib/features/workspace/data_grid_calc_bar.dart new file mode 100644 index 0000000..c58f41d --- /dev/null +++ b/lib/features/workspace/data_grid_calc_bar.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Status bar footer for Data Grid displaying live selection statistics (Count, Distinct, Sum, Avg, Median, Min, Max). +class DataGridCalcBar extends StatelessWidget { + const DataGridCalcBar({ + super.key, + required this.stats, + }); + + final GridCalcStats stats; + + @override + Widget build(BuildContext context) { + if (stats.totalCount <= 1 && !stats.hasNumericStats) { + return const material.SizedBox.shrink(); + } + + final cs = Theme.of(context).colorScheme; + + return material.Container( + height: 26, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Row( + children: [ + material.Expanded( + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + _StatBadge( + label: 'Count', + value: '${stats.totalCount}', + ), + const Gap(8), + _StatBadge( + label: 'Distinct', + value: '${stats.distinctCount}', + ), + if (stats.nullCount > 0) ...[ + const Gap(8), + _StatBadge( + label: 'NULLs', + value: '${stats.nullCount}', + ), + ], + if (stats.hasNumericStats) ...[ + const Gap(8), + _StatBadge( + label: 'Sum', + value: GridSelectionCalcEngine.formatNum(stats.sum), + ), + const Gap(8), + _StatBadge( + label: 'Avg', + value: GridSelectionCalcEngine.formatNum(stats.average), + ), + if (stats.median != null) ...[ + const Gap(8), + _StatBadge( + label: 'Median', + value: GridSelectionCalcEngine.formatNum(stats.median), + ), + ], + const Gap(8), + _StatBadge( + label: 'Min', + value: GridSelectionCalcEngine.formatNum(stats.min), + ), + const Gap(8), + _StatBadge( + label: 'Max', + value: GridSelectionCalcEngine.formatNum(stats.max), + ), + ], + ], + ), + ), + ), + const Gap(6), + material.Tooltip( + message: 'Copy all stats summary', + child: material.InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: stats.toSummaryString())); + }, + borderRadius: material.BorderRadius.circular(3), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.copy_all_rounded, + size: 13, + color: cs.mutedForeground, + ), + const Gap(3), + Text('Copy Stats', style: TextStyle(fontSize: 10.5, color: cs.mutedForeground)), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class _StatBadge extends StatelessWidget { + const _StatBadge({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.Tooltip( + message: 'Click to copy $label: $value', + child: material.InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + }, + borderRadius: material.BorderRadius.circular(3), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + Text( + '$label: ', + style: TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.foreground, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/workspace/data_grid_filter_bar.dart b/lib/features/workspace/data_grid_filter_bar.dart new file mode 100644 index 0000000..96da7aa --- /dev/null +++ b/lib/features/workspace/data_grid_filter_bar.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Kind of filter autocomplete suggestion. +enum FilterSuggestionKind { + column, + operator, + keyword, +} + +/// Autocomplete suggestion model for the Data Grid filter bar. +class FilterSuggestion { + const FilterSuggestion({ + required this.text, + required this.kind, + this.description = '', + this.insertText, + }); + + final String text; + final FilterSuggestionKind kind; + final String description; + final String? insertText; +} + +/// Helper to compute context-aware syntax suggestions for the filter bar. +abstract final class FilterSuggestionEngine { + static const _operators = [ + FilterSuggestion(text: '=', kind: FilterSuggestionKind.operator, description: 'Equal'), + FilterSuggestion(text: '!=', kind: FilterSuggestionKind.operator, description: 'Not equal'), + FilterSuggestion(text: '>', kind: FilterSuggestionKind.operator, description: 'Greater than'), + FilterSuggestion(text: '>=', kind: FilterSuggestionKind.operator, description: 'Greater or equal'), + FilterSuggestion(text: '<', kind: FilterSuggestionKind.operator, description: 'Less than'), + FilterSuggestion(text: '<=', kind: FilterSuggestionKind.operator, description: 'Less or equal'), + FilterSuggestion(text: 'LIKE', kind: FilterSuggestionKind.operator, description: 'Wildcard match (%_)'), + FilterSuggestion(text: 'ILIKE', kind: FilterSuggestionKind.operator, description: 'Case-insensitive match'), + FilterSuggestion(text: 'IN (...)', kind: FilterSuggestionKind.operator, description: 'List inclusion', insertText: "IN ('')"), + FilterSuggestion(text: 'IS NULL', kind: FilterSuggestionKind.operator, description: 'Null check'), + FilterSuggestion(text: 'IS NOT NULL', kind: FilterSuggestionKind.operator, description: 'Not null check'), + FilterSuggestion(text: 'BETWEEN', kind: FilterSuggestionKind.operator, description: 'Range check', insertText: 'BETWEEN AND '), + ]; + + static const _keywords = [ + FilterSuggestion(text: 'AND', kind: FilterSuggestionKind.keyword, description: 'Logical AND'), + FilterSuggestion(text: 'OR', kind: FilterSuggestionKind.keyword, description: 'Logical OR'), + FilterSuggestion(text: 'NOT', kind: FilterSuggestionKind.keyword, description: 'Logical NOT'), + ]; + + /// Computes suggestions based on current [text] and available [columns]. + static List getSuggestions({ + required String text, + required List columns, + }) { + final trimmed = text.trim(); + if (trimmed.isEmpty) { + return [ + for (final col in columns) + FilterSuggestion( + text: col, + kind: FilterSuggestionKind.column, + description: 'Column', + ), + ]; + } + + final tokens = trimmed.split(RegExp(r'\s+')); + final lastToken = tokens.last; + + // Check if previous token was a column name + if (tokens.length >= 2) { + final prevToken = tokens[tokens.length - 2].toLowerCase(); + final isPrevCol = columns.any((c) => c.toLowerCase() == prevToken); + if (isPrevCol) { + final matches = _operators + .where((op) => op.text.toLowerCase().startsWith(lastToken.toLowerCase())) + .toList(); + if (matches.isNotEmpty) return matches; + } + } + + // If only one token and matches a known column exactly, suggest operators + final exactCol = columns.firstWhere( + (c) => c.toLowerCase() == lastToken.toLowerCase(), + orElse: () => '', + ); + if (exactCol.isNotEmpty) { + return _operators; + } + + // Partial column name match + final matchingCols = columns + .where((c) => c.toLowerCase().startsWith(lastToken.toLowerCase())) + .map( + (c) => FilterSuggestion( + text: c, + kind: FilterSuggestionKind.column, + description: 'Column', + ), + ) + .toList(); + + // Partial keyword match (AND, OR, NOT) + final matchingKw = _keywords + .where((k) => k.text.toLowerCase().startsWith(lastToken.toLowerCase())) + .toList(); + + return [...matchingCols, ...matchingKw]; + } +} + +/// Quick Filter Bar for Data Grid. +/// Allows live client-side row filtering with context-aware autocomplete suggestions. +class DataGridFilterBar extends material.StatefulWidget { + const DataGridFilterBar({ + super.key, + required this.filterText, + required this.onFilterChanged, + required this.totalRowCount, + required this.filteredRowCount, + this.columns = const [], + }); + + final String filterText; + final ValueChanged onFilterChanged; + final int totalRowCount; + final int filteredRowCount; + final List columns; + + @override + material.State createState() => _DataGridFilterBarState(); +} + +class _DataGridFilterBarState extends material.State { + late final material.TextEditingController _controller; + final _layerLink = material.LayerLink(); + material.OverlayEntry? _overlayEntry; + List _suggestions = []; + int _highlightedIndex = 0; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController(text: widget.filterText); + } + + @override + void didUpdateWidget(covariant DataGridFilterBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.filterText != widget.filterText && + _controller.text != widget.filterText) { + _controller.text = widget.filterText; + } + } + + @override + void dispose() { + _hideSuggestions(); + _controller.dispose(); + super.dispose(); + } + + void _onChanged(String val) { + widget.onFilterChanged(val); + _updateSuggestions(val); + } + + void _updateSuggestions(String val) { + final suggs = FilterSuggestionEngine.getSuggestions( + text: val, + columns: widget.columns, + ); + + if (suggs.isEmpty || val.trim().isEmpty) { + _hideSuggestions(); + } else { + _suggestions = suggs; + _highlightedIndex = 0; + _showSuggestions(); + } + } + + void _showSuggestions() { + _hideSuggestions(); + final overlay = material.Overlay.maybeOf(context); + if (overlay == null) return; + + _overlayEntry = material.OverlayEntry( + builder: (context) { + final cs = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return material.Positioned( + width: 320, + child: material.CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: const material.Offset(24, 32), + child: material.Material( + elevation: 4, + borderRadius: material.BorderRadius.circular(6), + color: isDark + ? const material.Color(0xFF1E1E22) + : const material.Color(0xFFFFFFFF), + child: material.Container( + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.6), + ), + ), + constraints: const material.BoxConstraints(maxHeight: 200), + child: material.ListView.builder( + shrinkWrap: true, + padding: const material.EdgeInsets.symmetric(vertical: 4), + itemCount: _suggestions.length, + itemBuilder: (ctx, i) { + final s = _suggestions[i]; + final isHighlighted = i == _highlightedIndex; + return material.InkWell( + onTap: () => _applySuggestion(s), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + color: isHighlighted + ? cs.primary.withValues(alpha: 0.12) + : material.Colors.transparent, + child: material.Row( + children: [ + _buildSuggestionIcon(s.kind, cs), + const Gap(8), + Text(s.text).semiBold().small(), + const Spacer(), + if (s.description.isNotEmpty) + Text(s.description).muted().xSmall(), + ], + ), + ), + ); + }, + ), + ), + ), + ), + ); + }, + ); + + overlay.insert(_overlayEntry!); + } + + material.Widget _buildSuggestionIcon(FilterSuggestionKind kind, ColorScheme cs) { + switch (kind) { + case FilterSuggestionKind.column: + return material.Icon( + material.Icons.table_chart_outlined, + size: 13, + color: cs.primary, + ); + case FilterSuggestionKind.operator: + return material.Icon( + material.Icons.code_rounded, + size: 13, + color: material.Colors.amber.shade700, + ); + case FilterSuggestionKind.keyword: + return material.Icon( + material.Icons.vpn_key_outlined, + size: 13, + color: material.Colors.green.shade600, + ); + } + } + + void _hideSuggestions() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + void _applySuggestion(FilterSuggestion s) { + final text = _controller.text; + final toInsert = s.insertText ?? s.text; + + final tokens = text.split(RegExp(r'\s+')); + if (tokens.isNotEmpty && s.kind == FilterSuggestionKind.column) { + tokens[tokens.length - 1] = toInsert; + final newText = '${tokens.join(' ')} '; + _controller.value = material.TextEditingValue( + text: newText, + selection: material.TextSelection.collapsed(offset: newText.length), + ); + _onChanged(newText); + } else { + final newText = '$text $toInsert '; + _controller.value = material.TextEditingValue( + text: newText, + selection: material.TextSelection.collapsed(offset: newText.length), + ); + _onChanged(newText); + } + _hideSuggestions(); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isFiltered = widget.filterText.trim().isNotEmpty; + + return material.CompositedTransformTarget( + link: _layerLink, + child: material.Container( + height: 34, + padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.filter_alt_outlined, + size: 15, + color: isFiltered ? cs.primary : cs.mutedForeground, + ), + const Gap(6), + material.Expanded( + child: material.TextField( + controller: _controller, + onChanged: _onChanged, + style: TextStyle( + fontSize: 12, + color: cs.foreground, + ), + decoration: material.InputDecoration( + hintText: 'Filter results... (e.g. "active", "status = ACTIVE", "amount > 100")', + hintStyle: TextStyle( + fontSize: 12, + color: cs.mutedForeground.withValues(alpha: 0.7), + ), + border: material.InputBorder.none, + isDense: true, + contentPadding: material.EdgeInsets.zero, + ), + ), + ), + if (isFiltered) ...[ + const Gap(6), + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + '${widget.filteredRowCount} / ${widget.totalRowCount}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.primary, + ), + ), + ), + const Gap(4), + material.IconButton( + icon: const material.Icon(material.Icons.close, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 20, minHeight: 20), + color: cs.mutedForeground, + onPressed: () { + _controller.clear(); + _hideSuggestions(); + widget.onFilterChanged(''); + }, + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/workspace/data_grid_groupings_view.dart b/lib/features/workspace/data_grid_groupings_view.dart new file mode 100644 index 0000000..a585145 --- /dev/null +++ b/lib/features/workspace/data_grid_groupings_view.dart @@ -0,0 +1,391 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/workspace/grid_groupings_engine.dart'; +import 'package:querya_desktop/features/workspace/result_grid_view.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Groupings / Pivot view tab for tabular data with hierarchical grouping and custom aggregations. +class DataGridGroupingsView extends material.StatefulWidget { + const DataGridGroupingsView({ + super.key, + required this.columns, + required this.rows, + }); + + final List columns; + final List> rows; + + @override + material.State createState() => + _DataGridGroupingsViewState(); +} + +class _DataGridGroupingsViewState + extends material.State { + late List _selectedColIndices; + GroupingAggType _aggType = GroupingAggType.count; + int? _aggTargetColIndex; + GroupSortBy _sortBy = GroupSortBy.count; + bool _sortAscending = false; + final Set _expandedKeys = {}; + + @override + void initState() { + super.initState(); + _selectedColIndices = widget.columns.isNotEmpty ? [0] : []; + if (widget.columns.length > 1) { + // Pick first numeric-looking column as default target for sum/avg if available + _aggTargetColIndex = 1; + } + } + + @override + void didUpdateWidget(covariant DataGridGroupingsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.columns != widget.columns) { + if (_selectedColIndices.isEmpty && widget.columns.isNotEmpty) { + _selectedColIndices = [0]; + } else { + _selectedColIndices.removeWhere((idx) => idx >= widget.columns.length); + if (_selectedColIndices.isEmpty && widget.columns.isNotEmpty) { + _selectedColIndices = [0]; + } + } + } + } + + void _exportPivot() { + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: _selectedColIndices, + rows: widget.rows, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + sortBy: _sortBy, + sortAscending: _sortAscending, + ); + + final groupName = _selectedColIndices.isNotEmpty + ? widget.columns[_selectedColIndices.first] + : 'Group'; + final csv = GridGroupingsEngine.exportPivotToCsv( + groups: groups, + groupByColumnName: groupName, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + ); + + Clipboard.setData(ClipboardData(text: csv)); + } + + @override + material.Widget build(material.BuildContext context) { + if (widget.columns.isEmpty || widget.rows.isEmpty) { + return material.Center( + child: const Text('No data available for grouping.').muted(), + ); + } + + final cs = Theme.of(context).colorScheme; + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: _selectedColIndices, + rows: widget.rows, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + sortBy: _sortBy, + sortAscending: _sortAscending, + ); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Top Toolbar for selecting Group By, Aggregation, and Sorting + material.Container( + height: 40, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + children: [ + material.Icon( + material.Icons.account_tree_outlined, + size: 15, + color: cs.primary, + ), + const Gap(6), + const Text('Group:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _selectedColIndices.isNotEmpty && + _selectedColIndices.first < widget.columns.length + ? _selectedColIndices.first + : 0, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: List.generate(widget.columns.length, (i) { + return material.DropdownMenuItem( + value: i, + child: Text(widget.columns[i]), + ); + }), + onChanged: (idx) { + if (idx != null) { + setState(() { + _selectedColIndices = [idx]; + _expandedKeys.clear(); + }); + } + }, + ), + + const Gap(12), + material.VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: cs.border.withValues(alpha: 0.3), + ), + const Gap(12), + + // Aggregation Selector + const Text('Agg:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _aggType, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: GroupingAggType.values.map((t) { + return material.DropdownMenuItem( + value: t, + child: Text(t.label), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setState(() => _aggType = val); + } + }, + ), + if (_aggType != GroupingAggType.count) ...[ + const Gap(4), + material.DropdownButton( + value: _aggTargetColIndex != null && + _aggTargetColIndex! < widget.columns.length + ? _aggTargetColIndex + : 0, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: List.generate(widget.columns.length, (i) { + return material.DropdownMenuItem( + value: i, + child: Text(widget.columns[i]), + ); + }), + onChanged: (idx) { + if (idx != null) { + setState(() => _aggTargetColIndex = idx); + } + }, + ), + ], + + const Gap(12), + material.VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: cs.border.withValues(alpha: 0.3), + ), + const Gap(12), + + // Sort Selector + const Text('Sort:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _sortBy, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: GroupSortBy.values.map((s) { + return material.DropdownMenuItem( + value: s, + child: Text(s.label), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setState(() => _sortBy = val); + } + }, + ), + material.IconButton( + icon: material.Icon( + _sortAscending + ? material.Icons.arrow_upward_rounded + : material.Icons.arrow_downward_rounded, + size: 14, + ), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: () => setState(() => _sortAscending = !_sortAscending), + ), + + const Gap(8), + material.Tooltip( + message: 'Copy Pivot CSV to Clipboard', + child: material.IconButton( + icon: const material.Icon(material.Icons.copy_rounded, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: _exportPivot, + ), + ), + ], + ), + ), + ), + + // Groupings List + material.Expanded( + child: material.ListView.separated( + itemCount: groups.length, + separatorBuilder: (_, __) => material.Divider( + height: 1, + color: cs.border.withValues(alpha: 0.2), + ), + itemBuilder: (context, idx) { + final group = groups[idx]; + final isExpanded = _expandedKeys.contains(group.groupKey); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.InkWell( + onTap: () { + setState(() { + if (isExpanded) { + _expandedKeys.remove(group.groupKey); + } else { + _expandedKeys.add(group.groupKey); + } + }); + }, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + child: material.Row( + children: [ + material.Icon( + isExpanded + ? material.Icons.keyboard_arrow_down_rounded + : material.Icons.keyboard_arrow_right_rounded, + size: 18, + color: cs.mutedForeground, + ), + const Gap(8), + material.Expanded( + child: Text( + group.groupKey, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ), + ), + if (group.aggValue != null && _aggType != GroupingAggType.count) ...[ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + margin: const material.EdgeInsets.only(right: 6), + decoration: material.BoxDecoration( + color: cs.secondary.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(6), + ), + child: Text( + '${_aggType.label}: ${group.aggValue!.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + ), + ], + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + ), + child: Text( + '${group.count} rows (${group.percentage.toStringAsFixed(1)}%)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.primary, + ), + ), + ), + ], + ), + ), + ), + + // Expanded sub-grid + if (isExpanded) + material.Container( + height: 220, + margin: const material.EdgeInsets.only( + left: 28, + right: 12, + bottom: 8, + ), + decoration: material.BoxDecoration( + border: material.Border.all( + color: cs.border.withValues(alpha: 0.4), + ), + borderRadius: material.BorderRadius.circular(6), + ), + child: VirtualResultGrid( + columns: widget.columns, + rows: group.rows, + ), + ), + ], + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/workspace/data_grid_staging_buffer.dart b/lib/features/workspace/data_grid_staging_buffer.dart new file mode 100644 index 0000000..bae5c99 --- /dev/null +++ b/lib/features/workspace/data_grid_staging_buffer.dart @@ -0,0 +1,319 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; + +/// Status of a row within the staging buffer. +enum StagedRowStatus { + unchanged, + modified, + inserted, + deleted, +} + +/// Status of an individual cell. +enum StagedCellStatus { + clean, + modified, +} + +/// In-memory staging buffer for interactive table data edits. +/// +/// Keeps original data intact and tracks staged changes (modified cells, +/// newly inserted rows, and rows marked for deletion). Notifies listeners +/// on any mutation so the UI (VirtualResultGrid, toolbar) updates reactively. +class DataGridStagingBuffer extends ChangeNotifier { + DataGridStagingBuffer({ + required List columns, + required List> rows, + }) : _originalColumns = List.unmodifiable(columns), + _originalRows = List.unmodifiable( + rows.map((r) => List.unmodifiable(r)).toList(), + ); + + final List _originalColumns; + final List> _originalRows; + + /// Map of `rowIndex -> (colIndex -> stagedValue)` for modified cells in baseline rows. + final Map> _modifiedCells = {}; + + /// Rows appended as new records. + final List> _insertedRows = []; + + /// Baseline row indices marked for deletion. + final Set _deletedRowIndices = {}; + + List get columns => _originalColumns; + List> get originalRows => _originalRows; + + /// Total number of visible rows (baseline + inserted). + int get totalRowCount => _originalRows.length + _insertedRows.length; + + /// True if there are any pending edits, insertions, or deletions. + bool get isDirty => + _modifiedCells.isNotEmpty || + _insertedRows.isNotEmpty || + _deletedRowIndices.isNotEmpty; + + /// Total count of staged modifications (modified cells + inserted rows + deleted rows). + int get changeCount { + var cellCount = 0; + for (final colMap in _modifiedCells.values) { + cellCount += colMap.length; + } + return cellCount + _insertedRows.length + _deletedRowIndices.length; + } + + /// Number of baseline rows marked for deletion. + int get deletedRowCount => _deletedRowIndices.length; + + /// Number of newly inserted rows. + int get insertedRowCount => _insertedRows.length; + + /// Number of modified cells in baseline rows. + int get modifiedCellCount { + var count = 0; + for (final colMap in _modifiedCells.values) { + count += colMap.length; + } + return count; + } + + /// Returns the current effective cell value. + String getCellValue(int row, int col) { + if (row < 0 || col < 0) return ''; + if (row < _originalRows.length) { + final staged = _modifiedCells[row]?[col]; + if (staged != null) { + return staged == TableMutationEngine.kNullSentinel ? 'NULL' : staged; + } + if (col < _originalRows[row].length) { + return _originalRows[row][col]; + } + return ''; + } + final insertIdx = row - _originalRows.length; + if (insertIdx < _insertedRows.length && col < _insertedRows[insertIdx].length) { + final ins = _insertedRows[insertIdx][col]; + return ins == TableMutationEngine.kNullSentinel ? 'NULL' : ins; + } + return ''; + } + + /// True if the specified cell is explicitly null or stores 'NULL'. + bool isCellNull(int row, int col) { + if (row < 0 || col < 0) return false; + if (row < _originalRows.length) { + final staged = _modifiedCells[row]?[col]; + if (staged != null) return staged == TableMutationEngine.kNullSentinel; + if (col < _originalRows[row].length) { + final orig = _originalRows[row][col]; + return orig == 'NULL' || orig == TableMutationEngine.kNullSentinel; + } + return false; + } + final insertIdx = row - _originalRows.length; + if (insertIdx < _insertedRows.length && col < _insertedRows[insertIdx].length) { + final ins = _insertedRows[insertIdx][col]; + return ins == 'NULL' || ins == TableMutationEngine.kNullSentinel; + } + return false; + } + + /// Returns original baseline cell value, or null if row is inserted. + String? getOriginalCellValue(int row, int col) { + if (row >= 0 && row < _originalRows.length && col >= 0 && col < _originalRows[row].length) { + return _originalRows[row][col]; + } + return null; + } + + /// Returns the status of the specified row. + StagedRowStatus getRowStatus(int row) { + if (row < 0) return StagedRowStatus.unchanged; + if (row >= _originalRows.length) { + return StagedRowStatus.inserted; + } + if (_deletedRowIndices.contains(row)) { + return StagedRowStatus.deleted; + } + if (_modifiedCells.containsKey(row) && _modifiedCells[row]!.isNotEmpty) { + return StagedRowStatus.modified; + } + return StagedRowStatus.unchanged; + } + + /// Returns the status of the specified cell. + StagedCellStatus getCellStatus(int row, int col) { + if (row >= 0 && row < _originalRows.length) { + if (_modifiedCells[row]?.containsKey(col) == true) { + return StagedCellStatus.modified; + } + } + return StagedCellStatus.clean; + } + + /// Explicitly sets the cell to SQL NULL. + void setCellNull(int row, int col) { + setCell(row, col, TableMutationEngine.kNullSentinel); + } + + /// Stages an edit for the specified cell. + /// If the new value equals original value, clears the modified flag. + void setCell(int row, int col, String value) { + if (row < 0 || col < 0) return; + + if (row < _originalRows.length) { + final orig = col < _originalRows[row].length ? _originalRows[row][col] : ''; + if (value == orig) { + if (_modifiedCells.containsKey(row)) { + _modifiedCells[row]!.remove(col); + if (_modifiedCells[row]!.isEmpty) { + _modifiedCells.remove(row); + } + notifyListeners(); + } + } else { + final rowMap = _modifiedCells.putIfAbsent(row, () => {}); + if (rowMap[col] != value) { + rowMap[col] = value; + notifyListeners(); + } + } + } else { + final insertIdx = row - _originalRows.length; + if (insertIdx < _insertedRows.length) { + while (_insertedRows[insertIdx].length <= col) { + _insertedRows[insertIdx].add(''); + } + if (_insertedRows[insertIdx][col] != value) { + _insertedRows[insertIdx][col] = value; + notifyListeners(); + } + } + } + } + + /// Appends a new empty or pre-filled row. + int addRow([List? initialValues]) { + final row = initialValues != null + ? List.from(initialValues) + : List.filled(_originalColumns.length, ''); + _insertedRows.add(row); + notifyListeners(); + return totalRowCount - 1; + } + + /// Removes an inserted row at the given absolute row index. + void removeInsertedRow(int row) { + final insertIdx = row - _originalRows.length; + if (insertIdx >= 0 && insertIdx < _insertedRows.length) { + _insertedRows.removeAt(insertIdx); + notifyListeners(); + } + } + + /// Marks a baseline row as deleted, or removes an inserted row. + void toggleDeleteRow(int row) { + if (row < 0) return; + if (row < _originalRows.length) { + if (_deletedRowIndices.contains(row)) { + _deletedRowIndices.remove(row); + } else { + _deletedRowIndices.add(row); + } + notifyListeners(); + } else { + removeInsertedRow(row); + } + } + + /// Reverts changes for a single cell back to baseline. + void revertCell(int row, int col) { + if (row < _originalRows.length && _modifiedCells.containsKey(row)) { + if (_modifiedCells[row]!.remove(col) != null) { + if (_modifiedCells[row]!.isEmpty) { + _modifiedCells.remove(row); + } + notifyListeners(); + } + } + } + + /// Reverts all modifications or deletion for a given row. + void revertRow(int row) { + if (row < 0) return; + if (row < _originalRows.length) { + var changed = false; + if (_modifiedCells.remove(row) != null) changed = true; + if (_deletedRowIndices.remove(row)) changed = true; + if (changed) notifyListeners(); + } else { + removeInsertedRow(row); + } + } + + /// Reverts all staged changes and resets buffer to clean baseline. + void revertAll() { + if (!isDirty) return; + _modifiedCells.clear(); + _insertedRows.clear(); + _deletedRowIndices.clear(); + notifyListeners(); + } + + /// Read-only snapshot of modified cells mapping. + Map> get modifiedCells => + Map>.unmodifiable( + _modifiedCells.map((k, v) => MapEntry(k, Map.unmodifiable(v))), + ); + + /// Read-only list of newly inserted rows. + List> get insertedRows => + List.unmodifiable(_insertedRows.map((r) => List.unmodifiable(r))); + + /// Read-only set of deleted row indices. + Set get deletedRowIndices => Set.unmodifiable(_deletedRowIndices); + + /// Generates an atomic [TableMutationPlan] for the current staged changes. + TableMutationPlan generateMutationPlan({ + required SqlDialect dialect, + required String tableName, + String? schema, + List primaryKeys = const [], + Map? columnDataTypes, + }) { + return TableMutationEngine.generatePlan( + dialect: dialect, + tableName: tableName, + schema: schema, + columns: _originalColumns, + primaryKeys: primaryKeys, + originalRows: _originalRows, + modifiedCells: _modifiedCells, + insertedRows: _insertedRows, + deletedRowIndices: _deletedRowIndices, + columnDataTypes: columnDataTypes, + ); + } + + /// Returns the full list of effective rows (original with modifications applied + inserted rows). + List> get effectiveRows { + final result = >[]; + for (var r = 0; r < _originalRows.length; r++) { + final row = List.from(_originalRows[r]); + final mods = _modifiedCells[r]; + if (mods != null) { + for (final entry in mods.entries) { + if (entry.key < row.length) { + row[entry.key] = entry.value; + } + } + } + result.add(row); + } + for (final ins in _insertedRows) { + result.add(List.from(ins)); + } + return result; + } +} + diff --git a/lib/features/workspace/data_grid_staging_toolbar.dart b/lib/features/workspace/data_grid_staging_toolbar.dart new file mode 100644 index 0000000..5e455f4 --- /dev/null +++ b/lib/features/workspace/data_grid_staging_toolbar.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Toolbar for managing staged data changes (Add, Delete, Revert, Save). +class DataGridStagingToolbar extends StatelessWidget { + const DataGridStagingToolbar({ + super.key, + required this.stagingBuffer, + this.selectedRowIndex, + this.onApplyChanges, + this.isSaving = false, + }); + + final DataGridStagingBuffer stagingBuffer; + final int? selectedRowIndex; + final VoidCallback? onApplyChanges; + final bool isSaving; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return ListenableBuilder( + listenable: stagingBuffer, + builder: (context, _) { + final isDirty = stagingBuffer.isDirty; + final changeCount = stagingBuffer.changeCount; + final selectedRow = selectedRowIndex; + final hasSelectedRow = selectedRow != null && + selectedRow >= 0 && + selectedRow < stagingBuffer.totalRowCount; + + final isSelectedDeleted = hasSelectedRow && + stagingBuffer.getRowStatus(selectedRow) == StagedRowStatus.deleted; + + return material.Container( + height: 32, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + // Add Row + _ToolbarButton( + label: 'Add Row', + icon: material.Icons.add_rounded, + onPressed: isSaving ? null : () => stagingBuffer.addRow(), + ), + const Gap(4), + + // Delete / Restore Row + _ToolbarButton( + label: isSelectedDeleted ? 'Restore Row' : 'Delete Row', + icon: isSelectedDeleted + ? material.Icons.restore_from_trash_rounded + : material.Icons.remove_circle_outline_rounded, + color: isSelectedDeleted + ? cs.primary + : (hasSelectedRow ? cs.destructive : null), + onPressed: isSaving || !hasSelectedRow + ? null + : () => stagingBuffer.toggleDeleteRow(selectedRow), + ), + const Gap(4), + + // Revert + if (isDirty) ...[ + _ToolbarButton( + label: 'Revert All', + icon: material.Icons.undo_rounded, + color: cs.mutedForeground, + onPressed: isSaving ? null : () => stagingBuffer.revertAll(), + ), + const Gap(6), + ], + + // Badge + if (isDirty) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: cs.primary.withValues(alpha: 0.3), + width: 1, + ), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Container( + width: 5, + height: 5, + decoration: material.BoxDecoration( + color: cs.primary, + shape: material.BoxShape.circle, + ), + ), + const Gap(5), + Text( + '$changeCount pending ${changeCount == 1 ? 'change' : 'changes'}', + ).xSmall().semiBold(), + ], + ), + ) + else + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.check_circle_outline_rounded, + size: 13, + color: cs.mutedForeground, + ), + const Gap(4), + const Text('No changes').xSmall().muted(), + ], + ), + + const Gap(12), + + // Save Changes button + material.MouseRegion( + cursor: (isDirty && !isSaving) + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: isDirty && !isSaving ? onApplyChanges : null, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: isDirty + ? cs.primary + : cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + if (isSaving) + material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: cs.primaryForeground, + ), + ) + else + material.Icon( + material.Icons.save_rounded, + size: 14, + color: isDirty + ? cs.primaryForeground + : cs.mutedForeground, + ), + const Gap(5), + material.Text( + isSaving ? 'Saving…' : 'Save Changes', + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: isDirty + ? cs.primaryForeground + : cs.mutedForeground, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _ToolbarButton extends material.StatelessWidget { + const _ToolbarButton({ + required this.label, + required this.icon, + this.onPressed, + this.color, + }); + + final String label; + final material.IconData icon; + final material.VoidCallback? onPressed; + final material.Color? color; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final enabled = onPressed != null; + final fg = color ?? cs.foreground; + + return material.MouseRegion( + cursor: enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.GestureDetector( + onTap: onPressed, + behavior: material.HitTestBehavior.opaque, + child: material.Opacity( + opacity: enabled ? 1.0 : 0.4, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(icon, size: 14, color: fg), + const material.SizedBox(width: 4), + material.Text( + label, + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: fg, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/workspace/data_grid_value_panel.dart b/lib/features/workspace/data_grid_value_panel.dart new file mode 100644 index 0000000..5b78d4f --- /dev/null +++ b/lib/features/workspace/data_grid_value_panel.dart @@ -0,0 +1,396 @@ +import 'dart:convert'; +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'xml_html_formatter.dart'; + +/// Language mode for Value Panel inspector. +enum ValuePanelLanguage { + auto, + json, + xml, + sql, + text, +} + +/// Collapsible right-hand side panel for inspecting cell content in detail. +class DataGridValuePanel extends material.StatefulWidget { + const DataGridValuePanel({ + super.key, + required this.columnName, + required this.cellValue, + required this.rowIndex, + required this.onClose, + this.onUpdateValue, + }); + + final String columnName; + final String cellValue; + final int? rowIndex; + final material.VoidCallback onClose; + final ValueChanged? onUpdateValue; + + @override + material.State createState() => _DataGridValuePanelState(); +} + +class _DataGridValuePanelState extends material.State { + late final material.TextEditingController _controller; + ValuePanelLanguage _selectedLanguage = ValuePanelLanguage.auto; + String? _validationError; + bool _wordWrap = true; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController(text: _formatInitialValue(widget.cellValue)); + _controller.addListener(_validateContent); + _validateContent(); + } + + @override + void didUpdateWidget(covariant DataGridValuePanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.cellValue != widget.cellValue) { + _controller.text = _formatInitialValue(widget.cellValue); + _validateContent(); + } + } + + @override + void dispose() { + _controller.removeListener(_validateContent); + _controller.dispose(); + super.dispose(); + } + + String _formatInitialValue(String input) { + final trimmed = input.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + final parsed = jsonDecode(trimmed); + return const JsonEncoder.withIndent(' ').convert(parsed); + } catch (_) {} + } else if (trimmed.startsWith('<') && trimmed.endsWith('>')) { + try { + return XmlHtmlFormatter.format(trimmed); + } catch (_) {} + } + return input; + } + + ValuePanelLanguage get _effectiveLanguage { + if (_selectedLanguage != ValuePanelLanguage.auto) { + return _selectedLanguage; + } + final trimmed = _controller.text.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + return ValuePanelLanguage.json; + } + if (trimmed.startsWith('<') && trimmed.endsWith('>')) { + return ValuePanelLanguage.xml; + } + final upper = trimmed.toUpperCase(); + if (upper.startsWith('SELECT ') || + upper.startsWith('INSERT ') || + upper.startsWith('UPDATE ') || + upper.startsWith('CREATE ') || + upper.startsWith('WITH ')) { + return ValuePanelLanguage.sql; + } + return ValuePanelLanguage.text; + } + + void _validateContent() { + final text = _controller.text.trim(); + if (text.isEmpty) { + if (_validationError != null) { + setState(() => _validationError = null); + } + return; + } + + final lang = _effectiveLanguage; + String? err; + + if (lang == ValuePanelLanguage.json) { + try { + jsonDecode(text); + } catch (e) { + err = 'Invalid JSON: $e'; + } + } else if (lang == ValuePanelLanguage.xml) { + err = XmlHtmlFormatter.validate(text); + } + + if (err != _validationError) { + setState(() => _validationError = err); + } + } + + void _formatCode() { + final lang = _effectiveLanguage; + if (lang == ValuePanelLanguage.json) { + try { + final parsed = jsonDecode(_controller.text); + final pretty = const JsonEncoder.withIndent(' ').convert(parsed); + setState(() => _controller.text = pretty); + } catch (_) {} + } else if (lang == ValuePanelLanguage.xml) { + final pretty = XmlHtmlFormatter.format(_controller.text); + setState(() => _controller.text = pretty); + } + } + + void _minifyCode() { + final lang = _effectiveLanguage; + if (lang == ValuePanelLanguage.json) { + try { + final parsed = jsonDecode(_controller.text); + final compact = jsonEncode(parsed); + setState(() => _controller.text = compact); + } catch (_) {} + } else if (lang == ValuePanelLanguage.xml) { + final compact = XmlHtmlFormatter.minify(_controller.text); + setState(() => _controller.text = compact); + } + } + + QueryaCodeLanguage _toQueryaLanguage(ValuePanelLanguage lang) { + switch (lang) { + case ValuePanelLanguage.json: + return QueryaCodeLanguage.json; + case ValuePanelLanguage.sql: + return QueryaCodeLanguage.sql; + default: + return QueryaCodeLanguage.plain; + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final activeLang = _effectiveLanguage; + + return material.Container( + width: 340, + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + left: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Panel Header + material.Container( + height: 36, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.data_object_rounded, + size: 15, + color: cs.primary, + ), + const Gap(6), + material.Expanded( + child: Text( + '${widget.columnName}${widget.rowIndex != null ? ' [Row ${widget.rowIndex! + 1}]' : ''}', + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + ).small().semiBold(), + ), + material.IconButton( + icon: const material.Icon(material.Icons.close, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: widget.onClose, + ), + ], + ), + ), + + // Toolbar with language selector and actions + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: cs.background.withValues(alpha: 0.4), + child: material.Row( + children: [ + // Language Dropdown / Pill + material.DropdownButton( + value: _selectedLanguage, + isDense: true, + underline: const material.SizedBox(), + icon: const material.Icon(material.Icons.arrow_drop_down, size: 16), + style: TextStyle( + fontSize: 11, + color: cs.foreground, + fontWeight: FontWeight.w600, + ), + items: const [ + material.DropdownMenuItem( + value: ValuePanelLanguage.auto, + child: Text('Auto'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.json, + child: Text('JSON'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.xml, + child: Text('XML/HTML'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.sql, + child: Text('SQL'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.text, + child: Text('Plain Text'), + ), + ], + onChanged: (val) { + if (val != null) { + setState(() => _selectedLanguage = val); + _validateContent(); + } + }, + ), + const Gap(6), + if (activeLang == ValuePanelLanguage.json || activeLang == ValuePanelLanguage.xml) ...[ + material.TextButton( + onPressed: _formatCode, + style: material.TextButton.styleFrom( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + minimumSize: material.Size.zero, + tapTargetSize: material.MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Format').small(), + ), + const Gap(4), + material.TextButton( + onPressed: _minifyCode, + style: material.TextButton.styleFrom( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + minimumSize: material.Size.zero, + tapTargetSize: material.MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Minify').small(), + ), + ], + const material.Spacer(), + material.IconButton( + icon: material.Icon( + _wordWrap ? material.Icons.wrap_text : material.Icons.notes, + size: 14, + ), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: _wordWrap ? cs.primary : cs.mutedForeground, + onPressed: () => setState(() => _wordWrap = !_wordWrap), + ), + material.IconButton( + icon: const material.Icon(material.Icons.copy_rounded, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: () { + Clipboard.setData(ClipboardData(text: _controller.text)); + }, + ), + ], + ), + ), + + // Validation Error Banner (if any) + if (_validationError != null) + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: cs.destructive.withValues(alpha: 0.12), + child: material.Row( + children: [ + material.Icon( + material.Icons.warning_amber_rounded, + size: 14, + color: cs.destructive, + ), + const Gap(6), + material.Expanded( + child: Text( + _validationError!, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5, + color: cs.destructive, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + + // Code Editor Area with Syntax Highlighting + material.Expanded( + child: material.Padding( + padding: const material.EdgeInsets.all(6), + child: QueryaCodeEditor( + controller: _controller, + language: _toQueryaLanguage(activeLang), + enableHighlighting: true, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, + ), + ), + ), + + // Apply button if editable + if (widget.onUpdateValue != null) + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.ElevatedButton( + onPressed: () { + widget.onUpdateValue!(_controller.text); + }, + style: material.ElevatedButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.primaryForeground, + padding: const material.EdgeInsets.symmetric(vertical: 8), + minimumSize: material.Size.zero, + ), + child: const Text('Update Cell Value').small().bold(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/workspace/destructive_query_dialog.dart b/lib/features/workspace/destructive_query_dialog.dart new file mode 100644 index 0000000..30d59a3 --- /dev/null +++ b/lib/features/workspace/destructive_query_dialog.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/shared/widgets/app_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Opens a confirmation dialog when destructive SQL statements (DROP, TRUNCATE, etc.) +/// are detected before execution. +/// +/// Returns `true` if the user confirmed execution, or `false`/`null` if cancelled. +Future showDestructiveQueryDialog({ + required material.BuildContext context, + required DestructiveSqlInspectionResult result, + required String sql, + String? connectionName, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _DestructiveQueryDialog( + result: result, + sql: sql, + connectionName: connectionName, + ), + ); +} + +class _DestructiveQueryDialog extends material.StatefulWidget { + const _DestructiveQueryDialog({ + required this.result, + required this.sql, + this.connectionName, + }); + + final DestructiveSqlInspectionResult result; + final String sql; + final String? connectionName; + + @override + material.State<_DestructiveQueryDialog> createState() => + _DestructiveQueryDialogState(); +} + +class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialog> { + bool _acknowledged = false; + bool _copied = false; + + Future _copySql() async { + await Clipboard.setData(ClipboardData(text: widget.sql)); + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _copied = false); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final isCritical = widget.result.maxRiskLevel == 'CRITICAL'; + + return material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide( + color: cs.destructive.withValues(alpha: isDark ? 0.6 : 0.4), + width: 1.5, + ), + ), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 540, + maxWidth: 680, + minHeight: 440, + maxHeight: 580, + ), + child: material.SizedBox( + height: 540, + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: cs.destructive.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(8), + ), + child: material.Icon( + material.Icons.warning_amber_rounded, + size: 24, + color: cs.destructive, + ), + ), + const Gap(12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text( + isCritical + ? 'Critical Destructive Operation' + : 'Destructive Operation Detected', + ).semiBold().large(), + const Gap(2), + if (widget.connectionName != null) + Text( + 'Target connection: ${widget.connectionName}', + ).muted().small() + else + const Text( + 'This statement will permanently alter or delete database objects.', + ).muted().small(), + ], + ), + ), + ], + ), + const Gap(16), + + // Detected operations list + material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: cs.destructive.withValues( + alpha: isDark ? 0.12 : 0.06, + ), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.destructive.withValues( + alpha: isDark ? 0.35 : 0.25, + ), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + for (final op in widget.result.operations) ...[ + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.destructive, + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + op.type.label, + style: const TextStyle( + color: material.Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const Gap(8), + material.Expanded( + child: Text( + op.description, + style: material.TextStyle( + fontSize: 12, + color: cs.foreground, + fontWeight: material.FontWeight.w500, + ), + ), + ), + ], + ), + if (op != widget.result.operations.last) const Gap(8), + ], + ], + ), + ), + const Gap(14), + + // SQL Script Preview Header + material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + children: [ + const Text('QUERY PREVIEW').semiBold().xSmall().muted(), + material.InkWell( + onTap: _copySql, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + _copied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: 13, + color: _copied + ? material.Colors.green + : cs.mutedForeground, + ), + const Gap(4), + Text(_copied ? 'Copied' : 'Copy SQL').xSmall(), + ], + ), + ), + ), + ], + ), + const Gap(6), + + // SQL Code block container + material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: isDark + ? const material.Color(0xFF141416) + : const material.Color(0xFFF4F4F6), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.6), + ), + ), + child: material.SingleChildScrollView( + child: material.SelectableText( + widget.sql, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.45, + color: isDark + ? const material.Color(0xFFE2E8F0) + : const material.Color(0xFF1E293B), + ), + ), + ), + ), + ), + const Gap(14), + + // Confirmation Checkbox + material.Row( + children: [ + material.Checkbox( + value: _acknowledged, + onChanged: (v) => setState(() => _acknowledged = v ?? false), + ), + const Gap(8), + material.Expanded( + child: material.GestureDetector( + onTap: () => setState(() => _acknowledged = !_acknowledged), + child: const Text( + 'I understand that this query cannot be undone and may result in permanent data loss.', + ).small(), + ), + ), + ], + ), + const Gap(16), + + // Action buttons + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.end, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: _acknowledged + ? () => material.Navigator.of(context).pop(true) + : null, + leading: const material.Icon( + material.Icons.delete_forever_rounded, + size: 16, + ), + child: const Text('Execute Destructive Statement'), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/workspace/dml_preview_dialog.dart b/lib/features/workspace/dml_preview_dialog.dart new file mode 100644 index 0000000..3db5185 --- /dev/null +++ b/lib/features/workspace/dml_preview_dialog.dart @@ -0,0 +1,369 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/shared/widgets/app_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Opens the DML Preview and Confirmation dialog before executing staged changes. +/// +/// Returns `true` if user confirmed execution, or `false`/`null` if cancelled. +Future showDmlPreviewDialog({ + required material.BuildContext context, + required TableMutationPlan plan, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _DmlPreviewDialog(plan: plan), + ); +} + +class _DmlPreviewDialog extends material.StatefulWidget { + const _DmlPreviewDialog({required this.plan}); + + final TableMutationPlan plan; + + @override + material.State<_DmlPreviewDialog> createState() => _DmlPreviewDialogState(); +} + +class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { + bool _copied = false; + + int get _updateCount => + widget.plan.statements.where((s) => s.type == MutationType.update).length; + + int get _insertCount => + widget.plan.statements.where((s) => s.type == MutationType.insert).length; + + int get _deleteCount => + widget.plan.statements.where((s) => s.type == MutationType.delete).length; + + String get _dialectName { + switch (widget.plan.dialect) { + case SqlDialect.postgres: + return 'PostgreSQL'; + case SqlDialect.mysql: + return 'MySQL'; + case SqlDialect.sqlite: + return 'SQLite'; + } + } + + Future _copySql() async { + final sql = widget.plan.toTransactionSql(); + await Clipboard.setData(ClipboardData(text: sql)); + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _copied = false); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final sql = widget.plan.toTransactionSql(); + + return material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide(color: cs.border, width: 1), + ), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 540, + maxWidth: 680, + minHeight: 380, + maxHeight: 580, + ), + child: material.Padding( + padding: const material.EdgeInsets.all(18), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header Row + material.Row( + children: [ + material.Icon( + material.Icons.save_as_rounded, + size: 20, + color: cs.primary, + ), + const Gap(8), + const Text('Confirm Data Changes').semiBold().large(), + ], + ), + const Gap(4), + const Text( + 'Review pending SQL mutations before applying them to the database.', + ).muted().small(), + const Gap(14), + + // Metadata badges row + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + // Target Table Badge + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: cs.muted, + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.table_chart_outlined, + size: 14, + color: cs.foreground, + ), + const Gap(6), + Text( + widget.plan.schema != null && + widget.plan.schema!.isNotEmpty + ? '${widget.plan.schema}.${widget.plan.tableName}' + : widget.plan.tableName, + ).semiBold().small(), + ], + ), + ), + + // Dialect Badge + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.primary.withValues(alpha: 0.3), + ), + ), + child: Text( + _dialectName, + style: TextStyle( + color: cs.primary, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + + // Changes Breakdown Pills + if (_updateCount > 0) + _buildCountPill( + label: '$_updateCount UPDATE', + color: material.Colors.amber.shade700, + isDark: isDark, + ), + if (_insertCount > 0) + _buildCountPill( + label: '$_insertCount INSERT', + color: material.Colors.green.shade600, + isDark: isDark, + ), + if (_deleteCount > 0) + _buildCountPill( + label: '$_deleteCount DELETE', + color: material.Colors.red.shade600, + isDark: isDark, + ), + ], + ), + + // Warning banner if table lacks primary key and performs UPDATE/DELETE + if (!widget.plan.hasPrimaryKey && + widget.plan.statements.any( + (s) => + s.type == MutationType.update || + s.type == MutationType.delete, + )) ...[ + const Gap(10), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: material.BoxDecoration( + color: material.Colors.amber.withValues( + alpha: isDark ? 0.15 : 0.08, + ), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: material.Colors.amber.withValues(alpha: 0.4), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.warning_amber_rounded, + size: 15, + color: material.Colors.amber.shade700, + ), + const Gap(8), + material.Expanded( + child: const Text( + 'No Primary Key detected. WHERE clauses compare all columns (identical duplicate rows will be modified together).', + ).xSmall().muted(), + ), + ], + ), + ), + ], + const Gap(14), + + // SQL Preview code block header + material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + children: [ + const Text('TRANSACTION SCRIPT').semiBold().xSmall(), + material.InkWell( + onTap: _copySql, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + _copied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: 13, + color: _copied + ? material.Colors.green + : cs.mutedForeground, + ), + const Gap(4), + Text(_copied ? 'Copied' : 'Copy SQL').xSmall(), + ], + ), + ), + ), + ], + ), + const Gap(6), + + // SQL Code Preview Container + material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: isDark + ? const material.Color(0xFF141416) + : const material.Color(0xFFF4F4F6), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.6), + ), + ), + child: material.SingleChildScrollView( + child: material.SelectableText( + sql, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.45, + color: isDark + ? const material.Color(0xFFE2E8F0) + : const material.Color(0xFF1E293B), + ), + ), + ), + ), + ), + const Gap(12), + + // Atomic Notice + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.info_outline, + size: 15, + color: cs.mutedForeground, + ), + const Gap(8), + const material.Expanded( + child: Text( + 'All mutations will be executed atomically in a single transaction.', + ), + ), + ], + ), + ), + const Gap(16), + + // Actions + material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + const Gap(8), + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(true), + leading: const material.Icon( + material.Icons.save_outlined, + size: 16, + ), + child: const Text('Apply Changes'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + material.Widget _buildCountPill({ + required String label, + required material.Color color, + required bool isDark, + }) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: material.BoxDecoration( + color: color.withValues(alpha: isDark ? 0.18 : 0.12), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: color.withValues(alpha: isDark ? 0.45 : 0.3), + ), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ); + } +} diff --git a/lib/features/workspace/grid_cell_editor.dart b/lib/features/workspace/grid_cell_editor.dart new file mode 100644 index 0000000..8abd477 --- /dev/null +++ b/lib/features/workspace/grid_cell_editor.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/workspace/grid_data_type_validator.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Active inline editor widget for a data grid cell. +class GridCellEditor extends material.StatefulWidget { + const GridCellEditor({ + super.key, + required this.initialValue, + required this.width, + required this.height, + required this.onCommit, + required this.onCancel, + this.dataTypeName, + this.onOpenInspector, + }); + + final String initialValue; + final double width; + final double height; + final String? dataTypeName; + final void Function( + String value, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + bool movePrevRow, + }) onCommit; + final material.VoidCallback onCancel; + final material.VoidCallback? onOpenInspector; + + @override + material.State createState() => _GridCellEditorState(); +} + +class _GridCellEditorState extends material.State { + late final material.TextEditingController _controller; + final _focusNode = material.FocusNode(); + String? _validationError; + + @override + void initState() { + super.initState(); + final isNull = widget.initialValue == 'NULL'; + _controller = material.TextEditingController( + text: isNull ? '' : widget.initialValue, + ); + _controller.selection = material.TextSelection( + baseOffset: 0, + extentOffset: _controller.text.length, + ); + + _validate(); + _controller.addListener(_validate); + } + + void _validate() { + final error = GridDataTypeValidator.validate( + _controller.text, + dataTypeName: widget.dataTypeName, + ); + if (error != _validationError) { + setState(() { + _validationError = error; + }); + } + } + + @override + void dispose() { + _controller.removeListener(_validate); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _handleKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) return; + + final isShift = HardwareKeyboard.instance.isShiftPressed; + final isAlt = HardwareKeyboard.instance.isAltPressed; + final isControl = HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isMetaPressed; + + // Alt+N / Ctrl+Alt+N -> Set NULL + if (event.logicalKey == LogicalKeyboardKey.keyN && (isAlt || (isControl && isAlt))) { + widget.onCommit('NULL'); + return; + } + + // Alt+Enter or Ctrl+Enter -> Open Inspector + if ((event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter) && + (isAlt || isControl)) { + widget.onOpenInspector?.call(); + return; + } + + // Enter / Shift+Enter -> Commit and navigate row + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter) { + if (isShift) { + widget.onCommit(_controller.text, movePrevRow: true); + } else { + widget.onCommit(_controller.text, moveNextRow: true); + } + return; + } + + // Tab / Shift+Tab -> Commit and navigate col + if (event.logicalKey == LogicalKeyboardKey.tab) { + if (isShift) { + widget.onCommit(_controller.text, movePrevCol: true); + } else { + widget.onCommit(_controller.text, moveNextCol: true); + } + return; + } + + // Escape -> Cancel + if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onCancel(); + return; + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final hasError = _validationError != null; + + return material.Container( + width: widget.width, + height: widget.height, + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border.all( + color: hasError ? material.Colors.red.shade500 : cs.primary, + width: 1.5, + ), + ), + padding: const material.EdgeInsets.symmetric(horizontal: 6), + alignment: material.Alignment.centerLeft, + child: material.Row( + children: [ + material.Expanded( + child: material.KeyboardListener( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + autofocus: true, + child: material.TextField( + controller: _controller, + autofocus: true, + maxLines: 1, + style: const material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + isDense: true, + contentPadding: material.EdgeInsets.zero, + ), + onSubmitted: (value) { + widget.onCommit(value, moveNextRow: true); + }, + ), + ), + ), + if (hasError) + material.Tooltip( + message: _validationError!, + child: material.Padding( + padding: const material.EdgeInsets.only(left: 4), + child: material.Icon( + material.Icons.error_outline_rounded, + size: 14, + color: material.Colors.red.shade500, + ), + ), + ), + if (widget.onOpenInspector != null) + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + onTap: () { + widget.onOpenInspector!(); + }, + child: material.Padding( + padding: const material.EdgeInsets.only(left: 4), + child: material.Icon( + material.Icons.open_in_full_rounded, + size: 13, + color: cs.mutedForeground, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/workspace/grid_cell_popover_inspector.dart b/lib/features/workspace/grid_cell_popover_inspector.dart new file mode 100644 index 0000000..8f0d744 --- /dev/null +++ b/lib/features/workspace/grid_cell_popover_inspector.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Opens a rich modal inspector for viewing and editing large text or JSON values. +Future showGridCellInspectorDialog({ + required material.BuildContext context, + required String columnName, + required String initialValue, + int? rowIndex, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _GridCellInspectorDialog( + columnName: columnName, + initialValue: initialValue, + rowIndex: rowIndex, + ), + ); +} + +class _GridCellInspectorDialog extends material.StatefulWidget { + const _GridCellInspectorDialog({ + required this.columnName, + required this.initialValue, + this.rowIndex, + }); + + final String columnName; + final String initialValue; + final int? rowIndex; + + @override + material.State<_GridCellInspectorDialog> createState() => + _GridCellInspectorDialogState(); +} + +class _GridCellInspectorDialogState + extends material.State<_GridCellInspectorDialog> { + late final material.TextEditingController _controller; + bool _isNull = false; + + @override + void initState() { + super.initState(); + _isNull = widget.initialValue == 'NULL'; + _controller = material.TextEditingController( + text: _isNull ? '' : widget.initialValue, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _formatJson() { + try { + final parsed = jsonDecode(_controller.text); + final pretty = const JsonEncoder.withIndent(' ').convert(parsed); + setState(() { + _isNull = false; + _controller.text = pretty; + }); + } catch (_) { + // Not valid JSON, keep as is + } + } + + void _minifyJson() { + try { + final parsed = jsonDecode(_controller.text); + final compact = jsonEncode(parsed); + setState(() { + _isNull = false; + _controller.text = compact; + }); + } catch (_) { + // Not valid JSON, keep as is + } + } + + void _setNull() { + setState(() { + _isNull = true; + _controller.clear(); + }); + } + + bool _isJson() { + final text = _controller.text.trim(); + if ((text.startsWith('{') && text.endsWith('}')) || + (text.startsWith('[') && text.endsWith(']'))) { + try { + jsonDecode(text); + return true; + } catch (_) { + return false; + } + } + return false; + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final rowLabel = + widget.rowIndex != null ? ' (Row ${widget.rowIndex! + 1})' : ''; + + return material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide(color: cs.border, width: 1), + ), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 500, + maxWidth: 720, + minHeight: 380, + maxHeight: 560, + ), + child: material.Padding( + padding: const material.EdgeInsets.all(16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Row( + children: [ + material.Icon( + material.Icons.data_object_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: Text( + 'Edit ${widget.columnName}$rowLabel', + ).semiBold(), + ), + if (_isJson()) ...[ + GhostButton( + density: ButtonDensity.compact, + onPressed: _formatJson, + leading: const material.Icon( + material.Icons.format_align_left_rounded, + size: 14, + ), + child: const Text('Format JSON'), + ), + const Gap(6), + GhostButton( + density: ButtonDensity.compact, + onPressed: _minifyJson, + leading: const material.Icon( + material.Icons.compress_rounded, + size: 14, + ), + child: const Text('Minify'), + ), + const Gap(6), + ], + GhostButton( + density: ButtonDensity.compact, + onPressed: _isNull ? null : _setNull, + child: const Text('Set NULL'), + ), + ], + ), + const Gap(12), + + // Editor Body + material.Expanded( + child: material.Container( + decoration: material.BoxDecoration( + color: cs.background, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: _isNull + ? cs.primary.withValues(alpha: 0.5) + : cs.border, + width: 1, + ), + ), + child: _isNull + ? material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Value is NULL').muted().semiBold(), + const Gap(8), + GhostButton( + density: ButtonDensity.compact, + onPressed: () => setState(() => _isNull = false), + child: const Text('Enter text value'), + ), + ], + ), + ) + : material.TextField( + controller: _controller, + maxLines: null, + expands: true, + autofocus: true, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: material.EdgeInsets.all(12), + hintText: 'Enter cell value…', + ), + ), + ), + ), + const Gap(12), + + // Footer + material.Row( + children: [ + GhostButton( + density: ButtonDensity.compact, + onPressed: () { + Clipboard.setData( + ClipboardData(text: _isNull ? 'NULL' : _controller.text), + ); + }, + leading: const material.Icon( + material.Icons.copy_rounded, + size: 14, + ), + child: const Text('Copy'), + ), + const material.Spacer(), + OutlineButton( + density: ButtonDensity.compact, + onPressed: () => material.Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + const Gap(8), + PrimaryButton( + density: ButtonDensity.compact, + onPressed: () { + final result = _isNull ? 'NULL' : _controller.text; + material.Navigator.of(context).pop(result); + }, + child: const Text('Apply'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/workspace/grid_data_type_validator.dart b/lib/features/workspace/grid_data_type_validator.dart new file mode 100644 index 0000000..ae999c4 --- /dev/null +++ b/lib/features/workspace/grid_data_type_validator.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +/// Helper utility for validating cell values against SQL data types. +abstract final class GridDataTypeValidator { + static final _uuidRegex = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ); + static final _intRegex = RegExp(r'^-?\d+$'); + static final _numRegex = RegExp(r'^-?\d+(\.\d+)?$'); + static final _dateRegex = RegExp(r'^\d{4}-\d{2}-\d{2}$'); + static final _timestampRegex = RegExp( + r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}(:\d{2})?)?$', + ); + + /// Validates [value] against the column's [dataTypeName]. + /// Returns `null` if valid (or type is unknown), or an error description string if invalid. + static String? validate(String value, {String? dataTypeName}) { + if (value.isEmpty || value == 'NULL' || value == 'null') { + return null; + } + if (dataTypeName == null || dataTypeName.isEmpty) { + return null; + } + + final type = dataTypeName.toLowerCase().trim(); + + // Integer types + if (type.contains('int') || type == 'serial' || type == 'bigserial') { + if (!_intRegex.hasMatch(value.trim())) { + return 'Expected valid integer'; + } + return null; + } + + // Floating / Decimal / Numeric types + if (type.contains('num') || + type.contains('decimal') || + type.contains('float') || + type.contains('double') || + type == 'real') { + if (!_numRegex.hasMatch(value.trim())) { + return 'Expected valid number'; + } + return null; + } + + // Boolean types + if (type == 'bool' || type == 'boolean') { + final lower = value.toLowerCase().trim(); + if (lower != 'true' && + lower != 'false' && + lower != '1' && + lower != '0' && + lower != 't' && + lower != 'f') { + return 'Expected boolean (true/false/1/0)'; + } + return null; + } + + // UUID + if (type == 'uuid') { + if (!_uuidRegex.hasMatch(value.trim())) { + return 'Expected valid UUID (e.g. 123e4567-e89b-12d3-a456-426614174000)'; + } + return null; + } + + // JSON / JSONB + if (type.contains('json')) { + final trimmed = value.trim(); + if (!((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')))) { + return 'Expected valid JSON object or array'; + } + try { + jsonDecode(trimmed); + } catch (e) { + return 'Malformed JSON: $e'; + } + return null; + } + + // Date + if (type == 'date') { + if (!_dateRegex.hasMatch(value.trim())) { + return 'Expected date in YYYY-MM-DD format'; + } + return null; + } + + // Timestamp / DateTime + if (type.contains('timestamp') || + type.contains('datetime') || + type == 'timestamptz') { + if (!_timestampRegex.hasMatch(value.trim())) { + return 'Expected timestamp (YYYY-MM-DD HH:MM:SS)'; + } + return null; + } + + return null; + } +} diff --git a/lib/features/workspace/grid_filter_engine.dart b/lib/features/workspace/grid_filter_engine.dart new file mode 100644 index 0000000..5860f11 --- /dev/null +++ b/lib/features/workspace/grid_filter_engine.dart @@ -0,0 +1,631 @@ +/// Client-side filter engine for Data Grid. +/// +/// Evaluates complex multi-clause expressions with AND / OR / NOT, parentheses, +/// column predicates (`col = val`, `col > 10`, `col LIKE '%test%'`, `col IN ('a', 'b')`, +/// `col IS NULL`, `col BETWEEN x AND y`), and free-text substring search. +abstract final class GridFilterEngine { + /// Evaluates [filterText] against [rows] with respect to [columns]. + /// Returns the list of matching row indices. + static List filterRowIndices({ + required String filterText, + required List columns, + required List> rows, + }) { + final trimmed = filterText.trim(); + if (trimmed.isEmpty || columns.isEmpty || rows.isEmpty) { + return List.generate(rows.length, (i) => i); + } + + final lowerColumns = columns.map((c) => c.toLowerCase()).toList(); + + try { + final tokens = _FilterLexer.tokenize(trimmed, lowerColumns); + if (tokens.isEmpty) { + return List.generate(rows.length, (i) => i); + } + + final parser = _FilterParser(tokens); + final ast = parser.parse(); + + if (ast == null) { + return _fallbackSubstringFilter(trimmed, rows); + } + + final matchingIndices = []; + for (var r = 0; r < rows.length; r++) { + final row = rows[r]; + if (ast.evaluate(row, lowerColumns)) { + matchingIndices.add(r); + } + } + return matchingIndices; + } catch (_) { + // Graceful fallback to multi-term substring match if syntax has parse errors + return _fallbackSubstringFilter(trimmed, rows); + } + } + + static List _fallbackSubstringFilter(String input, List> rows) { + final terms = input.toLowerCase().split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList(); + if (terms.isEmpty) { + return List.generate(rows.length, (i) => i); + } + + final result = []; + for (var r = 0; r < rows.length; r++) { + final row = rows[r]; + var matchAll = true; + for (final term in terms) { + var termMatch = false; + for (var c = 0; c < row.length; c++) { + if (row[c].toLowerCase().contains(term)) { + termMatch = true; + break; + } + } + if (!termMatch) { + matchAll = false; + break; + } + } + if (matchAll) { + result.add(r); + } + } + return result; + } +} + +// ----------------------------------------------------------------------------- +// AST Nodes +// ----------------------------------------------------------------------------- + +abstract class _FilterAstNode { + const _FilterAstNode(); + bool evaluate(List row, List lowerColumns); +} + +class _AndNode extends _FilterAstNode { + const _AndNode(this.left, this.right); + final _FilterAstNode left; + final _FilterAstNode right; + + @override + bool evaluate(List row, List lowerColumns) { + return left.evaluate(row, lowerColumns) && right.evaluate(row, lowerColumns); + } +} + +class _OrNode extends _FilterAstNode { + const _OrNode(this.left, this.right); + final _FilterAstNode left; + final _FilterAstNode right; + + @override + bool evaluate(List row, List lowerColumns) { + return left.evaluate(row, lowerColumns) || right.evaluate(row, lowerColumns); + } +} + +class _NotNode extends _FilterAstNode { + const _NotNode(this.child); + final _FilterAstNode child; + + @override + bool evaluate(List row, List lowerColumns) { + return !child.evaluate(row, lowerColumns); + } +} + +class _PredicateNode extends _FilterAstNode { + const _PredicateNode({ + required this.colIndex, + required this.op, + required this.targetValue, + this.inValues = const [], + this.betweenMin, + this.betweenMax, + }); + + final int colIndex; + final String op; + final String targetValue; + final List inValues; + final String? betweenMin; + final String? betweenMax; + + @override + bool evaluate(List row, List lowerColumns) { + if (colIndex < 0 || colIndex >= row.length) return false; + final cellValue = row[colIndex]; + final isNull = cellValue == 'NULL' || cellValue == 'null' || cellValue.isEmpty; + final upperOp = op.toUpperCase().trim(); + + // IS NULL / IS NOT NULL + if (upperOp == 'IS NULL') { + return isNull; + } + if (upperOp == 'IS NOT NULL') { + return !isNull; + } + + // IN / NOT IN + if (upperOp == 'IN') { + final lowerCell = cellValue.toLowerCase().trim(); + return inValues.any((v) => v.toLowerCase().trim() == lowerCell); + } + if (upperOp == 'NOT IN') { + final lowerCell = cellValue.toLowerCase().trim(); + return !inValues.any((v) => v.toLowerCase().trim() == lowerCell); + } + + // BETWEEN x AND y + if (upperOp == 'BETWEEN' && betweenMin != null && betweenMax != null) { + final numCell = double.tryParse(cellValue.trim()); + final numMin = double.tryParse(betweenMin!.trim()); + final numMax = double.tryParse(betweenMax!.trim()); + if (numCell != null && numMin != null && numMax != null) { + return numCell >= numMin && numCell <= numMax; + } + return cellValue.compareTo(betweenMin!) >= 0 && cellValue.compareTo(betweenMax!) <= 0; + } + + // LIKE / NOT LIKE + if (upperOp == 'LIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: true); + return regex.hasMatch(cellValue); + } + if (upperOp == 'NOT LIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: true); + return !regex.hasMatch(cellValue); + } + + // ILIKE / NOT ILIKE + if (upperOp == 'ILIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: false); + return regex.hasMatch(cellValue); + } + if (upperOp == 'NOT ILIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: false); + return !regex.hasMatch(cellValue); + } + + final lowerCell = cellValue.toLowerCase(); + final lowerTarget = targetValue.toLowerCase(); + + // Numeric comparison if both values can be parsed as numbers + final numCell = double.tryParse(cellValue.trim()); + final numTarget = double.tryParse(targetValue.trim()); + + if (numCell != null && numTarget != null) { + switch (op) { + case '=': + case '==': + case ':': + return (numCell - numTarget).abs() < 1e-9; + case '!=': + case '<>': + return (numCell - numTarget).abs() >= 1e-9; + case '>': + return numCell > numTarget; + case '>=': + return numCell >= numTarget; + case '<': + return numCell < numTarget; + case '<=': + return numCell <= numTarget; + } + } + + // String / Lexicographic comparison + switch (op) { + case '=': + case '==': + return lowerCell == lowerTarget; + case ':': + return lowerCell.contains(lowerTarget); + case '!=': + case '<>': + return lowerCell != lowerTarget; + case '>': + return lowerCell.compareTo(lowerTarget) > 0; + case '>=': + return lowerCell.compareTo(lowerTarget) >= 0; + case '<': + return lowerCell.compareTo(lowerTarget) < 0; + case '<=': + return lowerCell.compareTo(lowerTarget) <= 0; + default: + return lowerCell.contains(lowerTarget); + } + } + + static RegExp _likeToRegExp(String pattern, {required bool caseSensitive}) { + final buffer = StringBuffer('^'); + for (var i = 0; i < pattern.length; i++) { + final char = pattern[i]; + if (char == '%') { + buffer.write('.*'); + } else if (char == '_') { + buffer.write('.'); + } else { + buffer.write(RegExp.escape(char)); + } + } + buffer.write(r'$'); + return RegExp(buffer.toString(), caseSensitive: caseSensitive); + } +} + +class _FreeTextNode extends _FilterAstNode { + const _FreeTextNode(this.term); + final String term; + + @override + bool evaluate(List row, List lowerColumns) { + final lowerTerm = term.toLowerCase(); + for (var c = 0; c < row.length; c++) { + if (row[c].toLowerCase().contains(lowerTerm)) { + return true; + } + } + return false; + } +} + +// ----------------------------------------------------------------------------- +// Lexer +// ----------------------------------------------------------------------------- + +enum _TokenType { + and, + or, + not, + lparen, + rparen, + predicate, + text, +} + +class _FilterToken { + const _FilterToken(this.type, {this.value = '', this.predicate}); + final _TokenType type; + final String value; + final _PredicateNode? predicate; +} + +abstract final class _FilterLexer { + static List<_FilterToken> tokenize(String input, List lowerColumns) { + final tokens = <_FilterToken>[]; + var i = 0; + + while (i < input.length) { + // Skip whitespace + if (input[i].trim().isEmpty) { + i++; + continue; + } + + // Check for extended predicates with keywords (IS NULL, IS NOT NULL, LIKE, ILIKE, IN, BETWEEN) + final remaining = input.substring(i); + final kwPredicate = _tryMatchKeywordPredicate(remaining, lowerColumns); + if (kwPredicate != null) { + tokens.add(_FilterToken(_TokenType.predicate, predicate: kwPredicate.node)); + i += kwPredicate.consumedChars; + continue; + } + + // Parentheses + if (input[i] == '(') { + tokens.add(const _FilterToken(_TokenType.lparen, value: '(')); + i++; + continue; + } + if (input[i] == ')') { + tokens.add(const _FilterToken(_TokenType.rparen, value: ')')); + i++; + continue; + } + + // Read next chunk/word until whitespace or parenthesis + final start = i; + while (i < input.length && + input[i].trim().isNotEmpty && + input[i] != '(' && + input[i] != ')') { + // Handle quoted literals inside words + if (input[i] == '\'' || input[i] == '"') { + final quote = input[i]; + i++; + while (i < input.length) { + if (input[i] == '\\' && i + 1 < input.length) { + i += 2; + } else if (input[i] == quote) { + if (i + 1 < input.length && input[i + 1] == quote) { + // SQL-style doubled quote escape: '' + i += 2; + } else { + i++; // closing quote + break; + } + } else { + i++; + } + } + } else { + i++; + } + } + + var word = input.substring(start, i).trim(); + if (word.isEmpty) continue; + + // Check logical operators + final upper = word.toUpperCase(); + if (upper == 'AND' || word == '&&') { + tokens.add(const _FilterToken(_TokenType.and, value: 'AND')); + continue; + } + if (upper == 'OR' || word == '||') { + tokens.add(const _FilterToken(_TokenType.or, value: 'OR')); + continue; + } + if (upper == 'NOT' || word == '!') { + tokens.add(const _FilterToken(_TokenType.not, value: 'NOT')); + continue; + } + + // Check if this token or upcoming sequence forms a predicate: col OP val + final predicate = _tryExtractPredicate(word, lowerColumns); + if (predicate != null) { + tokens.add(_FilterToken(_TokenType.predicate, predicate: predicate)); + continue; + } + + // If word is just a column name and the NEXT word is an operator (e.g. "amount", ">", "100") + final colIdx = lowerColumns.indexOf(word.toLowerCase()); + if (colIdx != -1) { + final rem = input.substring(i).trimLeft(); + final opMatch = RegExp(r'^(>=|<=|!=|<>|==|=|>|<|:)\s*([^\s()]+)') + .firstMatch(rem); + if (opMatch != null) { + final op = opMatch.group(1)!; + var val = opMatch.group(2)!; + val = _stripQuotes(val); + tokens.add( + _FilterToken( + _TokenType.predicate, + predicate: _PredicateNode( + colIndex: colIdx, + op: op, + targetValue: val, + ), + ), + ); + i += input.substring(i).indexOf(opMatch.group(0)!) + + opMatch.group(0)!.length; + continue; + } + } + + word = _stripQuotes(word); + tokens.add(_FilterToken(_TokenType.text, value: word)); + } + + return tokens; + } + + static ({_PredicateNode node, int consumedChars})? _tryMatchKeywordPredicate( + String remaining, + List lowerColumns, + ) { + // 1. IS NULL / IS NOT NULL (e.g. "status IS NULL", "email IS NOT NULL") + final isNullMatch = RegExp(r'^([a-zA-Z_]\w*)\s+IS\s+(NOT\s+)?NULL\b', caseSensitive: false) + .firstMatch(remaining); + if (isNullMatch != null) { + final colName = isNullMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final isNot = isNullMatch.group(2) != null; + return ( + node: _PredicateNode( + colIndex: colIdx, + op: isNot ? 'IS NOT NULL' : 'IS NULL', + targetValue: '', + ), + consumedChars: isNullMatch.group(0)!.length, + ); + } + } + + // 2. IN / NOT IN (e.g. "status IN ('ACTIVE', 'PENDING')", "id NOT IN (1, 2, 3)") + final inMatch = RegExp(r'^([a-zA-Z_]\w*)\s+(NOT\s+)?IN\s*\(([^)]+)\)', caseSensitive: false) + .firstMatch(remaining); + if (inMatch != null) { + final colName = inMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final isNot = inMatch.group(2) != null; + final listStr = inMatch.group(3)!; + final items = listStr + .split(',') + .map((s) => _stripQuotes(s.trim())) + .where((s) => s.isNotEmpty) + .toList(); + return ( + node: _PredicateNode( + colIndex: colIdx, + op: isNot ? 'NOT IN' : 'IN', + targetValue: '', + inValues: items, + ), + consumedChars: inMatch.group(0)!.length, + ); + } + } + + // 3. BETWEEN x AND y (e.g. "amount BETWEEN 10 AND 100") + final betweenMatch = RegExp(r'^([a-zA-Z_]\w*)\s+BETWEEN\s+([^\s]+)\s+AND\s+([^\s()]+)', caseSensitive: false) + .firstMatch(remaining); + if (betweenMatch != null) { + final colName = betweenMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final minVal = _stripQuotes(betweenMatch.group(2)!.trim()); + final maxVal = _stripQuotes(betweenMatch.group(3)!.trim()); + return ( + node: _PredicateNode( + colIndex: colIdx, + op: 'BETWEEN', + targetValue: '', + betweenMin: minVal, + betweenMax: maxVal, + ), + consumedChars: betweenMatch.group(0)!.length, + ); + } + } + + // 4. LIKE / ILIKE / NOT LIKE / NOT ILIKE (e.g. "name LIKE '%John%'", "email ILIKE '%.org'") + final likeMatch = RegExp(r'^([a-zA-Z_]\w*)\s+(NOT\s+)?(ILIKE|LIKE)\s+([^\s()]+)', caseSensitive: false) + .firstMatch(remaining); + if (likeMatch != null) { + final colName = likeMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final isNot = likeMatch.group(2) != null; + final likeType = likeMatch.group(3)!.toUpperCase(); + final pattern = _stripQuotes(likeMatch.group(4)!.trim()); + final op = isNot ? 'NOT $likeType' : likeType; + return ( + node: _PredicateNode( + colIndex: colIdx, + op: op, + targetValue: pattern, + ), + consumedChars: likeMatch.group(0)!.length, + ); + } + } + + return null; + } + + static _PredicateNode? _tryExtractPredicate( + String token, + List lowerColumns, + ) { + const ops = ['>=', '<=', '!=', '<>', '==', '=', '>', '<', ':']; + for (final op in ops) { + final parts = token.split(op); + if (parts.length == 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + final colCandidate = parts[0].trim().toLowerCase(); + final colIdx = lowerColumns.indexOf(colCandidate); + if (colIdx != -1) { + final val = _stripQuotes(parts[1].trim()); + return _PredicateNode( + colIndex: colIdx, + op: op, + targetValue: val, + ); + } + } + } + return null; + } + + static String _stripQuotes(String s) { + if ((s.startsWith("'") && s.endsWith("'")) || + (s.startsWith('"') && s.endsWith('"'))) { + if (s.length >= 2) { + return s + .substring(1, s.length - 1) + .replaceAll("''", "'") + .replaceAll(r"\'", "'") + .replaceAll(r'\"', '"'); + } + } + return s; + } +} + +// ----------------------------------------------------------------------------- +// Parser +// ----------------------------------------------------------------------------- + +class _FilterParser { + _FilterParser(this.tokens); + final List<_FilterToken> tokens; + int _pos = 0; + + _FilterAstNode? parse() { + if (tokens.isEmpty) return null; + return _parseOr(); + } + + _FilterAstNode _parseOr() { + var node = _parseAnd(); + while (_match(_TokenType.or)) { + final right = _parseAnd(); + node = _OrNode(node, right); + } + return node; + } + + _FilterAstNode _parseAnd() { + var node = _parseUnary(); + while (_match(_TokenType.and) || _isImplicitAnd()) { + final right = _parseUnary(); + node = _AndNode(node, right); + } + return node; + } + + bool _isImplicitAnd() { + if (_pos >= tokens.length) return false; + final type = tokens[_pos].type; + return type == _TokenType.predicate || + type == _TokenType.text || + type == _TokenType.lparen || + type == _TokenType.not; + } + + _FilterAstNode _parseUnary() { + if (_match(_TokenType.not)) { + return _NotNode(_parseUnary()); + } + return _parsePrimary(); + } + + _FilterAstNode _parsePrimary() { + if (_match(_TokenType.lparen)) { + final node = _parseOr(); + _consume(_TokenType.rparen); + return node; + } + + if (_pos < tokens.length) { + final token = tokens[_pos++]; + if (token.type == _TokenType.predicate && token.predicate != null) { + return token.predicate!; + } + return _FreeTextNode(token.value); + } + + return const _FreeTextNode(''); + } + + bool _match(_TokenType type) { + if (_pos < tokens.length && tokens[_pos].type == type) { + _pos++; + return true; + } + return false; + } + + void _consume(_TokenType type) { + if (_pos < tokens.length && tokens[_pos].type == type) { + _pos++; + } + } +} diff --git a/lib/features/workspace/grid_groupings_engine.dart b/lib/features/workspace/grid_groupings_engine.dart new file mode 100644 index 0000000..e117df1 --- /dev/null +++ b/lib/features/workspace/grid_groupings_engine.dart @@ -0,0 +1,239 @@ +import 'package:flutter/foundation.dart'; + +/// Aggregation operation to perform on groups. +enum GroupingAggType { + count('COUNT'), + sum('SUM'), + avg('AVG'), + min('MIN'), + max('MAX'); + + const GroupingAggType(this.label); + final String label; +} + +/// Sort criteria for grouping categories. +enum GroupSortBy { + count('Count'), + key('Group Key'), + aggregate('Aggregate'); + + const GroupSortBy(this.label); + final String label; +} + +/// Configuration for group aggregations. +@immutable +class GroupAggregationConfig { + const GroupAggregationConfig({ + this.aggType = GroupingAggType.count, + this.targetColIndex, + }); + + final GroupingAggType aggType; + final int? targetColIndex; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupAggregationConfig && + aggType == other.aggType && + targetColIndex == other.targetColIndex; + + @override + int get hashCode => Object.hash(aggType, targetColIndex); +} + +/// Represents an aggregated group in Groupings / Pivot View (supports nested sub-groups). +@immutable +class GroupedCategory { + const GroupedCategory({ + required this.groupKey, + required this.count, + required this.percentage, + required this.rows, + this.aggValue, + this.subGroups = const [], + this.level = 0, + }); + + final String groupKey; + final int count; + final double percentage; + final List> rows; + final double? aggValue; + final List subGroups; + final int level; + + bool get hasSubGroups => subGroups.isNotEmpty; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupedCategory && + groupKey == other.groupKey && + count == other.count && + percentage == other.percentage && + aggValue == other.aggValue && + level == other.level; + + @override + int get hashCode => Object.hash(groupKey, count, percentage, aggValue, level); +} + +/// Engine to construct multi-column pivot / hierarchical grouping breakdown tables. +abstract final class GridGroupingsEngine { + /// Builds multi-level groups by [groupColIndices] with optional aggregation and sorting. + static List buildGroups({ + required List groupColIndices, + required List> rows, + GroupAggregationConfig aggConfig = const GroupAggregationConfig(), + GroupSortBy sortBy = GroupSortBy.count, + bool sortAscending = false, + }) { + if (rows.isEmpty || groupColIndices.isEmpty) return const []; + + return _buildSubGroups( + groupColIndices: groupColIndices, + levelIndex: 0, + rows: rows, + totalRootRows: rows.length, + aggConfig: aggConfig, + sortBy: sortBy, + sortAscending: sortAscending, + ); + } + + static List _buildSubGroups({ + required List groupColIndices, + required int levelIndex, + required List> rows, + required int totalRootRows, + required GroupAggregationConfig aggConfig, + required GroupSortBy sortBy, + required bool sortAscending, + }) { + if (levelIndex >= groupColIndices.length || rows.isEmpty) return const []; + + final colIndex = groupColIndices[levelIndex]; + final map = >>{}; + + for (final row in rows) { + final key = colIndex < row.length ? row[colIndex] : 'NULL'; + final effectiveKey = key.isEmpty ? '(Empty)' : key; + map.putIfAbsent(effectiveKey, () => []).add(row); + } + + final categories = []; + final hasNextLevel = levelIndex + 1 < groupColIndices.length; + + map.forEach((key, categoryRows) { + final count = categoryRows.length; + final pct = totalRootRows > 0 ? (count / totalRootRows) * 100 : 0.0; + final agg = _computeAggregation(categoryRows, aggConfig); + + List subGroups = const []; + if (hasNextLevel) { + subGroups = _buildSubGroups( + groupColIndices: groupColIndices, + levelIndex: levelIndex + 1, + rows: categoryRows, + totalRootRows: totalRootRows, + aggConfig: aggConfig, + sortBy: sortBy, + sortAscending: sortAscending, + ); + } + + categories.add( + GroupedCategory( + groupKey: key, + count: count, + percentage: pct, + rows: categoryRows, + aggValue: agg, + subGroups: subGroups, + level: levelIndex, + ), + ); + }); + + // Sorting + categories.sort((a, b) { + int cmp; + switch (sortBy) { + case GroupSortBy.count: + cmp = a.count.compareTo(b.count); + break; + case GroupSortBy.key: + cmp = a.groupKey.compareTo(b.groupKey); + break; + case GroupSortBy.aggregate: + final aVal = a.aggValue ?? (a.count.toDouble()); + final bVal = b.aggValue ?? (b.count.toDouble()); + cmp = aVal.compareTo(bVal); + break; + } + return sortAscending ? cmp : -cmp; + }); + + return categories; + } + + static double? _computeAggregation( + List> rows, + GroupAggregationConfig config, + ) { + if (config.aggType == GroupingAggType.count) { + return rows.length.toDouble(); + } + if (config.targetColIndex == null) return null; + + final targetCol = config.targetColIndex!; + final numbers = []; + + for (final row in rows) { + if (targetCol < row.length) { + final val = row[targetCol].replaceAll(',', '').trim(); + final parsed = double.tryParse(val); + if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { + numbers.add(parsed); + } + } + } + + if (numbers.isEmpty) return null; + + switch (config.aggType) { + case GroupingAggType.count: + return numbers.length.toDouble(); + case GroupingAggType.sum: + return numbers.reduce((a, b) => a + b); + case GroupingAggType.avg: + return numbers.reduce((a, b) => a + b) / numbers.length; + case GroupingAggType.min: + return numbers.reduce((a, b) => a < b ? a : b); + case GroupingAggType.max: + return numbers.reduce((a, b) => a > b ? a : b); + } + } + + /// Exports pivot summary to CSV format. + static String exportPivotToCsv({ + required List groups, + required String groupByColumnName, + GroupAggregationConfig aggConfig = const GroupAggregationConfig(), + }) { + final buffer = StringBuffer(); + buffer.writeln('Group Key,Count,Percentage,Aggregate'); + + for (final g in groups) { + final aggStr = g.aggValue != null ? g.aggValue!.toStringAsFixed(2) : '-'; + buffer.writeln( + '"${g.groupKey.replaceAll('"', '""')}",${g.count},${g.percentage.toStringAsFixed(2)}%,$aggStr', + ); + } + + return buffer.toString(); + } +} diff --git a/lib/features/workspace/grid_selection_calc_engine.dart b/lib/features/workspace/grid_selection_calc_engine.dart new file mode 100644 index 0000000..c7a111f --- /dev/null +++ b/lib/features/workspace/grid_selection_calc_engine.dart @@ -0,0 +1,248 @@ +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; + +/// Aggregated statistical results for a selection of grid cell values. +@immutable +class GridCalcStats { + const GridCalcStats({ + required this.totalCount, + required this.distinctCount, + required this.numericCount, + required this.nullCount, + this.sum, + this.average, + this.median, + this.min, + this.max, + this.stdDev, + }); + + static const empty = GridCalcStats( + totalCount: 0, + distinctCount: 0, + numericCount: 0, + nullCount: 0, + ); + + final int totalCount; + final int distinctCount; + final int numericCount; + final int nullCount; + final double? sum; + final double? average; + final double? median; + final double? min; + final double? max; + final double? stdDev; + + bool get hasNumericStats => numericCount > 0 && sum != null; + + /// Formats all available statistics into a single copyable summary string. + String toSummaryString() { + final parts = [ + 'Count: $totalCount', + 'Distinct: $distinctCount', + ]; + if (nullCount > 0) { + parts.add('NULLs: $nullCount'); + } + if (hasNumericStats) { + parts.add('Sum: ${GridSelectionCalcEngine.formatNum(sum)}'); + parts.add('Avg: ${GridSelectionCalcEngine.formatNum(average)}'); + if (median != null) { + parts.add('Median: ${GridSelectionCalcEngine.formatNum(median)}'); + } + parts.add('Min: ${GridSelectionCalcEngine.formatNum(min)}'); + parts.add('Max: ${GridSelectionCalcEngine.formatNum(max)}'); + if (stdDev != null) { + parts.add('StdDev: ${GridSelectionCalcEngine.formatNum(stdDev)}'); + } + } + return parts.join(' | '); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GridCalcStats && + totalCount == other.totalCount && + distinctCount == other.distinctCount && + numericCount == other.numericCount && + nullCount == other.nullCount && + sum == other.sum && + average == other.average && + median == other.median && + min == other.min && + max == other.max && + stdDev == other.stdDev; + + @override + int get hashCode => Object.hash( + totalCount, + distinctCount, + numericCount, + nullCount, + sum, + average, + median, + min, + max, + stdDev, + ); +} + +/// Calculation engine for computing stats (Count, Distinct, Sum, Avg, Median, Min, Max, StdDev) on grid selections. +abstract final class GridSelectionCalcEngine { + /// Computes statistics for a list of string cell values. + static GridCalcStats compute(List values) { + if (values.isEmpty) return GridCalcStats.empty; + + final total = values.length; + var nulls = 0; + final distinctSet = {}; + final numericList = []; + var sum = 0.0; + double? minVal; + double? maxVal; + + for (final raw in values) { + final trimmed = raw.trim(); + if (trimmed == 'NULL' || trimmed.isEmpty) { + nulls++; + continue; + } + + distinctSet.add(trimmed); + + // Try parsing numeric values (stripping commas if present) + final normalized = trimmed.replaceAll(',', ''); + final parsed = double.tryParse(normalized); + if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { + numericList.add(parsed); + sum += parsed; + if (minVal == null || parsed < minVal) { + minVal = parsed; + } + if (maxVal == null || parsed > maxVal) { + maxVal = parsed; + } + } + } + + final numericCount = numericList.length; + final avg = numericCount > 0 ? sum / numericCount : null; + + // Calculate median using QuickSelect (O(N)) for large datasets (> 500 elements) or fast sort (<= 500) + double? median; + if (numericCount > 0) { + final mid = numericCount ~/ 2; + if (numericCount <= 500) { + numericList.sort(); + if (numericCount.isOdd) { + median = numericList[mid]; + } else { + median = (numericList[mid - 1] + numericList[mid]) / 2.0; + } + } else { + if (numericCount.isOdd) { + median = _quickSelect(numericList, 0, numericCount - 1, mid); + } else { + final m1 = _quickSelect(numericList, 0, numericCount - 1, mid - 1); + final m2 = _quickSelect(numericList, mid, numericCount - 1, mid); + median = (m1 + m2) / 2.0; + } + } + } + + // Calculate standard deviation + double? stdDev; + if (numericCount > 1 && avg != null) { + var varianceSum = 0.0; + for (final n in numericList) { + varianceSum += math.pow(n - avg, 2); + } + stdDev = math.sqrt(varianceSum / (numericCount - 1)); + } + + return GridCalcStats( + totalCount: total, + distinctCount: distinctSet.length, + numericCount: numericCount, + nullCount: nulls, + sum: numericCount > 0 ? sum : null, + average: avg, + median: median, + min: minVal, + max: maxVal, + stdDev: stdDev, + ); + } + + /// Linear-time QuickSelect algorithm to find the k-th smallest element. + static double _quickSelect(List list, int left, int right, int k) { + while (left < right) { + if (right - left < 10) { + // Insertion sort for small sub-arrays + for (var i = left + 1; i <= right; i++) { + final temp = list[i]; + var j = i - 1; + while (j >= left && list[j] > temp) { + list[j + 1] = list[j]; + j--; + } + list[j + 1] = temp; + } + return list[k]; + } + + final pivotIndex = _partition(list, left, right); + if (pivotIndex == k) { + return list[k]; + } else if (pivotIndex > k) { + right = pivotIndex - 1; + } else { + left = pivotIndex + 1; + } + } + return list[left]; + } + + static int _partition(List list, int left, int right) { + // Median-of-three pivot selection for optimal partitioning + final mid = left + ((right - left) >> 1); + if (list[left] > list[mid]) _swap(list, left, mid); + if (list[left] > list[right]) _swap(list, left, right); + if (list[mid] > list[right]) _swap(list, mid, right); + + final pivotValue = list[mid]; + _swap(list, mid, right - 1); + var i = left; + var j = right - 1; + + while (true) { + while (list[++i] < pivotValue) {} + while (list[--j] > pivotValue) {} + if (i >= j) break; + _swap(list, i, j); + } + _swap(list, i, right - 1); + return i; + } + + static void _swap(List list, int i, int j) { + final temp = list[i]; + list[i] = list[j]; + list[j] = temp; + } + + /// Formats a numeric stat cleanly for UI display. + static String formatNum(double? val) { + if (val == null) return '-'; + if (val == val.roundToDouble()) { + return val.toInt().toString(); + } + // Limit decimal precision to 4 decimal places + final formatted = val.toStringAsFixed(4); + return formatted.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); + } +} diff --git a/lib/features/workspace/query_editor_tab.dart b/lib/features/workspace/query_editor_tab.dart new file mode 100644 index 0000000..20b33eb --- /dev/null +++ b/lib/features/workspace/query_editor_tab.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart' as material + show EdgeInsets, Padding, TextEditingController; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class QueryEditorTab extends StatelessWidget { + const QueryEditorTab({ + super.key, + this.controller, + this.fontSize = 13, + }); + + /// When null, an internal controller is used (standalone workspace without PG). + final material.TextEditingController? controller; + + /// Monospace font size in logical pixels. + final double fontSize; + + @override + Widget build(BuildContext context) { + return material.Padding( + padding: const material.EdgeInsets.all(12), + child: SqlEditorChrome( + child: QueryaCodeEditor( + controller: controller, + language: QueryaCodeLanguage.sql, + fontSize: fontSize, + ), + ), + ); + } +} diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart new file mode 100644 index 0000000..c10b043 --- /dev/null +++ b/lib/features/workspace/result_grid_view.dart @@ -0,0 +1,1433 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' + show Clipboard, ClipboardData, HardwareKeyboard, LogicalKeyboardKey; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/ui/querya_tooltip.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/grid_cell_editor.dart'; +import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Layout metrics for [VirtualResultGrid]. +abstract final class ResultGridMetrics { + static const double rowHeight = 36; + static const double headerHeight = 36; + static const double minColumnWidth = 120; + 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]. +List computeResultGridColumnWidths({ + required List columns, + required List> rows, + double minWidth = ResultGridMetrics.minColumnWidth, + double maxWidth = ResultGridMetrics.maxColumnWidth, + int sampleRowCount = ResultGridMetrics.columnWidthSampleRows, +}) { + if (columns.isEmpty) return const []; + + final widths = List.filled(columns.length, minWidth); + final sample = rows.length < sampleRowCount ? rows.length : sampleRowCount; + + for (var c = 0; c < columns.length; c++) { + var maxChars = columns[c].length; + for (var r = 0; r < sample; r++) { + if (c < rows[r].length && rows[r][c].length > maxChars) { + maxChars = rows[r][c].length; + } + } + widths[c] = (maxChars * 7.5 + 24).clamp(minWidth, maxWidth); + } + return widths; +} + +/// 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]: smallest index where columnOffsets[first + 1] > start + var first = 0; + var low = 0; + var high = n - 1; + while (low <= high) { + final mid = (low + high) ~/ 2; + if (columnOffsets[mid + 1] > start) { + first = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + // Last column with any pixel before [end]: largest index where columnOffsets[last] < end + var last = n - 1; + low = 0; + high = n - 1; + while (low <= high) { + final mid = (low + high) ~/ 2; + if (columnOffsets[mid] < end) { + last = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + 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], + ); +} + +/// Coordinate of a cell in [VirtualResultGrid]. +@immutable +class ResultGridCellCoordinate { + const ResultGridCellCoordinate(this.row, this.column); + + final int row; + final int column; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResultGridCellCoordinate && + row == other.row && + column == other.column; + + @override + int get hashCode => Object.hash(row, column); +} + +/// Rectangular cell selection range in [VirtualResultGrid]. +@immutable +class ResultGridSelection { + const ResultGridSelection({ + required this.startRow, + required this.startColumn, + required this.endRow, + required this.endColumn, + }); + + factory ResultGridSelection.fromPoints({ + required ResultGridCellCoordinate anchor, + required ResultGridCellCoordinate focus, + }) { + final minR = anchor.row < focus.row ? anchor.row : focus.row; + final maxR = anchor.row > focus.row ? anchor.row : focus.row; + final minC = anchor.column < focus.column ? anchor.column : focus.column; + final maxC = anchor.column > focus.column ? anchor.column : focus.column; + return ResultGridSelection( + startRow: minR, + startColumn: minC, + endRow: maxR, + endColumn: maxC, + ); + } + + final int startRow; + final int startColumn; + final int endRow; + final int endColumn; + + bool contains(int row, int column) => + row >= startRow && + row <= endRow && + column >= startColumn && + column <= endColumn; + + int get rowCount => endRow - startRow + 1; + int get columnCount => endColumn - startColumn + 1; + + /// Formats selected cell values as a Tab-Separated Values (TSV) string. + String toTsv(List> rows) { + if (rows.isEmpty) return ''; + final buffer = StringBuffer(); + for (var r = startRow; r <= endRow; r++) { + if (r < 0 || r >= rows.length) continue; + final rowData = rows[r]; + final cells = []; + for (var c = startColumn; c <= endColumn; c++) { + cells.add(c < rowData.length ? rowData[c] : ''); + } + buffer.writeln(cells.join('\t')); + } + return buffer.toString().trimRight(); + } + + /// Formats selected cell values as a CSV string. + String toCsv(List> rows) { + if (rows.isEmpty) return ''; + final buffer = StringBuffer(); + for (var r = startRow; r <= endRow; r++) { + if (r < 0 || r >= rows.length) continue; + final rowData = rows[r]; + final cells = []; + for (var c = startColumn; c <= endColumn; c++) { + final val = c < rowData.length ? rowData[c] : ''; + if (val.contains(',') || val.contains('"') || val.contains('\n')) { + cells.add('"${val.replaceAll('"', '""')}"'); + } else { + cells.add(val); + } + } + buffer.writeln(cells.join(',')); + } + return buffer.toString().trimRight(); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResultGridSelection && + startRow == other.startRow && + startColumn == other.startColumn && + endRow == other.endRow && + endColumn == other.endColumn; + + @override + int get hashCode => Object.hash(startRow, startColumn, endRow, endColumn); +} + +/// Sorting direction for [VirtualResultGrid]. +enum ResultGridSortOrder { + ascending, + descending, +} + +enum _SortKeyType { nullOrEmpty, numeric, dateTime, string } + +class _SortKey implements Comparable<_SortKey> { + final _SortKeyType type; + final num? numVal; + final DateTime? dtVal; + final String strLower; + final String strRaw; + + _SortKey._({ + required this.type, + this.numVal, + this.dtVal, + this.strLower = '', + this.strRaw = '', + }); + + factory _SortKey.parse(String val) { + if (val == 'NULL' || val.isEmpty) { + return _SortKey._(type: _SortKeyType.nullOrEmpty); + } + final n = num.tryParse(val); + if (n != null) { + return _SortKey._(type: _SortKeyType.numeric, numVal: n, strRaw: val); + } + final dt = DateTime.tryParse(val); + if (dt != null) { + return _SortKey._(type: _SortKeyType.dateTime, dtVal: dt, strRaw: val); + } + return _SortKey._( + type: _SortKeyType.string, + strLower: val.toLowerCase(), + strRaw: val, + ); + } + + @override + int compareTo(_SortKey other) { + if (type == _SortKeyType.nullOrEmpty && other.type == _SortKeyType.nullOrEmpty) { + return 0; + } + if (type == _SortKeyType.nullOrEmpty) return 1; + if (other.type == _SortKeyType.nullOrEmpty) return -1; + + if (type == _SortKeyType.numeric && other.type == _SortKeyType.numeric) { + return numVal!.compareTo(other.numVal!); + } + if (type == _SortKeyType.dateTime && other.type == _SortKeyType.dateTime) { + return dtVal!.compareTo(other.dtVal!); + } + + final aLower = type == _SortKeyType.string ? strLower : strRaw.toLowerCase(); + final bLower = other.type == _SortKeyType.string ? other.strLower : other.strRaw.toLowerCase(); + final cmp = aLower.compareTo(bLower); + if (cmp != 0) return cmp; + + return strRaw.compareTo(other.strRaw); + } +} + +/// Sorts rows by the specified column index with natural numeric / temporal / lexicographic comparison. +/// Uses Schwartzian transform (Decorate-Sort-Undecorate) to precompute sort keys in O(N) time. +List> sortResultGridRows({ + required List> rows, + required int columnIndex, + required ResultGridSortOrder order, +}) { + if (rows.isEmpty || columnIndex < 0) return rows; + final n = rows.length; + + final keys = List<_SortKey>.generate(n, (i) { + final row = rows[i]; + final val = columnIndex < row.length ? row[columnIndex] : ''; + return _SortKey.parse(val); + }, growable: false); + + final indices = List.generate(n, (i) => i, growable: false); + + indices.sort((a, b) { + final cmp = keys[a].compareTo(keys[b]); + return order == ResultGridSortOrder.ascending ? cmp : -cmp; + }); + + return List>.generate(n, (i) => rows[indices[i]], growable: false); +} + +/// Virtualized read-only or interactive grid for SQL query results (rows + columns). +class VirtualResultGrid extends material.StatefulWidget { + const VirtualResultGrid({ + super.key, + required this.columns, + required this.rows, + this.stagingBuffer, + this.onRowSelected, + this.onSelectionValuesChanged, + this.onCellFocused, + }); + + final List columns; + final List> rows; + final DataGridStagingBuffer? stagingBuffer; + final material.ValueChanged? onRowSelected; + final material.ValueChanged>? onSelectionValuesChanged; + final void Function(String columnName, String cellValue, int rowIndex)? onCellFocused; + + @override + material.State createState() => _VirtualResultGridState(); +} + +class _VirtualResultGridState extends material.State { + final _horizontalController = material.ScrollController(); + final _verticalController = material.ScrollController(); + final _focusNode = material.FocusNode(); + + List _columnWidths = const []; + List _columnOffsets = const [0]; + bool _widthsNeedUpdate = true; + bool _userHasResized = false; + double _scrollOffset = 0; + + int? _sortColumnIndex; + ResultGridSortOrder? _sortOrder; + List> _sortedRows = const []; + + ResultGridCellCoordinate? _selectionAnchor; + ResultGridSelection? _selection; + ResultGridCellCoordinate? _editingCell; + + @override + void initState() { + super.initState(); + _horizontalController.addListener(_onHorizontalScroll); + widget.stagingBuffer?.addListener(_onStagingBufferChanged); + _updateSortedRows(); + } + + void _onStagingBufferChanged() { + if (!mounted) return; + setState(() { + _updateSortedRows(); + _widthsNeedUpdate = true; + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _widthsNeedUpdate = true; + } + + @override + void didUpdateWidget(VirtualResultGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.stagingBuffer != widget.stagingBuffer) { + oldWidget.stagingBuffer?.removeListener(_onStagingBufferChanged); + widget.stagingBuffer?.addListener(_onStagingBufferChanged); + } + if (oldWidget.columns != widget.columns || + oldWidget.rows != widget.rows || + oldWidget.stagingBuffer != widget.stagingBuffer) { + _widthsNeedUpdate = true; + if (oldWidget.columns != widget.columns) { + _userHasResized = false; + _sortColumnIndex = null; + _sortOrder = null; + _selectionAnchor = null; + _selection = null; + _editingCell = null; + widget.onRowSelected?.call(null); + } + _updateSortedRows(); + } + } + + @override + void dispose() { + widget.stagingBuffer?.removeListener(_onStagingBufferChanged); + _horizontalController.removeListener(_onHorizontalScroll); + _horizontalController.dispose(); + _verticalController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _startEditing(int row, int column) { + if (widget.stagingBuffer == null) return; + if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; + setState(() { + _editingCell = ResultGridCellCoordinate(row, column); + _selectionAnchor = _editingCell; + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + }); + } + + void _commitEdit( + int row, + int column, + String value, { + bool moveNextCol = false, + bool movePrevCol = false, + bool moveNextRow = false, + bool movePrevRow = false, + }) { + if (widget.stagingBuffer != null) { + widget.stagingBuffer!.setCell(row, column, value); + } + setState(() { + if (moveNextCol) { + if (column + 1 < widget.columns.length) { + _editingCell = ResultGridCellCoordinate(row, column + 1); + _selection = ResultGridSelection( + startRow: row, + startColumn: column + 1, + endRow: row, + endColumn: column + 1, + ); + } else if (row + 1 < _sortedRows.length) { + _editingCell = ResultGridCellCoordinate(row + 1, 0); + _selection = ResultGridSelection( + startRow: row + 1, + startColumn: 0, + endRow: row + 1, + endColumn: 0, + ); + } else { + _editingCell = null; + } + } else if (movePrevCol) { + if (column > 0) { + _editingCell = ResultGridCellCoordinate(row, column - 1); + _selection = ResultGridSelection( + startRow: row, + startColumn: column - 1, + endRow: row, + endColumn: column - 1, + ); + } else if (row > 0) { + _editingCell = ResultGridCellCoordinate(row - 1, widget.columns.length - 1); + _selection = ResultGridSelection( + startRow: row - 1, + startColumn: widget.columns.length - 1, + endRow: row - 1, + endColumn: widget.columns.length - 1, + ); + } else { + _editingCell = null; + } + } else if (moveNextRow) { + if (row + 1 < _sortedRows.length) { + _editingCell = ResultGridCellCoordinate(row + 1, column); + _selection = ResultGridSelection( + startRow: row + 1, + startColumn: column, + endRow: row + 1, + endColumn: column, + ); + } else { + _editingCell = null; + } + } else if (movePrevRow) { + if (row > 0) { + _editingCell = ResultGridCellCoordinate(row - 1, column); + _selection = ResultGridSelection( + startRow: row - 1, + startColumn: column, + endRow: row - 1, + endColumn: column, + ); + } else { + _editingCell = null; + } + } else { + _editingCell = null; + } + }); + } + + void _cancelEdit() { + setState(() { + _editingCell = null; + }); + } + + Future _openInspector(int row, int column) async { + if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; + final colName = widget.columns[column]; + final currentVal = column < _sortedRows[row].length ? _sortedRows[row][column] : ''; + final result = await showGridCellInspectorDialog( + context: context, + columnName: colName, + initialValue: currentVal, + rowIndex: row, + ); + if (result != null && widget.stagingBuffer != null) { + widget.stagingBuffer!.setCell(row, column, result); + } + } + + void _onHorizontalScroll() { + if (!_horizontalController.hasClients) return; + final offset = _horizontalController.offset; + if ((offset - _scrollOffset).abs() < 0.5) return; + setState(() => _scrollOffset = offset); + } + + void _onColumnResize(int index, double delta) { + if (index < 0 || index >= _columnWidths.length) return; + setState(() { + _userHasResized = true; + final minWidth = context.scaled(ResultGridMetrics.minColumnWidth); + final maxWidth = context.scaled(ResultGridMetrics.maxColumnWidth * 3); + final newWidth = (_columnWidths[index] + delta).clamp(minWidth, maxWidth); + _columnWidths = List.from(_columnWidths); + _columnWidths[index] = newWidth; + _columnOffsets = computeResultGridColumnOffsets(_columnWidths); + _widthsNeedUpdate = false; + }); + } + + void _toggleSort(int columnIndex) { + if (columnIndex < 0 || columnIndex >= widget.columns.length) return; + setState(() { + if (_sortColumnIndex == columnIndex) { + if (_sortOrder == ResultGridSortOrder.ascending) { + _sortOrder = ResultGridSortOrder.descending; + } else { + _sortColumnIndex = null; + _sortOrder = null; + } + } else { + _sortColumnIndex = columnIndex; + _sortOrder = ResultGridSortOrder.ascending; + } + _updateSortedRows(); + }); + } + + List> get _baseRows => + widget.stagingBuffer?.effectiveRows ?? widget.rows; + + void _updateSortedRows() { + final rows = _baseRows; + if (_sortColumnIndex == null || _sortOrder == null) { + _sortedRows = rows; + } else { + _sortedRows = sortResultGridRows( + rows: rows, + columnIndex: _sortColumnIndex!, + order: _sortOrder!, + ); + } + } + + void _notifySelectionAndFocus() { + if (widget.onSelectionValuesChanged != null) { + if (_selection == null) { + widget.onSelectionValuesChanged!(const []); + } else { + final rows = _sortedRows; + final values = []; + for (var r = _selection!.startRow; r <= _selection!.endRow; r++) { + if (r >= 0 && r < rows.length) { + for (var c = _selection!.startColumn; c <= _selection!.endColumn; c++) { + if (c >= 0 && c < rows[r].length) { + values.add(rows[r][c]); + } + } + } + } + widget.onSelectionValuesChanged!(values); + } + } + + if (widget.onCellFocused != null && _selectionAnchor != null) { + final r = _selectionAnchor!.row; + final c = _selectionAnchor!.column; + final rows = _sortedRows; + if (r >= 0 && r < rows.length && c >= 0 && c < widget.columns.length) { + final colName = widget.columns[c]; + final val = c < rows[r].length ? rows[r][c] : ''; + widget.onCellFocused!(colName, val, r); + } + } + } + + void _onCellTap(int row, int column, {bool isShift = false}) { + _focusNode.requestFocus(); + widget.onRowSelected?.call(row); + setState(() { + final coord = ResultGridCellCoordinate(row, column); + if (isShift && _selectionAnchor != null) { + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: coord, + ); + } else { + _selectionAnchor = coord; + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + } + }); + _notifySelectionAndFocus(); + } + + void _onCellSecondaryTap(int row, int column) { + _focusNode.requestFocus(); + widget.onRowSelected?.call(row); + if (_selection != null && _selection!.contains(row, column)) { + _copySelection(); + } else { + setState(() { + _selectionAnchor = ResultGridCellCoordinate(row, column); + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + }); + _notifySelectionAndFocus(); + _copySelection(); + } + } + + void _copySelection({bool asCsv = false}) { + if (_selection == null) return; + final text = asCsv + ? _selection!.toCsv(_sortedRows) + : _selection!.toTsv(_sortedRows); + if (text.isNotEmpty) { + Clipboard.setData(ClipboardData(text: text)); + } + } + + List _computeColumnWidths() { + return computeResultGridColumnWidths( + columns: widget.columns, + rows: _baseRows, + minWidth: context.scaled(ResultGridMetrics.minColumnWidth), + maxWidth: context.scaled(ResultGridMetrics.maxColumnWidth), + ); + } + + double get _tableWidth { + if (_columnWidths.isEmpty) return 0; + return _columnOffsets[_columnWidths.length]; + } + + double _scaledRowHeight(material.BuildContext context) => + context.scaled(ResultGridMetrics.rowHeight); + + 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 && !_userHasResized) { + _columnWidths = _computeColumnWidths(); + _columnOffsets = computeResultGridColumnOffsets(_columnWidths); + _widthsNeedUpdate = false; + } + final cs = Theme.of(context).colorScheme; + final rowHeight = _scaledRowHeight(context); + final headerHeight = _scaledHeaderHeight(context); + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator( + LogicalKeyboardKey.keyC, + meta: true, + ): () => _copySelection(), + const material.SingleActivator( + LogicalKeyboardKey.keyC, + control: true, + ): () => _copySelection(), + const material.SingleActivator( + LogicalKeyboardKey.insert, + control: true, + ): () => widget.stagingBuffer?.addRow(), + const material.SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + ): () { + if (widget.stagingBuffer != null) { + widget.stagingBuffer!.addRow(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.delete, + control: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.toggleDeleteRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.backspace, + meta: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.toggleDeleteRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyZ, + control: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.revertRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.revertRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.escape, + ): () { + if (_editingCell != null) { + _cancelEdit(); + } else { + widget.onRowSelected?.call(null); + setState(() { + _selection = null; + _selectionAnchor = null; + }); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.f2, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.space, + ): () { + if (_selection != null && _editingCell == null) { + _openInspector(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyN, + alt: true, + ): () { + if (_selection != null && widget.stagingBuffer != null) { + widget.stagingBuffer!.setCell( + _selection!.startRow, + _selection!.startColumn, + 'NULL', + ); + } + }, + }, + child: material.Focus( + focusNode: _focusNode, + child: material.RepaintBoundary( + child: material.LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + + var displayWidths = _columnWidths; + var tableWidth = _tableWidth; + if (!_userHasResized && + tableWidth < availableWidth && + _columnWidths.isNotEmpty) { + final extraPerCol = + (availableWidth - tableWidth) / _columnWidths.length; + displayWidths = [for (final w in _columnWidths) w + extraPerCol]; + tableWidth = availableWidth; + } else if (tableWidth < availableWidth) { + tableWidth = availableWidth; + } + + final window = _columnWindow(displayWidths, availableWidth); + + return material.Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (_) => true, + child: material.SingleChildScrollView( + controller: _horizontalController, + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: tableWidth, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _HeaderRow( + columns: widget.columns, + columnWidths: displayWidths, + window: window, + height: headerHeight, + colorScheme: cs, + sortColumnIndex: _sortColumnIndex, + sortOrder: _sortOrder, + onSortColumn: _toggleSort, + onResizeColumn: _onColumnResize, + ), + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: material.ListView.builder( + controller: _verticalController, + itemCount: _sortedRows.length, + itemExtent: rowHeight, + itemBuilder: (context, rowIndex) { + final row = _sortedRows[rowIndex]; + final isEven = rowIndex.isEven; + return _DataRow( + key: ValueKey('result-row-$rowIndex'), + rowIndex: rowIndex, + row: row, + columnWidths: displayWidths, + window: window, + height: rowHeight, + colorScheme: cs, + striped: !isEven, + selection: _selection, + stagingBuffer: widget.stagingBuffer, + editingCell: _editingCell, + onCellTap: _onCellTap, + onCellDoubleTap: _startEditing, + onCellSecondaryTap: _onCellSecondaryTap, + onCommitEdit: _commitEdit, + onCancelEdit: _cancelEdit, + onOpenInspector: _openInspector, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} + +class _HeaderRow extends material.StatelessWidget { + const _HeaderRow({ + required this.columns, + required this.columnWidths, + required this.window, + required this.height, + required this.colorScheme, + this.sortColumnIndex, + this.sortOrder, + this.onSortColumn, + this.onResizeColumn, + }); + + final List columns; + final List columnWidths; + final ResultGridColumnWindow window; + final double height; + final ColorScheme colorScheme; + final int? sortColumnIndex; + final ResultGridSortOrder? sortOrder; + final material.ValueChanged? onSortColumn; + final void Function(int index, double delta)? onResizeColumn; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + height: height, + decoration: material.BoxDecoration( + color: colorScheme.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.5), + ), + ), + ), + child: material.Row( + children: [ + if (window.leadingWidth > 0) + material.SizedBox(width: window.leadingWidth), + for (var i = window.first; i <= window.last; i++) + _HeaderCell( + text: columns[i], + width: columnWidths[i], + colorScheme: colorScheme, + sortOrder: sortColumnIndex == i ? sortOrder : null, + onSort: onSortColumn != null ? () => onSortColumn!(i) : null, + onResize: onResizeColumn != null + ? (delta) => onResizeColumn!(i, delta) + : null, + ), + if (window.trailingWidth > 0) + material.SizedBox(width: window.trailingWidth), + ], + ), + ); + } +} + +class _HeaderCell extends material.StatelessWidget { + const _HeaderCell({ + required this.text, + required this.width, + required this.colorScheme, + this.sortOrder, + this.onSort, + this.onResize, + }); + + final String text; + final double width; + final ColorScheme colorScheme; + final ResultGridSortOrder? sortOrder; + final material.VoidCallback? onSort; + final material.ValueChanged? onResize; + + @override + material.Widget build(material.BuildContext context) { + final isSorted = sortOrder != null; + final style = material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: isSorted ? colorScheme.primary : colorScheme.foreground, + ); + + return material.Container( + width: width, + height: double.infinity, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Stack( + clipBehavior: material.Clip.none, + children: [ + material.Positioned.fill( + child: material.MouseRegion( + cursor: onSort != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: onSort, + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + child: material.Row( + children: [ + material.Expanded( + child: material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + if (sortOrder != null) ...[ + const Gap(4), + material.Icon( + sortOrder == ResultGridSortOrder.ascending + ? material.Icons.arrow_upward_rounded + : material.Icons.arrow_downward_rounded, + size: 14, + color: colorScheme.primary, + ), + ], + ], + ), + ), + ), + ), + ), + if (onResize != null) + material.Positioned( + right: -4, + top: 0, + bottom: 0, + width: 10, + child: material.MouseRegion( + cursor: material.SystemMouseCursors.resizeColumn, + child: material.GestureDetector( + behavior: material.HitTestBehavior.translucent, + onHorizontalDragUpdate: (details) { + onResize!(details.delta.dx); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _DataRow extends material.StatelessWidget { + const _DataRow({ + super.key, + required this.rowIndex, + required this.row, + required this.columnWidths, + required this.window, + required this.height, + required this.colorScheme, + required this.striped, + this.selection, + this.stagingBuffer, + this.editingCell, + this.onCellTap, + this.onCellDoubleTap, + this.onCellSecondaryTap, + this.onCommitEdit, + this.onCancelEdit, + this.onOpenInspector, + }); + + final int rowIndex; + final List row; + final List columnWidths; + final ResultGridColumnWindow window; + final double height; + final ColorScheme colorScheme; + final bool striped; + final ResultGridSelection? selection; + final DataGridStagingBuffer? stagingBuffer; + final ResultGridCellCoordinate? editingCell; + final void Function(int row, int col, {bool isShift})? onCellTap; + final void Function(int row, int col)? onCellDoubleTap; + final void Function(int row, int col)? onCellSecondaryTap; + final void Function( + int row, + int col, + String val, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + bool movePrevRow, + })? onCommitEdit; + final material.VoidCallback? onCancelEdit; + final void Function(int row, int col)? onOpenInspector; + + @override + material.Widget build(material.BuildContext context) { + final rowStatus = stagingBuffer?.getRowStatus(rowIndex) ?? StagedRowStatus.unchanged; + + return material.RepaintBoundary( + child: material.SizedBox( + height: height, + child: material.Row( + children: [ + if (window.leadingWidth > 0) + material.SizedBox(width: window.leadingWidth), + for (var c = window.first; c <= window.last; c++) + _GridCell( + row: rowIndex, + column: c, + text: c < row.length ? row[c] : '', + width: columnWidths[c], + colorScheme: colorScheme, + striped: striped, + rowStatus: rowStatus, + cellStatus: stagingBuffer?.getCellStatus(rowIndex, c) ?? StagedCellStatus.clean, + isSelected: selection?.contains(rowIndex, c) ?? false, + isEditing: editingCell?.row == rowIndex && editingCell?.column == c, + isSelectionTop: selection != null && + selection!.contains(rowIndex, c) && + rowIndex == selection!.startRow, + isSelectionBottom: selection != null && + selection!.contains(rowIndex, c) && + rowIndex == selection!.endRow, + isSelectionLeft: selection != null && + selection!.contains(rowIndex, c) && + c == selection!.startColumn, + isSelectionRight: selection != null && + selection!.contains(rowIndex, c) && + c == selection!.endColumn, + onTap: onCellTap, + onDoubleTap: onCellDoubleTap, + onSecondaryTap: onCellSecondaryTap, + onCommitEdit: onCommitEdit, + onCancelEdit: onCancelEdit, + onOpenInspector: onOpenInspector, + ), + if (window.trailingWidth > 0) + material.SizedBox(width: window.trailingWidth), + ], + ), + ), + ); + } +} + +class _GridCell extends material.StatelessWidget { + const _GridCell({ + required this.row, + required this.column, + required this.text, + required this.width, + required this.colorScheme, + required this.striped, + this.rowStatus = StagedRowStatus.unchanged, + this.cellStatus = StagedCellStatus.clean, + this.isSelected = false, + this.isEditing = false, + this.isSelectionTop = false, + this.isSelectionBottom = false, + this.isSelectionLeft = false, + this.isSelectionRight = false, + this.onTap, + this.onDoubleTap, + this.onSecondaryTap, + this.onCommitEdit, + this.onCancelEdit, + this.onOpenInspector, + }); + + final int row; + final int column; + final String text; + final double width; + final ColorScheme colorScheme; + final bool striped; + final StagedRowStatus rowStatus; + final StagedCellStatus cellStatus; + final bool isSelected; + final bool isEditing; + final bool isSelectionTop; + final bool isSelectionBottom; + final bool isSelectionLeft; + final bool isSelectionRight; + final void Function(int row, int col, {bool isShift})? onTap; + final void Function(int row, int col)? onDoubleTap; + final void Function(int row, int col)? onSecondaryTap; + final void Function( + int row, + int col, + String val, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + bool movePrevRow, + })? onCommitEdit; + final material.VoidCallback? onCancelEdit; + final void Function(int row, int col)? onOpenInspector; + + @override + material.Widget build(material.BuildContext context) { + if (isEditing) { + return GridCellEditor( + initialValue: text, + width: width, + height: double.infinity, + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) { + onCommitEdit?.call( + row, + column, + val, + moveNextCol: moveNextCol, + movePrevCol: movePrevCol, + moveNextRow: moveNextRow, + movePrevRow: movePrevRow, + ); + }, + onCancel: () => onCancelEdit?.call(), + onOpenInspector: () => onOpenInspector?.call(row, column), + ); + } + + final isNull = text == 'NULL'; + final isDeleted = rowStatus == StagedRowStatus.deleted; + final isInserted = rowStatus == StagedRowStatus.inserted; + final isModified = cellStatus == StagedCellStatus.modified; + + var style = material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.normal, + fontFamily: 'monospace', + color: isDeleted + ? colorScheme.destructive.withValues(alpha: 0.7) + : (isNull + ? colorScheme.mutedForeground.withValues(alpha: 0.5) + : colorScheme.foreground), + decoration: isDeleted ? material.TextDecoration.lineThrough : null, + fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, + ); + + var bg = isSelected + ? colorScheme.primary.withValues(alpha: 0.18) + : (isDeleted + ? colorScheme.destructive.withValues(alpha: 0.08) + : (isModified + ? colorScheme.primary.withValues(alpha: 0.14) + : (isInserted + ? colorScheme.primary.withValues(alpha: 0.08) + : (striped + ? colorScheme.muted.withValues(alpha: 0.12) + : material.Colors.transparent)))); + + final cell = material.Container( + width: width, + height: double.infinity, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + decoration: material.BoxDecoration( + color: bg, + border: material.Border( + right: material.BorderSide( + color: isSelectionRight + ? colorScheme.primary + : colorScheme.border.withValues(alpha: 0.3), + width: isSelectionRight ? 1.5 : 1.0, + ), + left: isSelectionLeft + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide.none, + top: isSelectionTop + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide.none, + bottom: isSelectionBottom + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.15), + ), + ), + ), + child: material.Stack( + clipBehavior: material.Clip.none, + alignment: material.Alignment.centerLeft, + children: [ + material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + if (isModified) + material.Positioned( + top: -8, + right: -8, + child: material.CustomPaint( + size: const material.Size(6, 6), + painter: _TriangleCornerPainter(color: colorScheme.primary), + ), + ), + ], + ), + ); + + final interactiveCell = material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: () { + final isShift = HardwareKeyboard.instance.isShiftPressed; + onTap?.call(row, column, isShift: isShift); + }, + onDoubleTap: () { + onDoubleTap?.call(row, column); + }, + onSecondaryTap: () { + onSecondaryTap?.call(row, column); + }, + child: cell, + ); + + if (text.length < ResultGridMetrics.tooltipMinLength) { + return interactiveCell; + } + + return material.Tooltip( + message: text, + waitDuration: kQueryaTooltipWait, + child: interactiveCell, + ); + } +} + +class _TriangleCornerPainter extends material.CustomPainter { + const _TriangleCornerPainter({required this.color}); + final material.Color color; + + @override + void paint(material.Canvas canvas, material.Size size) { + final paint = material.Paint()..color = color; + final path = material.Path() + ..moveTo(0, 0) + ..lineTo(size.width, 0) + ..lineTo(size.width, size.height) + ..close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _TriangleCornerPainter oldDelegate) => + oldDelegate.color != color; +} + diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart new file mode 100644 index 0000000..c262f8a --- /dev/null +++ b/lib/features/workspace/results_tab.dart @@ -0,0 +1,416 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; +import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; +import 'package:querya_desktop/features/workspace/data_grid_calc_bar.dart'; +import 'package:querya_desktop/features/workspace/data_grid_filter_bar.dart'; +import 'package:querya_desktop/features/workspace/data_grid_groupings_view.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_toolbar.dart'; +import 'package:querya_desktop/features/workspace/data_grid_value_panel.dart'; +import 'package:querya_desktop/features/workspace/grid_filter_engine.dart'; +import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; +import 'package:querya_desktop/features/workspace/result_grid_view.dart'; +import 'package:querya_desktop/shared/services/data_export_service.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +enum ResultViewMode { + grid, + groupings, +} + +/// Query output: grid, loading, error, or placeholder. +class ResultsTab extends material.StatefulWidget { + const ResultsTab({ + super.key, + this.columns = const [], + this.rows = const [], + this.errorMessage, + this.isLoading = false, + this.affectedRows, + this.statusLine, + this.showExportToolbar = true, + this.stagingBuffer, + this.onApplyChanges, + this.isSaving = false, + }); + + final List columns; + final List> rows; + final String? errorMessage; + final bool isLoading; + final int? affectedRows; + final String? statusLine; + final bool showExportToolbar; + final DataGridStagingBuffer? stagingBuffer; + final material.VoidCallback? onApplyChanges; + final bool isSaving; + + @override + material.State createState() => _ResultsTabState(); +} + +class _ResultsTabState extends material.State { + int? _selectedRowIndex; + ResultViewMode _viewMode = ResultViewMode.grid; + + bool _showFilterBar = false; + String _filterText = ''; + + bool _showValuePanel = false; + String? _focusedColumnName; + String? _focusedCellValue; + int? _focusedRowIndex; + + GridCalcStats _selectionStats = GridCalcStats.empty; + + @override + Widget build(BuildContext context) { + return QueryaFadeSlide( + alignment: material.Alignment.center, + offset: const material.Offset(0, 0.015), + child: material.RepaintBoundary(child: _buildBody(context)), + ); + } + + material.Widget _buildBody(material.BuildContext context) { + if (widget.isLoading) { + return const material.Center( + key: material.ValueKey('results_mode_loading'), + child: material.CircularProgressIndicator(), + ); + } + if (widget.errorMessage != null && widget.errorMessage!.isNotEmpty) { + return material.KeyedSubtree( + key: const material.ValueKey('results_mode_error'), + child: VirtualSelectableTextView( + text: widget.errorMessage!, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Theme.of(context).colorScheme.destructive, + ), + ), + ); + } + if (widget.columns.isEmpty && widget.rows.isEmpty && widget.stagingBuffer == null) { + if (widget.statusLine != null) { + return material.Padding( + key: const material.ValueKey('results_mode_status'), + padding: const material.EdgeInsets.all(16), + child: Align( + alignment: material.Alignment.topLeft, + child: Text(widget.statusLine!).muted().small(), + ), + ); + } + if (widget.affectedRows != null) { + return material.Center( + key: const material.ValueKey('results_mode_affected'), + child: Text('Rows affected: ${widget.affectedRows}').muted(), + ); + } + return material.Center( + key: const material.ValueKey('results_mode_idle'), + child: const Text('Run a query to see results here.').muted(), + ); + } + + final effectiveRows = widget.stagingBuffer != null + ? widget.stagingBuffer!.effectiveRows + : widget.rows; + + final filteredIndices = GridFilterEngine.filterRowIndices( + filterText: _filterText, + columns: widget.columns, + rows: effectiveRows, + ); + + final filteredRows = filteredIndices.length == effectiveRows.length + ? effectiveRows + : filteredIndices.map((i) => effectiveRows[i]).toList(); + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator( + LogicalKeyboardKey.keyF, + meta: true, + ): () => setState(() => _showFilterBar = !_showFilterBar), + const material.SingleActivator( + LogicalKeyboardKey.keyF, + control: true, + ): () => setState(() => _showFilterBar = !_showFilterBar), + const material.SingleActivator( + LogicalKeyboardKey.keyS, + meta: true, + ): () { + if (widget.stagingBuffer?.isDirty == true && !widget.isSaving) { + widget.onApplyChanges?.call(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyS, + control: true, + ): () { + if (widget.stagingBuffer?.isDirty == true && !widget.isSaving) { + widget.onApplyChanges?.call(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyG, + meta: true, + ): () => setState(() { + _viewMode = _viewMode == ResultViewMode.grid + ? ResultViewMode.groupings + : ResultViewMode.grid; + }), + const material.SingleActivator( + LogicalKeyboardKey.keyG, + control: true, + ): () => setState(() { + _viewMode = _viewMode == ResultViewMode.grid + ? ResultViewMode.groupings + : ResultViewMode.grid; + }), + }, + child: material.Column( + key: const material.ValueKey('results_mode_grid'), + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (widget.stagingBuffer != null) + DataGridStagingToolbar( + stagingBuffer: widget.stagingBuffer!, + selectedRowIndex: _selectedRowIndex, + onApplyChanges: widget.onApplyChanges, + isSaving: widget.isSaving, + ), + if (widget.showExportToolbar && widget.columns.isNotEmpty) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: Theme.of(context).colorScheme.card, + border: material.Border( + bottom: material.BorderSide( + color: Theme.of(context) + .colorScheme + .border + .withValues(alpha: 0.5), + ), + ), + ), + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + // Grid / Groupings View Selector + material.SizedBox( + height: 28, + child: material.SegmentedButton( + segments: const [ + material.ButtonSegment( + value: ResultViewMode.grid, + label: Text('Grid'), + icon: material.Icon(material.Icons.table_chart_outlined, size: 14), + ), + material.ButtonSegment( + value: ResultViewMode.groupings, + label: Text('Groupings'), + icon: material.Icon(material.Icons.grid_view_rounded, size: 14), + ), + ], + selected: {_viewMode}, + onSelectionChanged: (selected) { + setState(() => _viewMode = selected.first); + }, + showSelectedIcon: false, + style: material.SegmentedButton.styleFrom( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 0), + visualDensity: material.VisualDensity.compact, + ), + ), + ), + const Gap(10), + Text( + widget.statusLine ?? + (widget.affectedRows != null + ? 'Rows affected: ${widget.affectedRows}' + : '${filteredRows.length}${_filterText.isNotEmpty ? ' of ${effectiveRows.length}' : ''} rows'), + ).small().semiBold(), + + const Gap(16), + + // Toggle Quick Filter + material.IconButton( + icon: material.Icon( + _showFilterBar ? material.Icons.filter_alt : material.Icons.filter_alt_outlined, + size: 15, + ), + tooltip: 'Toggle Quick Filter', + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), + color: _showFilterBar || _filterText.isNotEmpty + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.mutedForeground, + onPressed: () { + setState(() => _showFilterBar = !_showFilterBar); + }, + ), + const Gap(4), + + // Toggle Value Side Panel + material.IconButton( + icon: material.Icon( + _showValuePanel ? material.Icons.dock : material.Icons.data_object_rounded, + size: 15, + ), + tooltip: 'Inspect Cell Panel', + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), + color: _showValuePanel + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.mutedForeground, + onPressed: () { + setState(() => _showValuePanel = !_showValuePanel); + }, + ), + const Gap(8), + + ExportMenuButton( + label: 'Copy ▾', + icon: material.Icons.copy_rounded, + isSave: false, + onSelected: (format) { + unawaited(() async { + await DataExportService.copyToClipboard( + format, + columns: widget.columns, + rows: filteredRows, + ); + }()); + }, + ), + const Gap(6), + ExportMenuButton( + label: 'Save ▾', + icon: material.Icons.save_alt_rounded, + isSave: true, + onSelected: (format) { + unawaited(() async { + final outcome = await DataExportService.saveToFile( + format, + columns: widget.columns, + rows: filteredRows, + ); + if (!context.mounted) return; + if (outcome == SaveExportOutcome.error) { + await _showSaveFileErrorDialog(context); + } + }()); + }, + ), + ], + ), + ), + ), + + // Quick Filter Bar + if (_showFilterBar || _filterText.isNotEmpty) + DataGridFilterBar( + filterText: _filterText, + onFilterChanged: (text) => setState(() => _filterText = text), + totalRowCount: effectiveRows.length, + filteredRowCount: filteredRows.length, + columns: widget.columns, + ), + + // Main Grid Body / Groupings View + Side Panel + material.Expanded( + child: _viewMode == ResultViewMode.groupings + ? DataGridGroupingsView( + columns: widget.columns, + rows: filteredRows, + ) + : material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded( + child: VirtualResultGrid( + columns: widget.columns, + rows: filteredRows, + stagingBuffer: widget.stagingBuffer, + onRowSelected: (row) => setState(() => _selectedRowIndex = row), + onSelectionValuesChanged: (values) { + setState(() { + _selectionStats = GridSelectionCalcEngine.compute(values); + }); + }, + onCellFocused: (colName, cellVal, rowIdx) { + setState(() { + _focusedColumnName = colName; + _focusedCellValue = cellVal; + _focusedRowIndex = rowIdx; + }); + }, + ), + ), + + // Value Inspector Panel + if (_showValuePanel && + _focusedColumnName != null && + _focusedCellValue != null) + DataGridValuePanel( + columnName: _focusedColumnName!, + cellValue: _focusedCellValue!, + rowIndex: _focusedRowIndex, + onClose: () => setState(() => _showValuePanel = false), + onUpdateValue: widget.stagingBuffer != null && + _focusedRowIndex != null && + _focusedColumnName != null + ? (newVal) { + final colIdx = widget.columns.indexOf(_focusedColumnName!); + if (colIdx != -1) { + widget.stagingBuffer!.setCell( + _focusedRowIndex!, + colIdx, + newVal, + ); + } + } + : null, + ), + ], + ), + ), + + // Calc Bar Footer + if (_viewMode == ResultViewMode.grid) + DataGridCalcBar(stats: _selectionStats), + ], + ), + ); + } +} + +Future _showSaveFileErrorDialog(material.BuildContext context) { + return showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Could not save file'), + content: const material.Text( + 'Check folder permissions or disk space.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const material.Text('OK'), + ), + ], + ), + ); +} diff --git a/lib/features/workspace/sql_editor_chrome.dart b/lib/features/workspace/sql_editor_chrome.dart new file mode 100644 index 0000000..7dcd62d --- /dev/null +++ b/lib/features/workspace/sql_editor_chrome.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Outer chrome for SQL editors: border, surface, brand accent glow. +class SqlEditorChrome extends StatelessWidget { + const SqlEditorChrome({super.key, required this.child}); + + final Widget child; + + static const double outerRadius = 14; + static const double innerRadius = 10; + + /// Accent glow strength; slightly softer on light themes. + static double chromeGlowAlpha(Brightness brightness) => + brightness == Brightness.light ? 0.08 : 0.1; + + static double inlineGlowAlpha(Brightness brightness) => + brightness == Brightness.light ? 0.05 : 0.07; + + /// Toolbar strip above SQL editor (Postgres/MySQL workspaces). + static material.BoxDecoration sqlToolbarDecoration( + BuildContext context, + ) { + final workbench = context.workbench; + return material.BoxDecoration( + color: workbench.surface.withValues(alpha: 0.85), + border: material.Border( + bottom: material.BorderSide( + color: workbench.borderSubtle.withValues(alpha: 0.35), + ), + ), + ); + } + + /// Decoration for compact SQL fields (dialogs) from theme tokens. + static material.BoxDecoration inlineFieldDecoration( + QueryaEditorTheme editor, + QueryaWorkbenchTheme workbench, { + Brightness brightness = Brightness.dark, + }) { + final border = editor.widgetBorder ?? workbench.borderSubtle; + return material.BoxDecoration( + color: editor.background, + borderRadius: material.BorderRadius.circular(innerRadius), + border: material.Border.all( + color: border.withValues(alpha: 0.45), + ), + boxShadow: [ + material.BoxShadow( + color: workbench.accent.withValues( + alpha: inlineGlowAlpha(brightness), + ), + blurRadius: 18, + offset: const material.Offset(0, 6), + ), + ], + ); + } + + static material.BoxDecoration inlineFieldDecorationFromContext( + BuildContext context, + ) { + return inlineFieldDecoration( + context.editorTheme, + context.workbench, + brightness: Theme.of(context).brightness, + ); + } + + @override + Widget build(BuildContext context) { + final editor = context.editorTheme; + final workbench = context.workbench; + final brightness = Theme.of(context).brightness; + final border = editor.widgetBorder ?? workbench.borderSubtle; + final glow = workbench.accent.withValues( + alpha: chromeGlowAlpha(brightness), + ); + + return material.Container( + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(outerRadius), + boxShadow: [ + material.BoxShadow( + color: glow, + blurRadius: 28, + spreadRadius: 0, + offset: const material.Offset(0, 10), + ), + ], + ), + child: material.Container( + decoration: material.BoxDecoration( + color: editor.background, + borderRadius: material.BorderRadius.circular(outerRadius), + border: material.Border.all( + color: border.withValues(alpha: 0.5), + ), + ), + clipBehavior: material.Clip.antiAlias, + child: child, + ), + ); + } +} diff --git a/lib/features/workspace/sql_query_history_dialog.dart b/lib/features/workspace/sql_query_history_dialog.dart new file mode 100644 index 0000000..123cdf7 --- /dev/null +++ b/lib/features/workspace/sql_query_history_dialog.dart @@ -0,0 +1,259 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows recent SQL for this connection + database; choosing a row replaces the editor text. +void showSqlQueryHistoryDialog({ + required BuildContext context, + required int connectionId, + String? databaseName, + required material.TextEditingController sqlController, +}) { + showAppDialog( + context: context, + builder: (ctx) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(ctx), + child: _SqlQueryHistoryDialogContent( + connectionId: connectionId, + databaseName: databaseName, + sqlController: sqlController, + ), + ), + ); +} + +class _SqlQueryHistoryDialogContent extends material.StatefulWidget { + const _SqlQueryHistoryDialogContent({ + required this.connectionId, + required this.databaseName, + required this.sqlController, + }); + + final int connectionId; + final String? databaseName; + final material.TextEditingController sqlController; + + @override + material.State<_SqlQueryHistoryDialogContent> createState() => + _SqlQueryHistoryDialogContentState(); +} + +class _SqlQueryHistoryDialogContentState + extends material.State<_SqlQueryHistoryDialogContent> { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future> _load() async { + final cap = await AppSettings.instance.getSqlHistoryMaxEntries(); + return LocalDb.instance.listSqlQueryHistory( + connectionId: widget.connectionId, + databaseName: widget.databaseName, + limit: cap, + ); + } + + void _reload() { + setState(() { + _future = _load(); + }); + } + + static final _whitespacePattern = RegExp(r'\s+'); + + static String _previewOneLine(String sql) { + final collapsed = sql.replaceAll(_whitespacePattern, ' ').trim(); + if (collapsed.length <= 96) return collapsed; + return '${collapsed.substring(0, 93)}…'; + } + + static String? _formatWhen(String iso) { + final t = DateTime.tryParse(iso)?.toLocal(); + if (t == null) return null; + String z(int n) => n.toString().padLeft(2, '0'); + return '${t.year}-${z(t.month)}-${z(t.day)} ${z(t.hour)}:${z(t.minute)}'; + } + + Future _confirmClear() async { + final ok = await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Clear query history?'), + content: const material.Text( + 'Removes saved SQL for this connection and database. This cannot be undone.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const material.Text('Cancel'), + ), + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const material.Text('Clear'), + ), + ], + ), + ); + if (ok != true || !mounted) return; + await LocalDb.instance.clearSqlQueryHistoryBucket( + connectionId: widget.connectionId, + databaseName: widget.databaseName, + ); + if (!mounted) return; + _reload(); + } + + void _apply(SqlQueryHistoryEntry e) { + final text = e.sqlText; + widget.sqlController.value = material.TextEditingValue( + text: text, + selection: material.TextSelection.collapsed(offset: text.length), + ); + material.Navigator.of(context).pop(); + } + + @override + material.Widget build(material.BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + return material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 520, + minWidth: 320, + maxHeight: 440, + ), + decoration: material.BoxDecoration( + color: scheme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: scheme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(20, 20, 20, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Query history').large().semiBold(), + const material.SizedBox(height: 4), + const Text( + 'Successful runs from this workspace (newest first).', + ).muted().small(), + ], + ), + ), + material.Expanded( + child: material.FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != material.ConnectionState.done) { + return const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(24), + child: material.CircularProgressIndicator(), + ), + ); + } + if (snap.hasError) { + return material.Padding( + padding: const material.EdgeInsets.all(20), + child: Text( + 'Could not load history: ${snap.error}', + style: material.TextStyle(color: scheme.destructive), + ).small(), + ); + } + final items = snap.data ?? []; + if (items.isEmpty) { + return material.Center( + child: const Text( + 'No queries yet. Run SQL to build history.', + ).muted().small(), + ); + } + return material.Scrollbar( + child: material.ListView.separated( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + itemCount: items.length, + separatorBuilder: (_, __) => + material.Divider(height: 1, color: scheme.border), + itemBuilder: (context, i) { + final e = items[i]; + final when = _formatWhen(e.recordedAt); + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: () => _apply(e), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + children: [ + material.Text( + _previewOneLine(e.sqlText), + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontSize: 12, + color: scheme.foreground, + ), + ), + if (when != null) ...[ + const material.SizedBox(height: 4), + Text(when).muted().xSmall(), + ], + ], + ), + ), + ), + ); + }, + ), + ); + }, + ), + ), + material.Padding( + padding: const material.EdgeInsets.fromLTRB(16, 8, 16, 16), + child: material.Row( + children: [ + GhostButton( + onPressed: () => unawaited(_confirmClear()), + child: const Text('Clear history'), + ), + const Spacer(), + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/workspace/workspace.dart b/lib/features/workspace/workspace.dart new file mode 100644 index 0000000..a9db7f8 --- /dev/null +++ b/lib/features/workspace/workspace.dart @@ -0,0 +1,20 @@ +export 'data_grid_calc_bar.dart'; +export 'data_grid_filter_bar.dart'; +export 'data_grid_groupings_view.dart'; +export 'data_grid_staging_buffer.dart'; +export 'data_grid_staging_toolbar.dart'; +export 'data_grid_value_panel.dart'; +export 'destructive_query_dialog.dart'; +export 'dml_preview_dialog.dart'; +export 'grid_cell_editor.dart'; +export 'grid_cell_popover_inspector.dart'; +export 'grid_data_type_validator.dart'; +export 'grid_filter_engine.dart'; +export 'grid_groupings_engine.dart'; +export 'grid_selection_calc_engine.dart'; +export 'query_editor_tab.dart'; +export 'result_grid_view.dart'; +export 'results_tab.dart'; +export 'sql_editor_chrome.dart'; +export 'sql_query_history_dialog.dart'; +export 'xml_html_formatter.dart'; diff --git a/lib/features/workspace/xml_html_formatter.dart b/lib/features/workspace/xml_html_formatter.dart new file mode 100644 index 0000000..f170036 --- /dev/null +++ b/lib/features/workspace/xml_html_formatter.dart @@ -0,0 +1,117 @@ +/// Formatter and validator for XML and HTML strings. +abstract final class XmlHtmlFormatter { + /// Validates [xml] string and returns null if valid, or an error message if invalid. + static String? validate(String xml) { + final trimmed = xml.trim(); + if (trimmed.isEmpty) return null; + + final tagStack = []; + final tagRegex = RegExp(r'<(/)?([a-zA-Z0-9_\-:]+)([^>]*)>'); + final matches = tagRegex.allMatches(trimmed); + + if (matches.isEmpty) { + if (trimmed.contains('<') || trimmed.contains('>')) { + return 'Malformed XML/HTML tags'; + } + return null; + } + + for (final match in matches) { + final fullMatch = match.group(0)!; + final isClosing = match.group(1) != null; + final tagName = match.group(2)!; + final rest = match.group(3) ?? ''; + + // Check for self-closing tag: or XML declaration or comment + if (fullMatch.startsWith(''; + } + final last = tagStack.removeLast(); + if (last.toLowerCase() != tagName.toLowerCase()) { + return 'Mismatched closing tag: expected , got '; + } + } else { + tagStack.add(tagName); + } + } + + if (tagStack.isNotEmpty) { + return 'Unclosed tag: <${tagStack.last}>'; + } + + return null; + } + + /// Formats / pretty-prints [xml] with [indent] spaces per level. + static String format(String xml, {int indent = 2}) { + final trimmed = xml.trim(); + if (trimmed.isEmpty) return xml; + + final indentStr = ' ' * indent; + final buffer = StringBuffer(); + var level = 0; + + final tokenRegex = RegExp(r'(|<\?[^>]*\?>|]*>|<[^>]+>|[^<]+)'); + final matches = tokenRegex.allMatches(trimmed); + + for (final match in matches) { + var token = match.group(0)!.trim(); + if (token.isEmpty) continue; + + if (token.startsWith(' 0) level--; + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + } else if (token.startsWith('<') && token.endsWith('/>')) { + // Self closing tag + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + } else if (token.startsWith('')) { + // Opening tag + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + level++; + } else { + // Text node + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + } + } + + return buffer.toString(); + } + + /// Minifies [xml] by removing newlines and extraneous spaces between tags. + static String minify(String xml) { + return xml + .replaceAll(RegExp(r'>\s+<'), '><') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } +} diff --git a/test/features/main_screen/data_grid_e2e_integration_test.dart b/test/features/workspace/data_grid_e2e_integration_test.dart similarity index 94% rename from test/features/main_screen/data_grid_e2e_integration_test.dart rename to test/features/workspace/data_grid_e2e_integration_test.dart index 1a7a584..bdc1f12 100644 --- a/test/features/main_screen/data_grid_e2e_integration_test.dart +++ b/test/features/workspace/data_grid_e2e_integration_test.dart @@ -1,10 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; -import 'package:querya_desktop/features/main_screen/grid_filter_engine.dart'; -import 'package:querya_desktop/features/main_screen/grid_groupings_engine.dart'; -import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/grid_filter_engine.dart'; +import 'package:querya_desktop/features/workspace/grid_groupings_engine.dart'; +import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; void main() { group('Data Grid End-to-End Integration Tests', () { diff --git a/test/features/main_screen/data_grid_engines_test.dart b/test/features/workspace/data_grid_engines_test.dart similarity index 97% rename from test/features/main_screen/data_grid_engines_test.dart rename to test/features/workspace/data_grid_engines_test.dart index c6c5cf7..3ff65e9 100644 --- a/test/features/main_screen/data_grid_engines_test.dart +++ b/test/features/workspace/data_grid_engines_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/features/main_screen/grid_filter_engine.dart'; -import 'package:querya_desktop/features/main_screen/grid_groupings_engine.dart'; -import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; +import 'package:querya_desktop/features/workspace/grid_filter_engine.dart'; +import 'package:querya_desktop/features/workspace/grid_groupings_engine.dart'; +import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; void main() { group('GridFilterEngine', () { diff --git a/test/features/main_screen/data_grid_filter_bar_test.dart b/test/features/workspace/data_grid_filter_bar_test.dart similarity index 96% rename from test/features/main_screen/data_grid_filter_bar_test.dart rename to test/features/workspace/data_grid_filter_bar_test.dart index 400152e..e66b29f 100644 --- a/test/features/main_screen/data_grid_filter_bar_test.dart +++ b/test/features/workspace/data_grid_filter_bar_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_filter_bar.dart'; +import 'package:querya_desktop/features/workspace/data_grid_filter_bar.dart'; import '../../support/querya_theme_test_shell.dart'; diff --git a/test/features/main_screen/data_grid_staging_buffer_test.dart b/test/features/workspace/data_grid_staging_buffer_test.dart similarity index 98% rename from test/features/main_screen/data_grid_staging_buffer_test.dart rename to test/features/workspace/data_grid_staging_buffer_test.dart index 48c9285..1406643 100644 --- a/test/features/main_screen/data_grid_staging_buffer_test.dart +++ b/test/features/workspace/data_grid_staging_buffer_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; void main() { group('DataGridStagingBuffer', () { diff --git a/test/features/main_screen/data_grid_value_panel_test.dart b/test/features/workspace/data_grid_value_panel_test.dart similarity index 93% rename from test/features/main_screen/data_grid_value_panel_test.dart rename to test/features/workspace/data_grid_value_panel_test.dart index 71b1f36..d8d89a6 100644 --- a/test/features/main_screen/data_grid_value_panel_test.dart +++ b/test/features/workspace/data_grid_value_panel_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_value_panel.dart'; -import 'package:querya_desktop/features/main_screen/xml_html_formatter.dart'; +import 'package:querya_desktop/features/workspace/data_grid_value_panel.dart'; +import 'package:querya_desktop/features/workspace/xml_html_formatter.dart'; import '../../support/querya_theme_test_shell.dart'; diff --git a/test/features/main_screen/destructive_query_dialog_test.dart b/test/features/workspace/destructive_query_dialog_test.dart similarity index 97% rename from test/features/main_screen/destructive_query_dialog_test.dart rename to test/features/workspace/destructive_query_dialog_test.dart index 52e93f3..8e72fe0 100644 --- a/test/features/main_screen/destructive_query_dialog_test.dart +++ b/test/features/workspace/destructive_query_dialog_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; -import 'package:querya_desktop/features/main_screen/destructive_query_dialog.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/querya_theme_test_shell.dart'; diff --git a/test/features/main_screen/dml_preview_dialog_test.dart b/test/features/workspace/dml_preview_dialog_test.dart similarity index 98% rename from test/features/main_screen/dml_preview_dialog_test.dart rename to test/features/workspace/dml_preview_dialog_test.dart index 0546d65..b07006b 100644 --- a/test/features/main_screen/dml_preview_dialog_test.dart +++ b/test/features/workspace/dml_preview_dialog_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; -import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; +import 'package:querya_desktop/features/workspace/dml_preview_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/querya_theme_test_shell.dart'; diff --git a/test/features/main_screen/grid_cell_editor_test.dart b/test/features/workspace/grid_cell_editor_test.dart similarity index 95% rename from test/features/main_screen/grid_cell_editor_test.dart rename to test/features/workspace/grid_cell_editor_test.dart index cfaa10b..51b4342 100644 --- a/test/features/main_screen/grid_cell_editor_test.dart +++ b/test/features/workspace/grid_cell_editor_test.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; -import 'package:querya_desktop/features/main_screen/grid_cell_editor.dart'; -import 'package:querya_desktop/features/main_screen/grid_cell_popover_inspector.dart'; -import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/grid_cell_editor.dart'; +import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; +import 'package:querya_desktop/features/workspace/result_grid_view.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/querya_theme_test_shell.dart'; diff --git a/test/features/main_screen/grid_data_type_validator_test.dart b/test/features/workspace/grid_data_type_validator_test.dart similarity index 97% rename from test/features/main_screen/grid_data_type_validator_test.dart rename to test/features/workspace/grid_data_type_validator_test.dart index c4b9f54..276040e 100644 --- a/test/features/main_screen/grid_data_type_validator_test.dart +++ b/test/features/workspace/grid_data_type_validator_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/features/main_screen/grid_data_type_validator.dart'; +import 'package:querya_desktop/features/workspace/grid_data_type_validator.dart'; void main() { group('GridDataTypeValidator', () { diff --git a/test/features/main_screen/query_editor_tab_test.dart b/test/features/workspace/query_editor_tab_test.dart similarity index 94% rename from test/features/main_screen/query_editor_tab_test.dart rename to test/features/workspace/query_editor_tab_test.dart index acce6fb..bb94134 100644 --- a/test/features/main_screen/query_editor_tab_test.dart +++ b/test/features/workspace/query_editor_tab_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; +import 'package:querya_desktop/features/workspace/query_editor_tab.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/querya_theme_test_shell.dart'; diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/workspace/results_tab_test.dart similarity index 98% rename from test/features/main_screen/results_tab_test.dart rename to test/features/workspace/results_tab_test.dart index ee8b06e..315d4f3 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; -import 'package:querya_desktop/features/main_screen/data_grid_staging_toolbar.dart'; -import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; -import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_toolbar.dart'; +import 'package:querya_desktop/features/workspace/result_grid_view.dart'; +import 'package:querya_desktop/features/workspace/results_tab.dart'; import '../../support/querya_theme_test_shell.dart'; diff --git a/test/features/main_screen/sql_editor_chrome_test.dart b/test/features/workspace/sql_editor_chrome_test.dart similarity index 98% rename from test/features/main_screen/sql_editor_chrome_test.dart rename to test/features/workspace/sql_editor_chrome_test.dart index 59adee3..53697e4 100644 --- a/test/features/main_screen/sql_editor_chrome_test.dart +++ b/test/features/workspace/sql_editor_chrome_test.dart @@ -4,7 +4,7 @@ import 'package:querya_desktop/core/theme/parser/querya_theme_from_vscode.dart'; import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; -import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; +import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/querya_theme_test_shell.dart'; From c5a21607257e75f3b198c8f286c6da8fd39bed9f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 17:24:02 +0300 Subject: [PATCH 34/47] perf(workspace): memoize DataGrid filtering in ResultsTab - Memoize GridFilterEngine execution in _ResultsTabState - Avoid re-filtering rows and re-allocating filtered rows list on idle build passes - Invalidate cache when filterText, columns, or effectiveRows instance change - Add widget tests for ResultsTab filter memoization stability Closes #650 --- lib/features/workspace/results_tab.dart | 43 +++++++++++---- test/features/workspace/results_tab_test.dart | 53 +++++++++++++++++++ 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index c262f8a..66fcc99 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -66,6 +66,39 @@ class _ResultsTabState extends material.State { GridCalcStats _selectionStats = GridCalcStats.empty; + String? _memoFilterText; + List? _memoColumns; + List>? _memoEffectiveRows; + List> _cachedFilteredRows = const []; + + List> _getFilteredRows( + List> effectiveRows, + List columns, + ) { + if (_memoFilterText == _filterText && + identical(_memoEffectiveRows, effectiveRows) && + identical(_memoColumns, columns)) { + return _cachedFilteredRows; + } + + final filteredIndices = GridFilterEngine.filterRowIndices( + filterText: _filterText, + columns: columns, + rows: effectiveRows, + ); + + final filteredRows = filteredIndices.length == effectiveRows.length + ? effectiveRows + : filteredIndices.map((i) => effectiveRows[i]).toList(); + + _memoFilterText = _filterText; + _memoEffectiveRows = effectiveRows; + _memoColumns = columns; + _cachedFilteredRows = filteredRows; + + return filteredRows; + } + @override Widget build(BuildContext context) { return QueryaFadeSlide( @@ -122,15 +155,7 @@ class _ResultsTabState extends material.State { ? widget.stagingBuffer!.effectiveRows : widget.rows; - final filteredIndices = GridFilterEngine.filterRowIndices( - filterText: _filterText, - columns: widget.columns, - rows: effectiveRows, - ); - - final filteredRows = filteredIndices.length == effectiveRows.length - ? effectiveRows - : filteredIndices.map((i) => effectiveRows[i]).toList(); + final filteredRows = _getFilteredRows(effectiveRows, widget.columns); return material.CallbackShortcuts( bindings: { diff --git a/test/features/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index 315d4f3..d6ac4eb 100644 --- a/test/features/workspace/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -808,5 +808,58 @@ void main() { expect(appliedChanges, 1); }); + + testWidgets('memoizes filtered rows across rebuilds when filterText and rows do not change', (tester) async { + final rows = [ + ['1', 'Alice', 'Engineering'], + ['2', 'Bob', 'Marketing'], + ['3', 'Charlie', 'Engineering'], + ]; + + await tester.pumpWidget( + resultsShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: ResultsTab( + columns: const ['id', 'name', 'department'], + rows: rows, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + expect(find.text('Charlie'), findsOneWidget); + expect(find.text('3 rows'), findsOneWidget); + + // Open quick filter bar + await tester.tap(find.byTooltip('Toggle Quick Filter')); + await tester.pumpAndSettle(); + + // Enter filter text 'Engineering' + await tester.enterText(find.byType(material.TextField), 'Engineering'); + await tester.pumpAndSettle(); + + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Charlie'), findsOneWidget); + expect(find.text('Bob'), findsNothing); + expect(find.text('2 of 3 rows'), findsOneWidget); + + // Trigger a state change / rebuild (e.g. toggle value panel) + await tester.tap(find.byTooltip('Inspect Cell Panel')); + await tester.pumpAndSettle(); + + // Filtered rows should remain stable and correctly preserved + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Charlie'), findsOneWidget); + expect(find.text('Bob'), findsNothing); + expect(find.text('2 of 3 rows'), findsOneWidget); + }); }); } + From 2cfd61ee42961b8d9084b388c07006370f0fe2db Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 17:29:50 +0300 Subject: [PATCH 35/47] perf(workspace): debounce filter input in DataGridFilterBar - Add 150ms debounce Timer for filter text changes in DataGridFilterBar - Provide instant autocomplete suggestions on keystrokes while debouncing full dataset filter callbacks - Execute filter immediately on Enter submission and clear button tap - Cancel pending timers on widget disposal - Add widget tests for debounced filtering and immediate submission Closes #651 --- .../workspace/data_grid_filter_bar.dart | 24 ++++- .../workspace/data_grid_filter_bar_test.dart | 94 +++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/lib/features/workspace/data_grid_filter_bar.dart b/lib/features/workspace/data_grid_filter_bar.dart index 96da7aa..3689cc5 100644 --- a/lib/features/workspace/data_grid_filter_bar.dart +++ b/lib/features/workspace/data_grid_filter_bar.dart @@ -1,3 +1,4 @@ +import 'dart:async' show Timer; import 'package:flutter/material.dart' as material; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -118,6 +119,7 @@ class DataGridFilterBar extends material.StatefulWidget { required this.totalRowCount, required this.filteredRowCount, this.columns = const [], + this.debounceDuration = const Duration(milliseconds: 150), }); final String filterText; @@ -125,6 +127,7 @@ class DataGridFilterBar extends material.StatefulWidget { final int totalRowCount; final int filteredRowCount; final List columns; + final Duration debounceDuration; @override material.State createState() => _DataGridFilterBarState(); @@ -136,6 +139,7 @@ class _DataGridFilterBarState extends material.State { material.OverlayEntry? _overlayEntry; List _suggestions = []; int _highlightedIndex = 0; + Timer? _debounceTimer; @override void initState() { @@ -154,14 +158,30 @@ class _DataGridFilterBarState extends material.State { @override void dispose() { + _debounceTimer?.cancel(); _hideSuggestions(); _controller.dispose(); super.dispose(); } void _onChanged(String val) { - widget.onFilterChanged(val); _updateSuggestions(val); + if (widget.debounceDuration == Duration.zero) { + widget.onFilterChanged(val); + return; + } + _debounceTimer?.cancel(); + _debounceTimer = Timer(widget.debounceDuration, () { + if (mounted) { + widget.onFilterChanged(val); + } + }); + } + + void _onSubmitted(String val) { + _debounceTimer?.cancel(); + _hideSuggestions(); + widget.onFilterChanged(val); } void _updateSuggestions(String val) { @@ -333,6 +353,7 @@ class _DataGridFilterBarState extends material.State { child: material.TextField( controller: _controller, onChanged: _onChanged, + onSubmitted: _onSubmitted, style: TextStyle( fontSize: 12, color: cs.foreground, @@ -373,6 +394,7 @@ class _DataGridFilterBarState extends material.State { constraints: const material.BoxConstraints(minWidth: 20, minHeight: 20), color: cs.mutedForeground, onPressed: () { + _debounceTimer?.cancel(); _controller.clear(); _hideSuggestions(); widget.onFilterChanged(''); diff --git a/test/features/workspace/data_grid_filter_bar_test.dart b/test/features/workspace/data_grid_filter_bar_test.dart index e66b29f..e2b830b 100644 --- a/test/features/workspace/data_grid_filter_bar_test.dart +++ b/test/features/workspace/data_grid_filter_bar_test.dart @@ -71,5 +71,99 @@ void main() { expect(currentFilter, 'stat'); expect(find.text('status'), findsOneWidget); }); + + testWidgets('debounces rapid keystrokes and notifies only once after debounce duration', (tester) async { + final changeNotifications = []; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: DataGridFilterBar( + filterText: '', + onFilterChanged: (text) => changeNotifications.add(text), + totalRowCount: 100, + filteredRowCount: 20, + columns: const ['id', 'name', 'status'], + debounceDuration: const Duration(milliseconds: 150), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Type first letter 'a' + await tester.enterText(find.byType(material.TextField), 'a'); + await tester.pump(const Duration(milliseconds: 50)); + // No notification yet + expect(changeNotifications, isEmpty); + + // Type second letter 'ab' before timer expires + await tester.enterText(find.byType(material.TextField), 'ab'); + await tester.pump(const Duration(milliseconds: 50)); + expect(changeNotifications, isEmpty); + + // Type third letter 'abc' + await tester.enterText(find.byType(material.TextField), 'abc'); + await tester.pump(const Duration(milliseconds: 50)); + expect(changeNotifications, isEmpty); + + // Wait remaining debounce duration (100ms more => 150ms since last keystroke) + await tester.pump(const Duration(milliseconds: 100)); + expect(changeNotifications, ['abc']); + }); + + testWidgets('notifies immediately on submit without waiting for debounce duration', (tester) async { + final changeNotifications = []; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: DataGridFilterBar( + filterText: '', + onFilterChanged: (text) => changeNotifications.add(text), + totalRowCount: 100, + filteredRowCount: 20, + columns: const ['id', 'name', 'status'], + debounceDuration: const Duration(milliseconds: 150), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(material.TextField), 'active'); + // Submit immediately + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(changeNotifications, ['active']); + }); + + testWidgets('cancels pending timer and notifies empty on clear button tap', (tester) async { + final changeNotifications = []; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: DataGridFilterBar( + filterText: 'prefilled', + onFilterChanged: (text) => changeNotifications.add(text), + totalRowCount: 100, + filteredRowCount: 10, + columns: const ['id', 'name', 'status'], + debounceDuration: const Duration(milliseconds: 150), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(material.Icons.close), findsOneWidget); + + await tester.tap(find.byIcon(material.Icons.close)); + await tester.pumpAndSettle(); + + expect(changeNotifications, ['']); + }); }); } From 48554c3307dd6423298985a609a2b8cbd0782f89 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 17:35:55 +0300 Subject: [PATCH 36/47] perf(workspace): offload massive grid selection statistics calculation to background compute - Introduce GridSelectionCalcEngine.computeAdaptive with computeThreshold of 5000 - Offload stats calculations on massive multi-cell selections to background isolate - Update ResultsTab onSelectionValuesChanged to leverage adaptive compute - Add unit tests for synchronous and isolate-based selection statistics calculation Closes #652 --- .../workspace/grid_selection_calc_engine.dart | 19 +++++++++++++++- lib/features/workspace/results_tab.dart | 20 ++++++++++++++--- .../workspace/data_grid_engines_test.dart | 22 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/lib/features/workspace/grid_selection_calc_engine.dart b/lib/features/workspace/grid_selection_calc_engine.dart index c7a111f..b56d129 100644 --- a/lib/features/workspace/grid_selection_calc_engine.dart +++ b/lib/features/workspace/grid_selection_calc_engine.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; -import 'package:flutter/foundation.dart'; +import 'package:flutter/foundation.dart' as foundation; +import 'package:flutter/foundation.dart' show immutable; /// Aggregated statistical results for a selection of grid cell values. @immutable @@ -93,6 +94,22 @@ class GridCalcStats { /// Calculation engine for computing stats (Count, Distinct, Sum, Avg, Median, Min, Max, StdDev) on grid selections. abstract final class GridSelectionCalcEngine { + /// Default threshold for offloading stats calculation to a background isolate. + static const int computeThreshold = 5000; + + /// Computes statistics adaptively: synchronously for small lists (< [computeThreshold]), + /// and offloaded to a background isolate using [compute] for large selections. + static Future computeAdaptive( + List values, { + int threshold = computeThreshold, + }) async { + if (values.isEmpty) return GridCalcStats.empty; + if (values.length < threshold) { + return compute(values); + } + return foundation.compute(compute, values); + } + /// Computes statistics for a list of string cell values. static GridCalcStats compute(List values) { if (values.isEmpty) return GridCalcStats.empty; diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 66fcc99..6335a3e 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -371,9 +371,23 @@ class _ResultsTabState extends material.State { stagingBuffer: widget.stagingBuffer, onRowSelected: (row) => setState(() => _selectedRowIndex = row), onSelectionValuesChanged: (values) { - setState(() { - _selectionStats = GridSelectionCalcEngine.compute(values); - }); + if (values.isEmpty) { + setState(() => _selectionStats = GridCalcStats.empty); + return; + } + if (values.length < GridSelectionCalcEngine.computeThreshold) { + setState(() { + _selectionStats = GridSelectionCalcEngine.compute(values); + }); + } else { + unawaited( + GridSelectionCalcEngine.computeAdaptive(values).then((stats) { + if (mounted) { + setState(() => _selectionStats = stats); + } + }), + ); + } }, onCellFocused: (colName, cellVal, rowIdx) { setState(() { diff --git a/test/features/workspace/data_grid_engines_test.dart b/test/features/workspace/data_grid_engines_test.dart index 3ff65e9..be209d3 100644 --- a/test/features/workspace/data_grid_engines_test.dart +++ b/test/features/workspace/data_grid_engines_test.dart @@ -209,6 +209,28 @@ void main() { final expectedEvenMedian = (evenParsed[499] + evenParsed[500]) / 2.0; expect(evenStats.median, equals(expectedEvenMedian)); }); + + test('computeAdaptive returns empty stats for empty list', () async { + final stats = await GridSelectionCalcEngine.computeAdaptive(const []); + expect(stats, equals(GridCalcStats.empty)); + }); + + test('computeAdaptive computes synchronously below threshold', () async { + final values = ['10', '20', '30']; + final stats = await GridSelectionCalcEngine.computeAdaptive(values, threshold: 10); + expect(stats.totalCount, 3); + expect(stats.sum, 60.0); + expect(stats.average, 20.0); + }); + + test('computeAdaptive computes via background isolate above threshold', () async { + final values = List.generate(100, (i) => '$i'); + final stats = await GridSelectionCalcEngine.computeAdaptive(values, threshold: 50); + expect(stats.totalCount, 100); + expect(stats.min, 0.0); + expect(stats.max, 99.0); + expect(stats.sum, 4950.0); + }); }); group('GridGroupingsEngine', () { From 37d2ea63151b87d8e60b57f3c5889450e10345d8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 17:49:36 +0300 Subject: [PATCH 37/47] fix(sqlite): configure busy_timeout on SQLite connections - Configure PRAGMA busy_timeout = 5000 in SqliteConnection.connect() onOpen - Prevent immediate SQLITE_BUSY / database locked errors during concurrent disk access - Add unit test verifying PRAGMA busy_timeout on connection initialization Closes #656 --- lib/core/database/sqlite_connection.dart | 1 + test/core/database/sqlite_connection_test.dart | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index c699fa2..153bdf9 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -45,6 +45,7 @@ class SqliteConnection { options: OpenDatabaseOptions( readOnly: readOnly, onOpen: (db) async { + await db.execute('PRAGMA busy_timeout = 5000'); if (!readOnly) { await db.execute('PRAGMA foreign_keys = ON'); } diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index cd2612f..d00cfbe 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -69,6 +69,13 @@ void main() { expect(conn.isConnected, false); }); + test('configures PRAGMA busy_timeout to 5000 on connection open', () async { + await conn.connect(); + final res = await conn.execute('PRAGMA busy_timeout'); + expect(res, isNotEmpty); + expect(res.first['timeout'], 5000); + }); + test('testConnection connects, queries and cleans up', () async { final ok = await conn.testConnection(); expect(ok, true); From 0893b3aa5bf4b7b9dfb010d4e9c6bafd94708f3c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 17:54:12 +0300 Subject: [PATCH 38/47] fix(redis): guard disconnect against null check operator exceptions - Guard RedisConnection.disconnect() against uninitialized socket close exceptions - Add forceClose() delegating cleanly to disconnect() - Add unit tests for idempotent disconnect and forceClose Closes #657 --- lib/core/database/redis_connection.dart | 27 ++++++++++++------- test/core/database/redis_connection_test.dart | 10 +++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 5a0a4b4..98ea8ce 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -123,7 +123,9 @@ class RedisConnection { } final result = await _command!.send_object(['PING']); if (result == null || result.toString().toUpperCase() != 'PONG') { - await _conn?.close(); + try { + await _conn?.close(); + } catch (_) {} _conn = null; _command = null; throw RedisConnectionException('PING failed'); @@ -133,17 +135,24 @@ class RedisConnection { } Future disconnect() async { + final wasConnected = _isConnected; _isConnected = false; _command = null; final c = _conn; _conn = null; - try { - await c?.close(); - } catch (e) { - debugPrint('RedisConnection.disconnect: $e'); + if (c != null && wasConnected) { + try { + await c.close(); + } catch (e) { + if (e is! TypeError && !e.toString().contains('Null check operator')) { + debugPrint('RedisConnection.disconnect: $e'); + } + } } } + Future forceClose() => disconnect(); + Future info() async { if (!isConnected || _command == null) { throw StateError('Not connected to Redis'); @@ -464,10 +473,10 @@ class RedisConnectionTestFake extends RedisConnection { final c = _conn; _conn = null; _command = null; - try { - await c?.close(); - } catch (e) { - debugPrint('RedisConnection.disconnect: $e'); + if (c != null) { + try { + await c.close(); + } catch (_) {} } } diff --git a/test/core/database/redis_connection_test.dart b/test/core/database/redis_connection_test.dart index ea60869..c742260 100644 --- a/test/core/database/redis_connection_test.dart +++ b/test/core/database/redis_connection_test.dart @@ -82,6 +82,16 @@ void main() { await conn.disconnect(); expect(conn.isConnected, false); }); + + test('forceClose delegates to disconnect cleanly', () async { + final conn = RedisConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + await conn.forceClose(); + expect(conn.isConnected, false); + }); }); group('RedisConnection.info', () { From 813aab1277c839efb99485d05896b7225a736cb9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 17:59:33 +0300 Subject: [PATCH 39/47] feat(workspace): support binary and BLOB data types in DML mutation engine and grid validator - Add _isBinaryType and dialect-specific hex formatting in TableMutationEngine - Format bytea as E'\\x...' / '\\x...'::bytea for PostgreSQL and X'...' for MySQL / SQLite - Add hex validation for blob, binary, varbinary, bytea, and raw in GridDataTypeValidator - Add unit tests for binary formatting and validation across dialects Closes #658 --- lib/core/database/table_mutation_engine.dart | 35 ++++++++++++++++ .../workspace/grid_data_type_validator.dart | 23 ++++++++++ .../database/table_mutation_engine_test.dart | 42 +++++++++++++++++++ .../grid_data_type_validator_test.dart | 10 +++++ 4 files changed, 110 insertions(+) diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index 7f9f305..11049e3 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -129,6 +129,17 @@ abstract final class TableMutationEngine { lower.contains('number'); } + static bool _isBinaryType(String dataTypeName) { + final lower = dataTypeName.toLowerCase().trim(); + return lower.contains('blob') || + lower.contains('bytea') || + lower.contains('binary') || + lower.contains('varbinary') || + lower == 'raw' || + lower == 'image' || + lower.startsWith('bit'); + } + static const String kNullSentinel = '\u0000__QUERYA_NULL__\u0000'; /// Formats a cell string value safely as an SQL literal or `NULL`. @@ -146,6 +157,30 @@ abstract final class TableMutationEngine { final trimmed = value.trim(); if (dataTypeName != null && dataTypeName.isNotEmpty) { + if (_isBinaryType(dataTypeName)) { + if (trimmed == 'NULL' || trimmed == 'null') { + return 'NULL'; + } + var hex = trimmed; + if (hex.startsWith(r'\x') || + hex.startsWith(r'\X') || + hex.startsWith('0x') || + hex.startsWith('0X')) { + hex = hex.substring(2); + } else if ((hex.startsWith("x'") || hex.startsWith("X'")) && + hex.endsWith("'")) { + hex = hex.substring(2, hex.length - 1); + } + final cleanHex = hex.replaceAll(RegExp(r'[^0-9a-fA-F]'), ''); + switch (dialect) { + case SqlDialect.postgres: + return "'\\x$cleanHex'::bytea"; + case SqlDialect.mysql: + case SqlDialect.sqlite: + return "X'$cleanHex'"; + } + } + if (_isTextType(dataTypeName)) { // String columns: preserve literal 'NULL' or 'null' as a text string final escaped = value.replaceAll("'", "''"); diff --git a/lib/features/workspace/grid_data_type_validator.dart b/lib/features/workspace/grid_data_type_validator.dart index ae999c4..aa3a0d3 100644 --- a/lib/features/workspace/grid_data_type_validator.dart +++ b/lib/features/workspace/grid_data_type_validator.dart @@ -99,6 +99,29 @@ abstract final class GridDataTypeValidator { return null; } + // Binary / BLOB / Bytea + if (type.contains('blob') || + type.contains('bytea') || + type.contains('binary') || + type.contains('varbinary') || + type == 'raw' || + type == 'image') { + var hex = value.trim(); + if (hex.startsWith(r'\x') || + hex.startsWith(r'\X') || + hex.startsWith('0x') || + hex.startsWith('0X')) { + hex = hex.substring(2); + } else if ((hex.startsWith("x'") || hex.startsWith("X'")) && + hex.endsWith("'")) { + hex = hex.substring(2, hex.length - 1); + } + if (!RegExp(r'^[0-9a-fA-F]*$').hasMatch(hex) || hex.length.isOdd) { + return 'Expected valid hex string (e.g. \\xDEADBEEF, 0x12AB, or DEADBEEF)'; + } + return null; + } + return null; } } diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart index b96b7b7..af23cd4 100644 --- a/test/core/database/table_mutation_engine_test.dart +++ b/test/core/database/table_mutation_engine_test.dart @@ -281,5 +281,47 @@ void main() { 'NULL', ); }); + + test('formats binary and BLOB literals correctly across dialects', () { + // PostgreSQL bytea + expect( + TableMutationEngine.formatLiteral( + r'\xDEADBEEF', + SqlDialect.postgres, + dataTypeName: 'bytea', + ), + r"'\xDEADBEEF'::bytea", + ); + + // MySQL blob / varbinary + expect( + TableMutationEngine.formatLiteral( + '0x12AB', + SqlDialect.mysql, + dataTypeName: 'blob', + ), + "X'12AB'", + ); + + // SQLite blob + expect( + TableMutationEngine.formatLiteral( + "X'CAFE'", + SqlDialect.sqlite, + dataTypeName: 'blob', + ), + "X'CAFE'", + ); + + // NULL for binary + expect( + TableMutationEngine.formatLiteral( + 'NULL', + SqlDialect.postgres, + dataTypeName: 'bytea', + ), + 'NULL', + ); + }); }); } diff --git a/test/features/workspace/grid_data_type_validator_test.dart b/test/features/workspace/grid_data_type_validator_test.dart index 276040e..9e5f051 100644 --- a/test/features/workspace/grid_data_type_validator_test.dart +++ b/test/features/workspace/grid_data_type_validator_test.dart @@ -75,10 +75,20 @@ void main() { ); }); + test('validates binary / blob / bytea types', () { + expect(GridDataTypeValidator.validate(r'\xDEADBEEF', dataTypeName: 'bytea'), isNull); + expect(GridDataTypeValidator.validate('0x12AB', dataTypeName: 'blob'), isNull); + expect(GridDataTypeValidator.validate("X'CAFE'", dataTypeName: 'binary'), isNull); + expect(GridDataTypeValidator.validate('DEADBEEF', dataTypeName: 'varbinary'), isNull); + expect(GridDataTypeValidator.validate('not_hex', dataTypeName: 'blob'), isNotNull); + expect(GridDataTypeValidator.validate('123', dataTypeName: 'bytea'), isNotNull); // odd length hex + }); + test('allows empty and NULL values regardless of type', () { expect(GridDataTypeValidator.validate('', dataTypeName: 'int'), isNull); expect(GridDataTypeValidator.validate('NULL', dataTypeName: 'uuid'), isNull); expect(GridDataTypeValidator.validate('null', dataTypeName: 'json'), isNull); + expect(GridDataTypeValidator.validate('NULL', dataTypeName: 'blob'), isNull); }); }); } From acc44754eb36279d23f111ca581b31edb3676a60 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 18:11:23 +0300 Subject: [PATCH 40/47] feat(workspace): implement comprehensive keyboard navigation and multi-cell range selection in DataGrid - Add _navigateCell, _jumpToCell, and _selectAll in ResultGridView - Bind Arrow keys (Up, Down, Left, Right) and Shift+Arrows for multi-cell range selection - Bind Home, End, Ctrl+Home, Ctrl+End, PageUp, PageDown, and Ctrl+A / Meta+A - Implement _scrollToCell for auto-scrolling active cells into viewport view - Add widget tests covering cell keyboard navigation, range selection, and shortcuts Closes #662 --- lib/features/workspace/result_grid_view.dart | 311 +++++++++++++++++- .../grid_keyboard_navigation_test.dart | 253 ++++++++++++++ 2 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 test/features/workspace/grid_keyboard_navigation_test.dart diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index c10b043..b28004e 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -412,6 +412,7 @@ class _VirtualResultGridState extends material.State { List> _sortedRows = const []; ResultGridCellCoordinate? _selectionAnchor; + ResultGridCellCoordinate? _selectionFocus; ResultGridSelection? _selection; ResultGridCellCoordinate? _editingCell; @@ -453,6 +454,7 @@ class _VirtualResultGridState extends material.State { _sortColumnIndex = null; _sortOrder = null; _selectionAnchor = null; + _selectionFocus = null; _selection = null; _editingCell = null; widget.onRowSelected?.call(null); @@ -473,10 +475,16 @@ class _VirtualResultGridState extends material.State { void _startEditing(int row, int column) { if (widget.stagingBuffer == null) return; - if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; + if (row < 0 || + row >= _sortedRows.length || + column < 0 || + column >= widget.columns.length) { + return; + } setState(() { _editingCell = ResultGridCellCoordinate(row, column); _selectionAnchor = _editingCell; + _selectionFocus = _editingCell; _selection = ResultGridSelection( startRow: row, startColumn: column, @@ -529,7 +537,8 @@ class _VirtualResultGridState extends material.State { endColumn: column - 1, ); } else if (row > 0) { - _editingCell = ResultGridCellCoordinate(row - 1, widget.columns.length - 1); + _editingCell = + ResultGridCellCoordinate(row - 1, widget.columns.length - 1); _selection = ResultGridSelection( startRow: row - 1, startColumn: widget.columns.length - 1, @@ -566,7 +575,14 @@ class _VirtualResultGridState extends material.State { } else { _editingCell = null; } + if (_editingCell != null) { + _selectionAnchor = _editingCell; + _selectionFocus = _editingCell; + widget.onRowSelected?.call(_editingCell!.row); + _scrollToCell(_editingCell!.row, _editingCell!.column); + } }); + _notifySelectionAndFocus(); } void _cancelEdit() { @@ -683,12 +699,14 @@ class _VirtualResultGridState extends material.State { setState(() { final coord = ResultGridCellCoordinate(row, column); if (isShift && _selectionAnchor != null) { + _selectionFocus = coord; _selection = ResultGridSelection.fromPoints( anchor: _selectionAnchor!, focus: coord, ); } else { _selectionAnchor = coord; + _selectionFocus = coord; _selection = ResultGridSelection( startRow: row, startColumn: column, @@ -707,7 +725,9 @@ class _VirtualResultGridState extends material.State { _copySelection(); } else { setState(() { - _selectionAnchor = ResultGridCellCoordinate(row, column); + final coord = ResultGridCellCoordinate(row, column); + _selectionAnchor = coord; + _selectionFocus = coord; _selection = ResultGridSelection( startRow: row, startColumn: column, @@ -765,6 +785,168 @@ class _VirtualResultGridState extends material.State { ); } + void _scrollToCell(int row, int col) { + if (!mounted) return; + final rowHeight = _scaledRowHeight(context); + final headerHeight = _scaledHeaderHeight(context); + + // Vertical scroll + if (_verticalController.hasClients) { + final targetTop = row * rowHeight; + final targetBottom = targetTop + rowHeight; + final currentOffset = _verticalController.offset; + final viewportHeight = + _verticalController.position.viewportDimension - headerHeight; + + if (targetTop < currentOffset) { + _verticalController.jumpTo(targetTop.clamp( + 0.0, + _verticalController.position.maxScrollExtent, + )); + } else if (targetBottom > currentOffset + viewportHeight && + viewportHeight > 0) { + final newOffset = (targetBottom - viewportHeight).clamp( + 0.0, + _verticalController.position.maxScrollExtent, + ); + _verticalController.jumpTo(newOffset); + } + } + + // Horizontal scroll + if (_horizontalController.hasClients && + col >= 0 && + col < _columnWidths.length) { + final colLeft = _columnOffsets[col]; + final colRight = colLeft + _columnWidths[col]; + final currentOffset = _horizontalController.offset; + final viewportWidth = _horizontalController.position.viewportDimension; + + if (colLeft < currentOffset) { + _horizontalController.jumpTo(colLeft.clamp( + 0.0, + _horizontalController.position.maxScrollExtent, + )); + } else if (colRight > currentOffset + viewportWidth && + viewportWidth > 0) { + final newOffset = (colRight - viewportWidth).clamp( + 0.0, + _horizontalController.position.maxScrollExtent, + ); + _horizontalController.jumpTo(newOffset); + } + } + } + + void _navigateCell(int dRow, int dCol, {bool extendSelection = false}) { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + if (_editingCell != null) return; + + if (_selectionAnchor == null) { + setState(() { + _selectionAnchor = const ResultGridCellCoordinate(0, 0); + _selectionFocus = const ResultGridCellCoordinate(0, 0); + _selection = const ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 0, + ); + }); + widget.onRowSelected?.call(0); + _scrollToCell(0, 0); + _notifySelectionAndFocus(); + return; + } + + final currentFocus = _selectionFocus ?? _selectionAnchor!; + final newRow = (currentFocus.row + dRow).clamp(0, _sortedRows.length - 1); + final newCol = + (currentFocus.column + dCol).clamp(0, widget.columns.length - 1); + final newFocus = ResultGridCellCoordinate(newRow, newCol); + + setState(() { + if (extendSelection) { + _selectionFocus = newFocus; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: newFocus, + ); + } else { + _selectionAnchor = newFocus; + _selectionFocus = newFocus; + _selection = ResultGridSelection( + startRow: newRow, + startColumn: newCol, + endRow: newRow, + endColumn: newCol, + ); + widget.onRowSelected?.call(newRow); + } + }); + + _scrollToCell(newRow, newCol); + _notifySelectionAndFocus(); + } + + void _jumpToCell({int? row, int? column, bool extendSelection = false}) { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + if (_editingCell != null) return; + + final currentFocus = _selectionFocus ?? + _selectionAnchor ?? + const ResultGridCellCoordinate(0, 0); + final newRow = (row ?? currentFocus.row).clamp(0, _sortedRows.length - 1); + final newCol = + (column ?? currentFocus.column).clamp(0, widget.columns.length - 1); + final newFocus = ResultGridCellCoordinate(newRow, newCol); + + setState(() { + if (extendSelection) { + _selectionAnchor ??= currentFocus; + _selectionFocus = newFocus; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: newFocus, + ); + } else { + _selectionAnchor = newFocus; + _selectionFocus = newFocus; + _selection = ResultGridSelection( + startRow: newRow, + startColumn: newCol, + endRow: newRow, + endColumn: newCol, + ); + widget.onRowSelected?.call(newRow); + } + }); + + _scrollToCell(newRow, newCol); + _notifySelectionAndFocus(); + } + + void _selectAll() { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + if (_editingCell != null) return; + + setState(() { + _selectionAnchor = const ResultGridCellCoordinate(0, 0); + _selectionFocus = ResultGridCellCoordinate( + _sortedRows.length - 1, + widget.columns.length - 1, + ); + _selection = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: _sortedRows.length - 1, + endColumn: widget.columns.length - 1, + ); + }); + + _notifySelectionAndFocus(); + } + @override material.Widget build(material.BuildContext context) { if (_widthsNeedUpdate && !_userHasResized) { @@ -840,7 +1022,9 @@ class _VirtualResultGridState extends material.State { setState(() { _selection = null; _selectionAnchor = null; + _selectionFocus = null; }); + _notifySelectionAndFocus(); } }, const material.SingleActivator( @@ -883,6 +1067,127 @@ class _VirtualResultGridState extends material.State { ); } }, + + // Navigation: Arrows + const material.SingleActivator(LogicalKeyboardKey.arrowDown): () => + _navigateCell(1, 0), + const material.SingleActivator(LogicalKeyboardKey.arrowUp): () => + _navigateCell(-1, 0), + const material.SingleActivator(LogicalKeyboardKey.arrowRight): () => + _navigateCell(0, 1), + const material.SingleActivator(LogicalKeyboardKey.arrowLeft): () => + _navigateCell(0, -1), + + // Navigation: Shift + Arrows (range selection) + const material.SingleActivator( + LogicalKeyboardKey.arrowDown, + shift: true, + ): () => _navigateCell(1, 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.arrowUp, + shift: true, + ): () => _navigateCell(-1, 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.arrowRight, + shift: true, + ): () => _navigateCell(0, 1, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.arrowLeft, + shift: true, + ): () => _navigateCell(0, -1, extendSelection: true), + + // Navigation: Home / End (column jump) + const material.SingleActivator(LogicalKeyboardKey.home): () => + _jumpToCell(column: 0), + const material.SingleActivator( + LogicalKeyboardKey.home, + shift: true, + ): () => _jumpToCell(column: 0, extendSelection: true), + const material.SingleActivator(LogicalKeyboardKey.end): () => + _jumpToCell(column: widget.columns.length - 1), + const material.SingleActivator( + LogicalKeyboardKey.end, + shift: true, + ): () => _jumpToCell( + column: widget.columns.length - 1, + extendSelection: true, + ), + + // Navigation: Ctrl+Home / Ctrl+End (table start / end) + const material.SingleActivator( + LogicalKeyboardKey.home, + control: true, + ): () => _jumpToCell(row: 0, column: 0), + const material.SingleActivator( + LogicalKeyboardKey.home, + meta: true, + ): () => _jumpToCell(row: 0, column: 0), + const material.SingleActivator( + LogicalKeyboardKey.home, + control: true, + shift: true, + ): () => _jumpToCell(row: 0, column: 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.home, + meta: true, + shift: true, + ): () => _jumpToCell(row: 0, column: 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.end, + control: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + ), + const material.SingleActivator( + LogicalKeyboardKey.end, + meta: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + ), + const material.SingleActivator( + LogicalKeyboardKey.end, + control: true, + shift: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + extendSelection: true, + ), + const material.SingleActivator( + LogicalKeyboardKey.end, + meta: true, + shift: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + extendSelection: true, + ), + + // Navigation: PageUp / PageDown + const material.SingleActivator(LogicalKeyboardKey.pageDown): () => + _navigateCell(20, 0), + const material.SingleActivator( + LogicalKeyboardKey.pageDown, + shift: true, + ): () => _navigateCell(20, 0, extendSelection: true), + const material.SingleActivator(LogicalKeyboardKey.pageUp): () => + _navigateCell(-20, 0), + const material.SingleActivator( + LogicalKeyboardKey.pageUp, + shift: true, + ): () => _navigateCell(-20, 0, extendSelection: true), + + // Select All: Ctrl+A / Meta+A + const material.SingleActivator( + LogicalKeyboardKey.keyA, + control: true, + ): () => _selectAll(), + const material.SingleActivator( + LogicalKeyboardKey.keyA, + meta: true, + ): () => _selectAll(), }, child: material.Focus( focusNode: _focusNode, diff --git a/test/features/workspace/grid_keyboard_navigation_test.dart b/test/features/workspace/grid_keyboard_navigation_test.dart new file mode 100644 index 0000000..211ef84 --- /dev/null +++ b/test/features/workspace/grid_keyboard_navigation_test.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/result_grid_view.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +material.Widget _testShell({required material.Widget child}) { + return queryaThemeTestShell( + child: material.Scaffold( + body: child, + ), + ); +} + +void main() { + group('VirtualResultGrid Keyboard Navigation & Selection', () { + testWidgets('navigates cells with arrow keys', (tester) async { + List? selectedValues; + String? focusedVal; + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name', 'role'], + rows: const [ + ['1', 'Alice', 'Admin'], + ['2', 'Bob', 'User'], + ['3', 'Charlie', 'Manager'], + ], + onSelectionValuesChanged: (vals) => selectedValues = vals, + onCellFocused: (col, val, row) => focusedVal = val, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap on 'Alice' (row 0, col 1) + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Alice']); + expect(focusedVal, 'Alice'); + + // Press ArrowDown -> should move to 'Bob' (row 1, col 1) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Bob']); + expect(focusedVal, 'Bob'); + + // Press ArrowRight -> should move to 'User' (row 1, col 2) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + expect(selectedValues, ['User']); + expect(focusedVal, 'User'); + + // Press ArrowUp -> should move to 'Admin' (row 0, col 2) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Admin']); + expect(focusedVal, 'Admin'); + + // Press ArrowLeft -> should move back to 'Alice' (row 0, col 1) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Alice']); + expect(focusedVal, 'Alice'); + }); + + testWidgets('extends rectangular range selection with Shift+Arrow keys', (tester) async { + List? selectedValues; + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name', 'role'], + rows: const [ + ['1', 'Alice', 'Admin'], + ['2', 'Bob', 'User'], + ['3', 'Charlie', 'Manager'], + ], + onSelectionValuesChanged: (vals) => selectedValues = vals, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap on top-left '1' (row 0, col 0) + await tester.tap(find.text('1')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + + expect(selectedValues, ['1']); + + // Shift + ArrowDown -> extends selection to rows 0..1, col 0 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + + expect(selectedValues, ['1', '2']); + + // Shift + ArrowRight -> extends selection to 2x2 box: (row 0..1, col 0..1) + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + + expect(selectedValues, ['1', 'Alice', '2', 'Bob']); + }); + + testWidgets('selects all cells with Ctrl+A / Meta+A', (tester) async { + List? selectedValues; + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name'], + rows: const [ + ['1', 'Alice'], + ['2', 'Bob'], + ], + onSelectionValuesChanged: (vals) => selectedValues = vals, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap on Alice + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Alice']); + + // Press Ctrl+A + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(selectedValues, ['1', 'Alice', '2', 'Bob']); + }); + + testWidgets('jumps to start and end with Home / End and Ctrl+Home / Ctrl+End', (tester) async { + List? selectedValues; + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['c1', 'c2', 'c3'], + rows: const [ + ['r0c0', 'r0c1', 'r0c2'], + ['r1c0', 'r1c1', 'r1c2'], + ['r2c0', 'r2c1', 'r2c2'], + ], + onSelectionValuesChanged: (vals) => selectedValues = vals, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap on middle cell 'r1c1' + await tester.tap(find.text('r1c1')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + expect(selectedValues, ['r1c1']); + + // Press Home -> moves to 'r1c0' + await tester.sendKeyEvent(LogicalKeyboardKey.home); + await tester.pumpAndSettle(); + expect(selectedValues, ['r1c0']); + + // Press End -> moves to 'r1c2' + await tester.sendKeyEvent(LogicalKeyboardKey.end); + await tester.pumpAndSettle(); + expect(selectedValues, ['r1c2']); + + // Press Ctrl+Home -> moves to top-left 'r0c0' + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.home); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(selectedValues, ['r0c0']); + + // Press Ctrl+End -> moves to bottom-right 'r2c2' + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.end); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(selectedValues, ['r2c2']); + }); + + testWidgets('presses F2 to start editing on selected cell', (tester) async { + final staging = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ], + ); + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name'], + rows: const [ + ['1', 'Alice'], + ], + stagingBuffer: staging, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap once to select Alice + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + + // Press F2 -> opens inline editor + await tester.sendKeyEvent(LogicalKeyboardKey.f2); + await tester.pumpAndSettle(); + + expect(find.byType(material.TextField), findsOneWidget); + }); + }); +} From de4c626de800bce0d42a36a34300ea7b927af8f5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 18:17:44 +0300 Subject: [PATCH 41/47] feat(workspace): expand rich cell inspector modal with XML/HTML formatting, hex viewer, and shortcut support - Add Format XML/HTML, Format Hex, and Wrap/No-Wrap buttons to GridCellInspectorDialog - Add real-time text statistics bar (lines, characters, byte size) - Support keyboard shortcuts in inspector (Ctrl+Enter / Meta+Enter to apply, Alt+N for NULL) - Add Ctrl+I / Meta+I shortcut in VirtualResultGrid to inspect active cell - Add widget tests for XML, Hex formatting, wrap toggles, and shortcut handling Closes #663 --- .../grid_cell_popover_inspector.dart | 430 ++++++++++++------ lib/features/workspace/result_grid_view.dart | 23 +- .../workspace/grid_cell_editor_test.dart | 117 +++++ 3 files changed, 441 insertions(+), 129 deletions(-) diff --git a/lib/features/workspace/grid_cell_popover_inspector.dart b/lib/features/workspace/grid_cell_popover_inspector.dart index 8f0d744..9d1f7b4 100644 --- a/lib/features/workspace/grid_cell_popover_inspector.dart +++ b/lib/features/workspace/grid_cell_popover_inspector.dart @@ -1,14 +1,16 @@ import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/workspace/xml_html_formatter.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Opens a rich modal inspector for viewing and editing large text or JSON values. +/// Opens a rich modal inspector for viewing and editing large text, JSON, XML, or BLOB values. Future showGridCellInspectorDialog({ required material.BuildContext context, required String columnName, required String initialValue, int? rowIndex, + String? dataTypeName, }) { return showAppDialog( context: context, @@ -16,6 +18,7 @@ Future showGridCellInspectorDialog({ columnName: columnName, initialValue: initialValue, rowIndex: rowIndex, + dataTypeName: dataTypeName, ), ); } @@ -25,11 +28,13 @@ class _GridCellInspectorDialog extends material.StatefulWidget { required this.columnName, required this.initialValue, this.rowIndex, + this.dataTypeName, }); final String columnName; final String initialValue; final int? rowIndex; + final String? dataTypeName; @override material.State<_GridCellInspectorDialog> createState() => @@ -40,6 +45,7 @@ class _GridCellInspectorDialogState extends material.State<_GridCellInspectorDialog> { late final material.TextEditingController _controller; bool _isNull = false; + bool _wordWrap = true; @override void initState() { @@ -48,10 +54,16 @@ class _GridCellInspectorDialogState _controller = material.TextEditingController( text: _isNull ? '' : widget.initialValue, ); + _controller.addListener(_onTextChanged); + } + + void _onTextChanged() { + if (mounted) setState(() {}); } @override void dispose() { + _controller.removeListener(_onTextChanged); _controller.dispose(); super.dispose(); } @@ -82,6 +94,42 @@ class _GridCellInspectorDialogState } } + void _formatXml() { + try { + final pretty = XmlHtmlFormatter.format(_controller.text); + setState(() { + _isNull = false; + _controller.text = pretty; + }); + } catch (_) {} + } + + void _formatHex() { + var raw = _controller.text.trim(); + var prefix = ''; + if (raw.startsWith(r'\x') || raw.startsWith(r'\X')) { + prefix = r'\x'; + raw = raw.substring(2); + } else if (raw.startsWith('0x') || raw.startsWith('0X')) { + prefix = '0x'; + raw = raw.substring(2); + } + final clean = raw.replaceAll(RegExp(r'\s+'), '').toUpperCase(); + if (clean.isEmpty) return; + + final pairs = []; + for (var i = 0; i < clean.length; i += 2) { + final end = (i + 2 <= clean.length) ? i + 2 : clean.length; + pairs.add(clean.substring(i, end)); + } + final formatted = + prefix.isNotEmpty ? '$prefix ${pairs.join(' ')}' : pairs.join(' '); + setState(() { + _isNull = false; + _controller.text = formatted; + }); + } + void _setNull() { setState(() { _isNull = true; @@ -103,156 +151,282 @@ class _GridCellInspectorDialogState return false; } + bool _isXml() { + final text = _controller.text.trim(); + if (text.startsWith('<') && text.endsWith('>')) { + return XmlHtmlFormatter.validate(text) == null; + } + return false; + } + + bool _isHex() { + final text = _controller.text.trim(); + if (text.length < 4) return false; + var hex = text; + if (hex.startsWith(r'\x') || + hex.startsWith(r'\X') || + hex.startsWith('0x') || + hex.startsWith('0X')) { + hex = hex.substring(2); + } + final clean = hex.replaceAll(RegExp(r'\s+'), ''); + return clean.isNotEmpty && + clean.length.isEven && + RegExp(r'^[0-9a-fA-F]+$').hasMatch(clean); + } + + void _apply() { + final result = _isNull ? 'NULL' : _controller.text; + material.Navigator.of(context).pop(result); + } + @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; final rowLabel = widget.rowIndex != null ? ' (Row ${widget.rowIndex! + 1})' : ''; + final text = _isNull ? '' : _controller.text; + final linesCount = text.isEmpty ? 0 : '\n'.allMatches(text).length + 1; + final charsCount = text.length; + final bytesCount = utf8.encode(text).length; - return material.Dialog( - backgroundColor: cs.card, - shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(8), - side: material.BorderSide(color: cs.border, width: 1), - ), - child: material.ConstrainedBox( - constraints: const material.BoxConstraints( - minWidth: 500, - maxWidth: 720, - minHeight: 380, - maxHeight: 560, + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): _apply, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): _apply, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + control: true, + ): _apply, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + meta: true, + ): _apply, + const material.SingleActivator( + LogicalKeyboardKey.keyN, + alt: true, + ): _setNull, + }, + child: material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide(color: cs.border, width: 1), ), - child: material.Padding( - padding: const material.EdgeInsets.all(16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Header - material.Row( - children: [ - material.Icon( - material.Icons.data_object_rounded, - size: 18, - color: cs.primary, - ), - const Gap(8), - material.Expanded( - child: Text( - 'Edit ${widget.columnName}$rowLabel', - ).semiBold(), - ), - if (_isJson()) ...[ + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 520, + maxWidth: 760, + minHeight: 400, + maxHeight: 580, + ), + child: material.Padding( + padding: const material.EdgeInsets.all(16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Row( + children: [ + material.Icon( + material.Icons.data_object_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: Text( + 'Edit ${widget.columnName}$rowLabel', + ).semiBold(), + ), + if (_isJson()) ...[ + GhostButton( + density: ButtonDensity.compact, + onPressed: _formatJson, + leading: const material.Icon( + material.Icons.format_align_left_rounded, + size: 14, + ), + child: const Text('Format JSON'), + ), + const Gap(6), + GhostButton( + density: ButtonDensity.compact, + onPressed: _minifyJson, + leading: const material.Icon( + material.Icons.compress_rounded, + size: 14, + ), + child: const Text('Minify'), + ), + const Gap(6), + ], + if (_isXml()) ...[ + GhostButton( + density: ButtonDensity.compact, + onPressed: _formatXml, + leading: const material.Icon( + material.Icons.code_rounded, + size: 14, + ), + child: const Text('Format XML'), + ), + const Gap(6), + ], + if (_isHex()) ...[ + GhostButton( + density: ButtonDensity.compact, + onPressed: _formatHex, + leading: const material.Icon( + material.Icons.grid_view_rounded, + size: 14, + ), + child: const Text('Format Hex'), + ), + const Gap(6), + ], GhostButton( density: ButtonDensity.compact, - onPressed: _formatJson, - leading: const material.Icon( - material.Icons.format_align_left_rounded, + onPressed: () => setState(() => _wordWrap = !_wordWrap), + leading: material.Icon( + _wordWrap + ? material.Icons.wrap_text_rounded + : material.Icons.notes_rounded, size: 14, ), - child: const Text('Format JSON'), + child: Text(_wordWrap ? 'Wrap' : 'No Wrap'), ), const Gap(6), GhostButton( density: ButtonDensity.compact, - onPressed: _minifyJson, - leading: const material.Icon( - material.Icons.compress_rounded, - size: 14, - ), - child: const Text('Minify'), + onPressed: _isNull ? null : _setNull, + child: const Text('Set NULL'), ), - const Gap(6), ], - GhostButton( - density: ButtonDensity.compact, - onPressed: _isNull ? null : _setNull, - child: const Text('Set NULL'), - ), - ], - ), - const Gap(12), + ), + const Gap(12), - // Editor Body - material.Expanded( - child: material.Container( - decoration: material.BoxDecoration( - color: cs.background, - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: _isNull - ? cs.primary.withValues(alpha: 0.5) - : cs.border, - width: 1, + // Editor Body + material.Expanded( + child: material.Container( + decoration: material.BoxDecoration( + color: cs.background, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: _isNull + ? cs.primary.withValues(alpha: 0.5) + : cs.border, + width: 1, + ), ), - ), - child: _isNull - ? material.Center( - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Value is NULL').muted().semiBold(), - const Gap(8), - GhostButton( - density: ButtonDensity.compact, - onPressed: () => setState(() => _isNull = false), - child: const Text('Enter text value'), + child: _isNull + ? material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Value is NULL').muted().semiBold(), + const Gap(8), + GhostButton( + density: ButtonDensity.compact, + onPressed: () => + setState(() => _isNull = false), + child: const Text('Enter text value'), + ), + ], + ), + ) + : _wordWrap + ? material.TextField( + controller: _controller, + maxLines: null, + expands: true, + autofocus: true, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: material.EdgeInsets.all(12), + hintText: 'Enter cell value…', + ), + ) + : material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.SingleChildScrollView( + scrollDirection: material.Axis.vertical, + child: material.SizedBox( + width: 3000, + child: material.TextField( + controller: _controller, + maxLines: null, + autofocus: true, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: + material.EdgeInsets.all(12), + hintText: 'Enter cell value…', + ), + ), + ), + ), ), - ], - ), - ) - : material.TextField( - controller: _controller, - maxLines: null, - expands: true, - autofocus: true, - style: const material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - ), - decoration: const material.InputDecoration( - border: material.InputBorder.none, - contentPadding: material.EdgeInsets.all(12), - hintText: 'Enter cell value…', - ), - ), + ), ), - ), - const Gap(12), + const Gap(12), - // Footer - material.Row( - children: [ - GhostButton( - density: ButtonDensity.compact, - onPressed: () { - Clipboard.setData( - ClipboardData(text: _isNull ? 'NULL' : _controller.text), - ); - }, - leading: const material.Icon( - material.Icons.copy_rounded, - size: 14, + // Footer + material.Row( + children: [ + GhostButton( + density: ButtonDensity.compact, + onPressed: () { + Clipboard.setData( + ClipboardData( + text: _isNull ? 'NULL' : _controller.text), + ); + }, + leading: const material.Icon( + material.Icons.copy_rounded, + size: 14, + ), + child: const Text('Copy'), ), - child: const Text('Copy'), - ), - const material.Spacer(), - OutlineButton( - density: ButtonDensity.compact, - onPressed: () => material.Navigator.of(context).pop(null), - child: const Text('Cancel'), - ), - const Gap(8), - PrimaryButton( - density: ButtonDensity.compact, - onPressed: () { - final result = _isNull ? 'NULL' : _controller.text; - material.Navigator.of(context).pop(result); - }, - child: const Text('Apply'), - ), - ], - ), - ], + const Gap(12), + if (!_isNull) + material.Text( + '$linesCount ${linesCount == 1 ? "line" : "lines"} · $charsCount chars · $bytesCount B', + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + const material.Spacer(), + OutlineButton( + density: ButtonDensity.compact, + onPressed: () => material.Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + const Gap(8), + PrimaryButton( + density: ButtonDensity.compact, + onPressed: _apply, + child: const Text('Apply'), + ), + ], + ), + ], + ), ), ), ), diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index b28004e..6732d6c 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -592,7 +592,12 @@ class _VirtualResultGridState extends material.State { } Future _openInspector(int row, int column) async { - if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; + if (row < 0 || + row >= _sortedRows.length || + column < 0 || + column >= widget.columns.length) { + return; + } final colName = widget.columns[column]; final currentVal = column < _sortedRows[row].length ? _sortedRows[row][column] : ''; final result = await showGridCellInspectorDialog( @@ -1055,6 +1060,22 @@ class _VirtualResultGridState extends material.State { _openInspector(_selection!.startRow, _selection!.startColumn); } }, + const material.SingleActivator( + LogicalKeyboardKey.keyI, + control: true, + ): () { + if (_selection != null && _editingCell == null) { + _openInspector(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyI, + meta: true, + ): () { + if (_selection != null && _editingCell == null) { + _openInspector(_selection!.startRow, _selection!.startColumn); + } + }, const material.SingleActivator( LogicalKeyboardKey.keyN, alt: true, diff --git a/test/features/workspace/grid_cell_editor_test.dart b/test/features/workspace/grid_cell_editor_test.dart index 51b4342..82203dd 100644 --- a/test/features/workspace/grid_cell_editor_test.dart +++ b/test/features/workspace/grid_cell_editor_test.dart @@ -172,6 +172,123 @@ void main() { expect(result, 'NULL'); }); + + testWidgets('formats XML content in dialog', (tester) async { + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'payload', + initialValue: 'data', + rowIndex: 0, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Format XML'), findsOneWidget); + + await tester.tap(find.text('Format XML')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.contains('\n'), isTrue); + expect(result!.contains(' material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'bin_data', + initialValue: r'\xdeadbeef1234', + rowIndex: 0, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Format Hex'), findsOneWidget); + + await tester.tap(find.text('Format Hex')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(result, r'\x DE AD BE EF 12 34'); + }); + + testWidgets('toggles wrap and applies with Ctrl+Enter shortcut', (tester) async { + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'notes', + initialValue: 'Hello world', + rowIndex: 0, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + // Toggle wrap + expect(find.text('Wrap'), findsOneWidget); + await tester.tap(find.text('Wrap')); + await tester.pumpAndSettle(); + expect(find.text('No Wrap'), findsOneWidget); + + // Verify metrics info text rendered + expect(find.textContaining('1 line · 11 chars'), findsOneWidget); + + // Send Ctrl+Enter to apply + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(result, 'Hello world'); + }); }); group('VirtualResultGrid Inline Editing Integration', () { From 9957075edd48c9dd8ad829ce926be8c5ae274777 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 18:37:32 +0300 Subject: [PATCH 42/47] fix(extensions): harden plugin rpc bridge against stdin flush hangs and manifest request timeouts (close #666) --- .../extensions/extension_driver_session.dart | 27 ++++- .../models/sandbox_capabilities.dart | 6 + .../extensions/rpc/json_rpc_stdio_client.dart | 12 +- .../extensions/rpc/plugin_rpc_bridge.dart | 16 ++- .../extensions/rpc/plugin_rpc_exceptions.dart | 17 +++ .../sandbox/sandbox_stderr_pipe.dart | 9 ++ .../extension_driver_session_test.dart | 14 +++ .../rpc/plugin_rpc_bridge_test.dart | 104 ++++++++++++++++++ 8 files changed, 199 insertions(+), 6 deletions(-) diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index 47b0c3f..74e6c78 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -52,7 +52,16 @@ class ExtensionDriverSession { final manifest = await _resolveManifestForRow(row); final hydrated = await _hydrateSecrets(row); - final bridge = await _startBridge(manifest); + late final PluginRpcBridge bridge; + bridge = await _startBridge( + manifest, + onProcessExited: (code) { + if (_bridges[id] == bridge) { + _bridges.remove(id); + _manifests.remove(id); + } + }, + ); try { await _injectAndConnect(bridge, connectionId: id, row: hydrated); @@ -99,7 +108,10 @@ class ExtensionDriverSession { } } - Future _startBridge(ExtensionManifest manifest) async { + Future _startBridge( + ExtensionManifest manifest, { + void Function(int code)? onProcessExited, + }) async { final root = manifest.installPath; if (root == null || root.isEmpty) { throw StateError('Extension "${manifest.id}" has no install path'); @@ -127,7 +139,16 @@ class ExtensionDriverSession { } } - final bridge = bridgeFactory?.call() ?? PluginRpcBridge(); + final manifestTimeoutSeconds = + manifest.sandbox?.resources.timeoutSeconds; + final bridge = bridgeFactory?.call() ?? + PluginRpcBridge( + requestTimeout: + manifestTimeoutSeconds != null && manifestTimeoutSeconds > 0 + ? Duration(seconds: manifestTimeoutSeconds) + : const Duration(seconds: 60), + onProcessExited: onProcessExited, + ); await _startBridgeWithConsent( bridge: bridge, manifest: manifest, diff --git a/lib/core/extensions/models/sandbox_capabilities.dart b/lib/core/extensions/models/sandbox_capabilities.dart index 3037e83..8ec210b 100644 --- a/lib/core/extensions/models/sandbox_capabilities.dart +++ b/lib/core/extensions/models/sandbox_capabilities.dart @@ -118,6 +118,7 @@ class ResourceLimits { const ResourceLimits({ this.memoryMb = defaultMemoryMb, this.maxOpenFiles = defaultMaxOpenFiles, + this.timeoutSeconds, }); static const defaultMemoryMb = 256; @@ -129,16 +130,21 @@ class ResourceLimits { /// Maximum number of open file descriptors (`ulimit -n`). final int maxOpenFiles; + /// Optional custom request/query timeout declared in manifest, in seconds. + final int? timeoutSeconds; + factory ResourceLimits.fromJson(Map json) { return ResourceLimits( memoryMb: json['memory_mb'] as int? ?? defaultMemoryMb, maxOpenFiles: json['max_open_files'] as int? ?? defaultMaxOpenFiles, + timeoutSeconds: json['timeout_seconds'] as int?, ); } Map toJson() => { 'memory_mb': memoryMb, 'max_open_files': maxOpenFiles, + if (timeoutSeconds != null) 'timeout_seconds': timeoutSeconds, }; } diff --git a/lib/core/extensions/rpc/json_rpc_stdio_client.dart b/lib/core/extensions/rpc/json_rpc_stdio_client.dart index 4e0e544..d1cb953 100644 --- a/lib/core/extensions/rpc/json_rpc_stdio_client.dart +++ b/lib/core/extensions/rpc/json_rpc_stdio_client.dart @@ -74,7 +74,17 @@ class JsonRpcStdioClient { }; _stdin.writeln(jsonEncode(payload)); - await _stdin.flush(); + try { + await _stdin.flush().timeout(const Duration(seconds: 3)); + } on TimeoutException { + _pending.remove(id); + throw TimeoutException( + 'Failed to flush JSON-RPC request "$method" to plugin stdin within 3s', + ); + } catch (e) { + _pending.remove(id); + rethrow; + } try { return await completer.future.timeout(requestTimeout); diff --git a/lib/core/extensions/rpc/plugin_rpc_bridge.dart b/lib/core/extensions/rpc/plugin_rpc_bridge.dart index 4b0a61b..1b4fbde 100644 --- a/lib/core/extensions/rpc/plugin_rpc_bridge.dart +++ b/lib/core/extensions/rpc/plugin_rpc_bridge.dart @@ -18,11 +18,12 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_auto_recovery.dar class PluginRpcBridge { PluginRpcBridge({ SandboxProcessRunner? processRunner, - this.handshakeTimeout = const Duration(seconds: 3), + this.handshakeTimeout = const Duration(seconds: 6), this.shutdownTimeout = const Duration(seconds: 3), this.requestTimeout = const Duration(seconds: 30), this.enableWatchdog = true, this.enableStderrPipe = true, + this.onProcessExited, SandboxSecurityAudit? audit, SandboxAutoRecovery? recovery, }) : _runner = processRunner ?? SandboxProcessRunner(), @@ -35,6 +36,7 @@ class PluginRpcBridge { final Duration requestTimeout; final bool enableWatchdog; final bool enableStderrPipe; + final void Function(int exitCode)? onProcessExited; final SandboxSecurityAudit? _audit; final SandboxAutoRecovery _recovery; @@ -125,6 +127,11 @@ class PluginRpcBridge { pluginId: handle.pluginId, detail: 'watchdog deadlock', )); + _failPending(PluginDeadlockException( + pluginId: handle.pluginId, + message: 'Watchdog detected plugin deadlock (ping timeout)', + )); + unawaited(_disposeLocal(keepRecovery: true)); } }, ); @@ -138,9 +145,13 @@ class PluginRpcBridge { _recovery.recordSuccess(); return result; } on TimeoutException { + final recent = _stderrPipe?.recentLines ?? const []; + final stderrTail = recent.isNotEmpty + ? '\nStderr output:\n${recent.join('\n')}' + : ''; await _forceKill(); throw PluginProtocolTimeoutException( - 'system.handshake timed out after $handshakeTimeout', + 'system.handshake timed out after $handshakeTimeout$stderrTail', ); } } @@ -212,6 +223,7 @@ class PluginRpcBridge { pluginId: _handle?.pluginId ?? 'unknown', exitCode: code, )); + onProcessExited?.call(code); unawaited(_disposeLocal(keepRecovery: true)); } diff --git a/lib/core/extensions/rpc/plugin_rpc_exceptions.dart b/lib/core/extensions/rpc/plugin_rpc_exceptions.dart index 257d9b4..175fcfe 100644 --- a/lib/core/extensions/rpc/plugin_rpc_exceptions.dart +++ b/lib/core/extensions/rpc/plugin_rpc_exceptions.dart @@ -26,3 +26,20 @@ class PluginProtocolTimeoutException implements Exception { @override String toString() => 'PluginProtocolTimeoutException: $message'; } + +/// Thrown when the watchdog detects that the plugin process is deadlocked (ping timeout). +class PluginDeadlockException implements Exception { + PluginDeadlockException({ + required this.pluginId, + this.message, + }); + + final String pluginId; + final String? message; + + @override + String toString() { + final detail = message == null ? '' : ': $message'; + return 'PluginDeadlockException($pluginId)$detail'; + } +} diff --git a/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart b/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart index 5eb1f8b..63e8f1a 100644 --- a/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart +++ b/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart @@ -39,6 +39,11 @@ class SandboxStderrPipe { final int maxCarryChars; final int maxSanitizeChars; + final List _recentLines = []; + + /// Rolling buffer of recent sanitized stderr lines (last 20 lines max). + List get recentLines => List.unmodifiable(_recentLines); + StreamSubscription>? _subscription; final StringBuffer _carry = StringBuffer(); int _carryLength = 0; @@ -193,6 +198,10 @@ class SandboxStderrPipe { ); } onSanitizedLine?.call(sanitized); + _recentLines.add(sanitized); + if (_recentLines.length > 20) { + _recentLines.removeAt(0); + } await log.appendLine(sanitized); } catch (e, st) { debugPrint('SandboxStderrPipe($pluginId) write failed: $e\n$st'); diff --git a/test/core/extensions/extension_driver_session_test.dart b/test/core/extensions/extension_driver_session_test.dart index 1830155..0c16ec6 100644 --- a/test/core/extensions/extension_driver_session_test.dart +++ b/test/core/extensions/extension_driver_session_test.dart @@ -3,6 +3,7 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; import 'package:querya_desktop/core/extensions/models/extension_object_metadata.dart'; import 'package:querya_desktop/core/extensions/models/extension_server_stats.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; void main() { @@ -136,5 +137,18 @@ void main() { expect(metadata.columns[0].comment, 'Primary ID'); expect(metadata.properties['engine'], 'MergeTree'); }); + + test('ResourceLimits parses timeout_seconds correctly', () { + final limits = ResourceLimits.fromJson({ + 'memory_mb': 512, + 'max_open_files': 128, + 'timeout_seconds': 600, + }); + + expect(limits.memoryMb, 512); + expect(limits.maxOpenFiles, 128); + expect(limits.timeoutSeconds, 600); + expect(limits.toJson()['timeout_seconds'], 600); + }); }); } diff --git a/test/core/extensions/rpc/plugin_rpc_bridge_test.dart b/test/core/extensions/rpc/plugin_rpc_bridge_test.dart index c7bee5e..f864b30 100644 --- a/test/core/extensions/rpc/plugin_rpc_bridge_test.dart +++ b/test/core/extensions/rpc/plugin_rpc_bridge_test.dart @@ -8,6 +8,7 @@ import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_bridge.dart'; import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_exceptions.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_log_paths.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; class _FakeProcess implements Process { @@ -66,9 +67,11 @@ void main() { setUp(() async { tempBase = await Directory.systemTemp.createTemp('querya_rpc_bridge_'); + SandboxLogPaths.mockLogsDirectory = tempBase; }); tearDown(() async { + SandboxLogPaths.mockLogsDirectory = null; try { if (await tempBase.exists()) { await tempBase.delete(recursive: true); @@ -316,4 +319,105 @@ void main() { expect(bridge.isStarted, isFalse); await sub.cancel(); }); + + test('unexpected exit fires onProcessExited callback', () async { + final process = _FakeProcess(); + final sub = process.stdinLines.listen((line) { + final req = jsonDecode(line) as Map; + if (req['method'] == 'system.handshake') { + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': {'ok': true}, + }); + } + }); + + final runner = SandboxProcessRunner( + platformOverride: 'windows', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + process, + ); + + int? capturedExitCode; + final bridge = PluginRpcBridge( + processRunner: runner, + enableWatchdog: false, + enableStderrPipe: false, + onProcessExited: (code) { + capturedExitCode = code; + }, + ); + + await bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ); + + process.completeExit(137); // SIGKILL + await Future.delayed(const Duration(milliseconds: 20)); + + expect(capturedExitCode, 137); + expect(bridge.isStarted, isFalse); + await sub.cancel(); + }); + + test('handshake timeout includes recent stderr output in exception', () async { + final process = _FakeProcess(); + final sub = process.stdinLines.listen((_) { + process._stderrController.add( + utf8.encode('FATAL: libclickhouse.so not found\n'), + ); + }); + + final runner = SandboxProcessRunner( + platformOverride: 'windows', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + process, + ); + + final bridge = PluginRpcBridge( + processRunner: runner, + handshakeTimeout: const Duration(milliseconds: 50), + enableWatchdog: false, + enableStderrPipe: true, + ); + + final startFuture = bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ); + + await expectLater( + startFuture, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('FATAL: libclickhouse.so not found'), + ), + ), + ); + await sub.cancel(); + }); } From 6fe525f89026a41d6fe84bfc10381e39e64a40b5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 19:40:01 +0300 Subject: [PATCH 43/47] feat(extensions): implement extension driver session restart and recovery action in workspace UI (close #667) --- .../extensions/extension_driver_session.dart | 10 +++ .../extension_driver_recovery_banner.dart | 85 +++++++++++++++++++ .../extensions/extension_sql_workspace.dart | 80 +++++++++++++++++ .../extensions/extension_table_toolbar.dart | 24 ++++++ .../extensions/extension_table_view.dart | 57 +++++++++++++ lib/features/workspace/results_tab.dart | 24 ++++-- .../extension_driver_session_test.dart | 18 ++++ ...extension_driver_recovery_banner_test.dart | 50 +++++++++++ test/features/workspace/results_tab_test.dart | 14 +++ 9 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 lib/features/extensions/extension_driver_recovery_banner.dart create mode 100644 test/features/extensions/extension_driver_recovery_banner_test.dart diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index 74e6c78..01455aa 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -465,6 +465,16 @@ class ExtensionDriverSession { } } + /// Restarts the extension driver process for [row] by cleanly disconnecting + /// the active bridge and re-establishing the connection. + Future restart(ConnectionRow row) async { + final id = row.id; + if (id != null) { + await disconnect(id); + } + return ensureConnected(row); + } + Future disconnectAll() async { final ids = _bridges.keys.toList(); for (final id in ids) { diff --git a/lib/features/extensions/extension_driver_recovery_banner.dart b/lib/features/extensions/extension_driver_recovery_banner.dart new file mode 100644 index 0000000..1dd6f15 --- /dev/null +++ b/lib/features/extensions/extension_driver_recovery_banner.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Banner displayed when an extension driver process crashes, deadlocks, +/// or disconnects unexpectedly, allowing the user to restart the driver process +/// with a single click and retry the pending action. +class ExtensionDriverRecoveryBanner extends material.StatelessWidget { + const ExtensionDriverRecoveryBanner({ + super.key, + required this.onRestart, + this.isRestarting = false, + this.customMessage, + }); + + final material.VoidCallback onRestart; + final bool isRestarting; + final String? customMessage; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == material.Brightness.dark; + + final bgColor = isDark + ? theme.colorScheme.destructive.withValues(alpha: 0.12) + : theme.colorScheme.destructive.withValues(alpha: 0.08); + + final borderColor = theme.colorScheme.destructive.withValues(alpha: 0.35); + + return material.Container( + margin: const material.EdgeInsets.fromLTRB(12, 8, 12, 8), + padding: const material.EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: material.BoxDecoration( + color: bgColor, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: borderColor), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.power_off_rounded, + size: 20, + color: theme.colorScheme.destructive, + ), + const Gap(12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text( + 'Driver Process Terminated or Unresponsive', + ).semiBold().small(), + const Gap(2), + Text( + customMessage ?? + 'The background driver process exited or lost communication. ' + 'Restart the driver to restore the connection.', + ).muted().xSmall(), + ], + ), + ), + const Gap(12), + OutlineButton( + size: ButtonSize.small, + onPressed: isRestarting ? null : onRestart, + leading: isRestarting + ? const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const material.Icon( + material.Icons.restart_alt_rounded, + size: 16, + ), + child: Text(isRestarting ? 'Restarting...' : 'Restart Driver'), + ), + ], + ), + ); + } +} diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index c9d91ac..52a2a16 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.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'; +import 'package:querya_desktop/features/extensions/extension_driver_recovery_banner.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -44,6 +45,7 @@ class _ExtensionSqlWorkspaceState final ValueNotifier _topFraction = ValueNotifier(0.6); bool _running = false; + bool _restartingDriver = false; String? _error; List _columns = []; List> _rows = []; @@ -55,6 +57,52 @@ class _ExtensionSqlWorkspaceState static const _previewRowLimit = 200; + bool get _isDriverError { + final err = _error; + if (err == null) return false; + return err.contains('PluginCrashedException') || + err.contains('PluginDeadlockException') || + err.contains('PluginProtocolTimeoutException') || + err.contains('TimeoutException') || + err.contains('SocketException') || + err.contains('Broken pipe') || + err.contains('JsonRpcStdioClient') || + err.contains('Connection') || + err.contains('is not started'); + } + + Future _restartDriver() async { + if (_restartingDriver) return; + setState(() { + _restartingDriver = true; + }); + try { + await ExtensionDriverSession.instance.restart(widget.connectionRow); + if (!mounted) return; + showAppToast( + context: context, + message: 'Driver restarted successfully', + variant: AppToastVariant.success, + ); + setState(() { + _restartingDriver = false; + _error = null; + }); + await _execute(); + } catch (e) { + if (!mounted) return; + setState(() { + _error = 'Driver restart failed: $e'; + _restartingDriver = false; + }); + showAppToast( + context: context, + message: 'Driver restart failed: $e', + variant: AppToastVariant.error, + ); + } + } + @override void initState() { super.initState(); @@ -313,6 +361,8 @@ class _ExtensionSqlWorkspaceState connectionName: widget.connectionRow.name, onExecute: _running ? null : () => unawaited(_execute()), running: _running, + isRestarting: _restartingDriver, + onRestartDriver: _running ? null : () => unawaited(_restartDriver()), onOpenSqlFile: () => unawaited(_openSqlFile()), onSaveSqlFile: () => unawaited(_saveSqlFile()), onOpenHistory: widget.connectionRow.id != null && !_running @@ -355,6 +405,12 @@ class _ExtensionSqlWorkspaceState errorMessage: _error, isLoading: _running, statusLine: _statusLine, + errorAction: _isDriverError + ? ExtensionDriverRecoveryBanner( + onRestart: () => unawaited(_restartDriver()), + isRestarting: _restartingDriver, + ) + : null, ), ), ], @@ -373,6 +429,8 @@ class _ExtensionSqlToolbar extends material.StatelessWidget { required this.onOpenSqlFile, required this.onSaveSqlFile, this.onOpenHistory, + this.onRestartDriver, + this.isRestarting = false, }); final String connectionName; @@ -381,6 +439,8 @@ class _ExtensionSqlToolbar extends material.StatelessWidget { final VoidCallback onOpenSqlFile; final VoidCallback onSaveSqlFile; final VoidCallback? onOpenHistory; + final VoidCallback? onRestartDriver; + final bool isRestarting; @override material.Widget build(material.BuildContext context) { @@ -394,6 +454,26 @@ class _ExtensionSqlToolbar extends material.StatelessWidget { child: Text('Query · $connectionName').semiBold().small(), ), const Spacer(), + if (onRestartDriver != null) ...[ + IconButton.ghost( + onPressed: running || isRestarting ? null : onRestartDriver, + icon: isRestarting + ? material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: accent, + ), + ) + : material.Icon( + material.Icons.restart_alt_rounded, + size: 18, + color: accent, + ), + ), + const Gap(4), + ], IconButton.ghost( onPressed: running ? null : onOpenSqlFile, icon: material.Icon( diff --git a/lib/features/extensions/extension_table_toolbar.dart b/lib/features/extensions/extension_table_toolbar.dart index d1e6421..73391ce 100644 --- a/lib/features/extensions/extension_table_toolbar.dart +++ b/lib/features/extensions/extension_table_toolbar.dart @@ -23,6 +23,8 @@ class ExtensionTableToolbar extends material.StatelessWidget { this.onCopyFormat, this.onSaveFormat, this.onNavigateHome, + this.onRestartDriver, + this.isRestarting = false, }); final String title; @@ -42,6 +44,8 @@ class ExtensionTableToolbar extends material.StatelessWidget { final material.ValueChanged? onCopyFormat; final material.ValueChanged? onSaveFormat; final VoidCallback? onNavigateHome; + final VoidCallback? onRestartDriver; + final bool isRestarting; @override material.Widget build(material.BuildContext context) { @@ -193,6 +197,26 @@ class ExtensionTableToolbar extends material.StatelessWidget { child: const Text('Next'), ), const Gap(8), + if (onRestartDriver != null) ...[ + OutlineButton( + size: ButtonSize.small, + onPressed: loading || isRestarting ? null : onRestartDriver, + leading: isRestarting + ? const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const material.Icon( + material.Icons.restart_alt_rounded, + size: 15, + ), + child: Text(isRestarting ? 'Restarting...' : 'Restart Driver'), + ), + const Gap(4), + ], OutlineButton( size: ButtonSize.small, onPressed: loading ? null : onRefresh, diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index fc0aad6..91b459c 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -5,6 +5,7 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/extensions/extension_driver_recovery_banner.dart'; import 'package:querya_desktop/features/extensions/extension_table_toolbar.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; @@ -52,6 +53,8 @@ class _ExtensionTableViewState extends material.State { DataGridStagingBuffer? _stagingBuffer; bool _isSaving = false; + bool _restartingDriver = false; + String get _qualifiedName => '`${widget.database}`.`${widget.tableName}`'; String get _whereClause { @@ -59,6 +62,52 @@ class _ExtensionTableViewState extends material.State { return text.isEmpty ? '' : ' WHERE $text'; } + bool get _isDriverError { + final err = _error; + if (err == null) return false; + return err.contains('PluginCrashedException') || + err.contains('PluginDeadlockException') || + err.contains('PluginProtocolTimeoutException') || + err.contains('TimeoutException') || + err.contains('SocketException') || + err.contains('Broken pipe') || + err.contains('JsonRpcStdioClient') || + err.contains('Connection') || + err.contains('is not started'); + } + + Future _restartDriver() async { + if (_restartingDriver) return; + setState(() { + _restartingDriver = true; + }); + try { + await ExtensionDriverSession.instance.restart(widget.connectionRow); + if (!mounted) return; + showAppToast( + context: context, + message: 'Driver restarted successfully', + variant: AppToastVariant.success, + ); + setState(() { + _restartingDriver = false; + _error = null; + }); + await _loadPage(refreshCount: true); + } catch (e) { + if (!mounted) return; + setState(() { + _error = 'Driver restart failed: $e'; + _restartingDriver = false; + }); + showAppToast( + context: context, + message: 'Driver restart failed: $e', + variant: AppToastVariant.error, + ); + } + } + @override void initState() { super.initState(); @@ -414,6 +463,8 @@ class _ExtensionTableViewState extends material.State { onGoPrevious: _previousPage, onGoNext: _nextPage, onRefresh: () => _loadPage(refreshCount: true), + onRestartDriver: () => unawaited(_restartDriver()), + isRestarting: _restartingDriver, onCopyFormat: (format) { unawaited(() async { await DataExportService.copyToClipboard( @@ -495,6 +546,12 @@ class _ExtensionTableViewState extends material.State { stagingBuffer: _stagingBuffer, onApplyChanges: _stagingBuffer != null ? _onApplyChanges : null, isSaving: _isSaving, + errorAction: _isDriverError + ? ExtensionDriverRecoveryBanner( + onRestart: () => unawaited(_restartDriver()), + isRestarting: _restartingDriver, + ) + : null, ), ), ], diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 6335a3e..20bd286 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -35,6 +35,7 @@ class ResultsTab extends material.StatefulWidget { this.stagingBuffer, this.onApplyChanges, this.isSaving = false, + this.errorAction, }); final List columns; @@ -47,6 +48,7 @@ class ResultsTab extends material.StatefulWidget { final DataGridStagingBuffer? stagingBuffer; final material.VoidCallback? onApplyChanges; final bool isSaving; + final material.Widget? errorAction; @override material.State createState() => _ResultsTabState(); @@ -118,13 +120,21 @@ class _ResultsTabState extends material.State { if (widget.errorMessage != null && widget.errorMessage!.isNotEmpty) { return material.KeyedSubtree( key: const material.ValueKey('results_mode_error'), - child: VirtualSelectableTextView( - text: widget.errorMessage!, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: Theme.of(context).colorScheme.destructive, - ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (widget.errorAction != null) widget.errorAction!, + material.Expanded( + child: VirtualSelectableTextView( + text: widget.errorMessage!, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Theme.of(context).colorScheme.destructive, + ), + ), + ), + ], ), ); } diff --git a/test/core/extensions/extension_driver_session_test.dart b/test/core/extensions/extension_driver_session_test.dart index 0c16ec6..7f7fce0 100644 --- a/test/core/extensions/extension_driver_session_test.dart +++ b/test/core/extensions/extension_driver_session_test.dart @@ -150,5 +150,23 @@ void main() { expect(limits.timeoutSeconds, 600); expect(limits.toJson()['timeout_seconds'], 600); }); + + test('restart disconnects active session and validates extension driver presence', () async { + const row = ConnectionRow( + id: 888, + type: 'postgresql', + name: 'PG', + createdAt: '2026-01-01T00:00:00Z', + ); + + await expectLater( + ExtensionDriverSession.instance.restart(row), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('not backed by an installed extension driver'), + )), + ); + }); }); } diff --git a/test/features/extensions/extension_driver_recovery_banner_test.dart b/test/features/extensions/extension_driver_recovery_banner_test.dart new file mode 100644 index 0000000..ae7d86e --- /dev/null +++ b/test/features/extensions/extension_driver_recovery_banner_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/extensions/extension_driver_recovery_banner.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + testWidgets('ExtensionDriverRecoveryBanner renders and triggers onRestart callback', (tester) async { + var restartClicked = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: ExtensionDriverRecoveryBanner( + onRestart: () { + restartClicked = true; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Driver Process Terminated or Unresponsive'), findsOneWidget); + expect(find.text('Restart Driver'), findsOneWidget); + + await tester.tap(find.text('Restart Driver')); + await tester.pumpAndSettle(); + + expect(restartClicked, isTrue); + }); + + testWidgets('ExtensionDriverRecoveryBanner displays custom message and restarting spinner', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const ExtensionDriverRecoveryBanner( + onRestart: _noop, + isRestarting: true, + customMessage: 'Custom crash diagnostic message', + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Custom crash diagnostic message'), findsOneWidget); + expect(find.text('Restarting...'), findsOneWidget); + expect(find.byType(material.CircularProgressIndicator), findsOneWidget); + }); +} + +void _noop() {} diff --git a/test/features/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index d6ac4eb..835759f 100644 --- a/test/features/workspace/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -470,6 +470,20 @@ void main() { ); expect(find.textContaining('syntax error'), findsOneWidget); + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab( + errorMessage: 'driver connection failed', + errorAction: material.Text('RESTART_ACTION_BUTTON'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('RESTART_ACTION_BUTTON'), findsOneWidget); + expect(find.textContaining('driver connection failed'), findsOneWidget); + await tester.pumpWidget( resultsShell( child: const material.Scaffold( From d68191c6525d97491daace8e57c2b426089b0548 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 20:21:31 +0300 Subject: [PATCH 44/47] perf(workspace): harden memory lifecycle, staging buffer disposal, and grid row cache cleanup (close #670) --- .../extensions/extension_table_view.dart | 4 ++++ lib/features/mysql/mysql_sql_workspace.dart | 7 ++++++- .../postgresql/postgres_sql_workspace.dart | 7 ++++++- lib/features/sqlite/sqlite_sql_workspace.dart | 7 ++++++- .../workspace/data_grid_staging_buffer.dart | 8 ++++++++ lib/features/workspace/result_grid_view.dart | 3 +++ lib/features/workspace/results_tab.dart | 8 ++++++++ .../data_grid_staging_buffer_test.dart | 17 +++++++++++++++++ 8 files changed, 58 insertions(+), 3 deletions(-) diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index 91b459c..9070da9 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -124,6 +124,7 @@ class _ExtensionTableViewState extends material.State { _totalRows = null; _filterController.clear(); _filterActive = false; + _stagingBuffer?.dispose(); _stagingBuffer = null; unawaited(_loadPage(refreshCount: true)); } @@ -131,6 +132,8 @@ class _ExtensionTableViewState extends material.State { @override void dispose() { + _stagingBuffer?.dispose(); + _stagingBuffer = null; _filterController.dispose(); super.dispose(); } @@ -208,6 +211,7 @@ class _ExtensionTableViewState extends material.State { _columns = dataResult.columns; _rows = dataResult.rows; _loading = false; + _stagingBuffer?.dispose(); if (!widget.isView && (_capabilities?.supportsMutations == true)) { _stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows); diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index bfbd55c..e1f1f3f 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -149,6 +149,8 @@ class _MysqlSqlWorkspaceState extends material.State { ); } _lease?.release(); + _stagingBuffer?.dispose(); + _stagingBuffer = null; _sqlController.dispose(); super.dispose(); } @@ -244,6 +246,7 @@ class _MysqlSqlWorkspaceState extends material.State { _rows = outRows; _affectedRows = affected; _lastExecutedSql = userSql; + _stagingBuffer?.dispose(); _stagingBuffer = cols.isNotEmpty ? DataGridStagingBuffer(columns: cols, rows: outRows) : null; @@ -325,8 +328,10 @@ class _MysqlSqlWorkspaceState extends material.State { } if (!mounted) return; + final newRows = _stagingBuffer!.effectiveRows; + _stagingBuffer?.dispose(); setState(() { - _rows = _stagingBuffer!.effectiveRows; + _rows = newRows; _stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows); _savingChanges = false; }); diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index b732363..90a9ac6 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -296,6 +296,8 @@ class _PostgresSqlWorkspaceState extends material.State { ); } _dropLease(); + _stagingBuffer?.dispose(); + _stagingBuffer = null; _sqlController.dispose(); super.dispose(); } @@ -388,6 +390,7 @@ class _PostgresSqlWorkspaceState extends material.State { _rows = outRows; _affectedRows = result.affectedRows; _lastExecutedSql = userSql; + _stagingBuffer?.dispose(); _stagingBuffer = cols.isNotEmpty ? DataGridStagingBuffer(columns: cols, rows: outRows) : null; @@ -478,8 +481,10 @@ class _PostgresSqlWorkspaceState extends material.State { await conn.execute(txSql, timeout: to); if (!mounted) return; + final newRows = _stagingBuffer!.effectiveRows; + _stagingBuffer?.dispose(); setState(() { - _rows = _stagingBuffer!.effectiveRows; + _rows = newRows; _stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows); _savingChanges = false; }); diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 737f708..605957d 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -125,6 +125,8 @@ class _SqliteSqlWorkspaceState extends material.State { .removeListener(_appSettingsListener); _topFraction.dispose(); _lease?.release(); + _stagingBuffer?.dispose(); + _stagingBuffer = null; _sqlController.dispose(); super.dispose(); } @@ -206,6 +208,7 @@ class _SqliteSqlWorkspaceState extends material.State { _rows = outRows; _affectedRows = null; _lastExecutedSql = userSql; + _stagingBuffer?.dispose(); _stagingBuffer = cols.isNotEmpty ? DataGridStagingBuffer(columns: cols, rows: outRows) : null; @@ -285,8 +288,10 @@ class _SqliteSqlWorkspaceState extends material.State { } if (!mounted) return; + final newRows = _stagingBuffer!.effectiveRows; + _stagingBuffer?.dispose(); setState(() { - _rows = _stagingBuffer!.effectiveRows; + _rows = newRows; _stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows); _savingChanges = false; }); diff --git a/lib/features/workspace/data_grid_staging_buffer.dart b/lib/features/workspace/data_grid_staging_buffer.dart index bae5c99..85471d3 100644 --- a/lib/features/workspace/data_grid_staging_buffer.dart +++ b/lib/features/workspace/data_grid_staging_buffer.dart @@ -315,5 +315,13 @@ class DataGridStagingBuffer extends ChangeNotifier { } return result; } + + @override + void dispose() { + _modifiedCells.clear(); + _insertedRows.clear(); + _deletedRowIndices.clear(); + super.dispose(); + } } diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 6732d6c..80afd80 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -470,6 +470,9 @@ class _VirtualResultGridState extends material.State { _horizontalController.dispose(); _verticalController.dispose(); _focusNode.dispose(); + _sortedRows = const []; + _columnWidths = const []; + _columnOffsets = const [0]; super.dispose(); } diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 20bd286..3d0b4b6 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -101,6 +101,14 @@ class _ResultsTabState extends material.State { return filteredRows; } + @override + void dispose() { + _memoColumns = null; + _memoEffectiveRows = null; + _cachedFilteredRows = const []; + super.dispose(); + } + @override Widget build(BuildContext context) { return QueryaFadeSlide( diff --git a/test/features/workspace/data_grid_staging_buffer_test.dart b/test/features/workspace/data_grid_staging_buffer_test.dart index 1406643..1849ae0 100644 --- a/test/features/workspace/data_grid_staging_buffer_test.dart +++ b/test/features/workspace/data_grid_staging_buffer_test.dart @@ -154,5 +154,22 @@ void main() { expect(plan.statements[2].type, MutationType.delete); expect(plan.statements[2].sql, 'DELETE FROM "public"."users" WHERE "id" = 3'); }); + + test('dispose clears internal collections and prevents further listener notifications', () { + buffer.setCell(0, 1, 'Alice Modified'); + buffer.addRow(['4', 'Diana', 'diana@test.com']); + buffer.toggleDeleteRow(2); + + expect(buffer.isDirty, isTrue); + expect(buffer.changeCount, 3); + + buffer.dispose(); + + expect(buffer.isDirty, isFalse); + expect(buffer.changeCount, 0); + expect(buffer.modifiedCellCount, 0); + expect(buffer.insertedRowCount, 0); + expect(buffer.deletedRowCount, 0); + }); }); } From 608a294824da0499cbc1f1eae3ec457cfafb4f9b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 21:28:54 +0300 Subject: [PATCH 45/47] perf(ui): accelerate menubar dropdown open and transition speed (#672) - Add customizable show/dismiss duration and animation curves to PopoverOverlayHandler - Configure high-performance menuHandler with 60ms open and 50ms dismiss duration using Curves.easeOutCubic - Update scale animation from 0.9->1.0 to 0.96->1.0 for subtle, desktop-native reveal - Unify TapRegion group IDs and allow immediate closure when switching sibling menus - Toggle close open dropdowns upon clicking menu button again - Add widget test verifying rapid open timing, toggle close, and sibling switch --- lib/app/app.dart | 6 ++ .../menubar_dropdown_speed_test.dart | 87 +++++++++++++++++++ .../lib/src/components/menu/menu.dart | 18 ++-- .../lib/src/components/overlay/popover.dart | 34 ++++++-- 4 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 test/features/main_screen/menubar_dropdown_speed_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 51f10be..2e6b16c 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -60,6 +60,12 @@ class QueryaApp extends StatelessWidget { themeMode: themeController.themeMode, materialTheme: themeController.materialThemeFor(colorScheme), debugShowCheckedModeBanner: false, + menuHandler: const PopoverOverlayHandler( + defaultShowDuration: Duration(milliseconds: 60), + defaultDismissDuration: Duration(milliseconds: 50), + showCurve: Curves.easeOutCubic, + dismissCurve: Curves.easeIn, + ), enableThemeAnimation: themeAnimEnabled, themeAnimationDuration: themeDuration, themeAnimationCurve: themeCurve, diff --git a/test/features/main_screen/menubar_dropdown_speed_test.dart b/test/features/main_screen/menubar_dropdown_speed_test.dart new file mode 100644 index 0000000..5c5d6fd --- /dev/null +++ b/test/features/main_screen/menubar_dropdown_speed_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + testWidgets('Menubar dropdown opens fast and toggles on repeated tap', + (tester) async { + await tester.pumpWidget( + ShadcnApp( + menuHandler: const PopoverOverlayHandler( + defaultShowDuration: Duration(milliseconds: 60), + defaultDismissDuration: Duration(milliseconds: 50), + showCurve: Curves.easeOutCubic, + dismissCurve: Curves.easeIn, + ), + home: material.Scaffold( + body: material.Column( + children: [ + Menubar( + border: false, + children: [ + MenuButton( + subMenu: [ + MenuButton( + onPressed: (_) {}, + child: const Text('New File'), + ), + MenuButton( + onPressed: (_) {}, + child: const Text('Save File'), + ), + ], + child: const Text('File'), + ), + MenuButton( + subMenu: [ + MenuButton( + onPressed: (_) {}, + child: const Text('Undo Action'), + ), + ], + child: const Text('Edit'), + ), + ], + ), + ], + ), + ), + ), + ); + + // Initial state: menu items are not visible + expect(find.text('New File'), findsNothing); + expect(find.text('Undo Action'), findsNothing); + + // Tap 'File' + await tester.tap(find.text('File')); + // Advance by 60ms (the fast open duration) + await tester.pump(const Duration(milliseconds: 60)); + + expect(find.text('New File'), findsOneWidget); + expect(find.text('Save File'), findsOneWidget); + + // Tap 'File' again to toggle close + await tester.tap(find.text('File')); + await tester.pumpAndSettle(); + + expect(find.text('New File'), findsNothing); + + // Open 'File' again + await tester.tap(find.text('File')); + await tester.pump(const Duration(milliseconds: 60)); + expect(find.text('New File'), findsOneWidget); + + // Tap 'Edit' while 'File' is open -> closes 'File' immediately and opens 'Edit' + await tester.tap(find.text('Edit')); + await tester.pump(const Duration(milliseconds: 60)); + + expect(find.text('New File'), findsNothing); + expect(find.text('Undo Action'), findsOneWidget); + + // Tap outside -> closes 'Edit' menu + await tester.tapAt(const Offset(300, 300)); + await tester.pumpAndSettle(); + expect(find.text('Undo Action'), findsNothing); + }); +} diff --git a/third_party/shadcn_flutter/lib/src/components/menu/menu.dart b/third_party/shadcn_flutter/lib/src/components/menu/menu.dart index 9fc6986..edc3aad 100644 --- a/third_party/shadcn_flutter/lib/src/components/menu/menu.dart +++ b/third_party/shadcn_flutter/lib/src/components/menu/menu.dart @@ -600,14 +600,16 @@ class _MenuButtonState extends State { final isDialogOverlay = DialogOverlayHandler.isDialogOverlay(context); final isIndependentOverlay = isSheetOverlay || isDialogOverlay; void openSubMenu(BuildContext context, bool autofocus) { - menuGroupData!.closeOthers(); + menuGroupData!.closeOthers(true); final overlayManager = OverlayManager.of(context); + final effectiveRegionGroupId = + menuGroupData.regionGroupId ?? menuGroupData.root; menuData!.popoverController.show( context: context, - regionGroupId: menuGroupData.regionGroupId, + regionGroupId: effectiveRegionGroupId, consumeOutsideTaps: false, dismissBackdropFocus: false, - modal: true, + modal: false, handler: MenuOverlayHandler(overlayManager), overlayBarrier: OverlayBarrier( borderRadius: BorderRadius.circular(theme.radiusMd), @@ -633,7 +635,7 @@ class _MenuButtonState extends State { direction: menuGroupData.direction, parent: menuGroupData, onDismissed: menuGroupData.onDismissed, - regionGroupId: menuGroupData.regionGroupId, + regionGroupId: effectiveRegionGroupId, subMenuOffset: compTheme?.subMenuOffset ?? Offset(densityGap, -densityGap * 0.625), itemPadding: itemPadding, @@ -685,7 +687,7 @@ class _MenuButtonState extends State { return Data.boundary( child: Data.boundary( child: TapRegion( - groupId: menuGroupData!.root, + groupId: menuGroupData!.regionGroupId ?? menuGroupData.root, child: AnimatedBuilder( animation: menuData!.popoverController, builder: (context, child) { @@ -773,6 +775,8 @@ class _MenuButtonState extends State { widget.subMenu!.isNotEmpty) { if (!menuData.popoverController.hasOpenPopover) { openSubMenu(context, false); + } else { + menuData.popoverController.close(); } } else { if (widget.autoClose) { @@ -863,9 +867,9 @@ class MenuGroupData { /// Closes all open popovers in child menu items. /// /// Iterates through children and closes any open submenu popovers. - void closeOthers() { + void closeOthers([bool immediate = false]) { for (final child in children) { - child.popoverController.close(); + child.popoverController.close(immediate); } } diff --git a/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart b/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart index b35b24c..eae3450 100644 --- a/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart +++ b/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart @@ -11,8 +11,25 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Manages the display, positioning, and lifecycle of popover overlays /// with support for alignment, constraints, and modal behavior. class PopoverOverlayHandler extends OverlayHandler { + /// Default animation duration when showing the popover. + final Duration? defaultShowDuration; + + /// Default animation duration when dismissing the popover. + final Duration? defaultDismissDuration; + + /// Animation curve when showing. + final Curve? showCurve; + + /// Animation curve when dismissing. + final Curve? dismissCurve; + /// Creates a [PopoverOverlayHandler]. - const PopoverOverlayHandler(); + const PopoverOverlayHandler({ + this.defaultShowDuration, + this.defaultDismissDuration, + this.showCurve, + this.dismissCurve, + }); @override OverlayCompleter show({ required BuildContext context, @@ -112,12 +129,15 @@ class PopoverOverlayHandler extends OverlayHandler { value: isClosed.value ? 0.0 : 1.0, initialValue: 0.0, curve: isClosed.value - ? const Interval(0, 2 / 3) - : Curves.linear, + ? (dismissCurve ?? const Interval(0, 2 / 3)) + : (showCurve ?? Curves.easeOutCubic), duration: isClosed.value - ? (showDuration ?? kDefaultDuration) - : (dismissDuration ?? - const Duration(milliseconds: 100)), + ? (dismissDuration ?? + defaultDismissDuration ?? + const Duration(milliseconds: 60)) + : (showDuration ?? + defaultShowDuration ?? + const Duration(milliseconds: 60)), onEnd: (value) { if (value == 0.0 && isClosed.value) { popoverEntry.remove(); @@ -685,7 +705,7 @@ class PopoverOverlayWidgetState extends State offset: _offset, margin: _margin?.optionallyResolve(context) ?? EdgeInsets.all(densityGap), - scale: tweenValue(0.9, 1.0, widget.animation.value), + scale: tweenValue(0.96, 1.0, widget.animation.value), scaleAlignment: (widget.transitionAlignment ?? _alignment) .optionallyResolve(context), allowInvertVertical: _allowInvertVertical, From 8ae21ff94adc722b0c54a1aed809c8c41768a32b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 21:40:12 +0300 Subject: [PATCH 46/47] perf(ui): eliminate initial menubar dropdown delay on pointer down (#672) - Unwrap Menubar from MoveWindow in QueryaWindowTitleBar to prevent window drag gesture arena interception from delaying menu clicks - Trigger top-level Menubar opening on onTapDown (pointer down) rather than waiting for mouse release - Adjust entry scale to 0.98 for instantaneous perceived start of dropdown appearance --- .../main_screen/querya_window_title_bar.dart | 6 ++++-- .../lib/src/components/menu/menu.dart | 18 ++++++++++++++++++ .../lib/src/components/overlay/popover.dart | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index d30252b..64c7801 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -103,6 +103,7 @@ class QueryaWindowTitleBar extends StatelessWidget { final closeButtonColors = QueryaWindowTitleBar.closeButtonColors(context); final rowContent = Row( + mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: QueryaWindowTitleBar.titleBarLeadingInset( @@ -390,10 +391,11 @@ class QueryaWindowTitleBar extends StatelessWidget { final inner = Row( children: [ + rowContent, Expanded( child: useNativeWindowChrome - ? MoveWindow(child: rowContent) - : rowContent, + ? MoveWindow() + : const SizedBox(), ), Row( mainAxisSize: material.MainAxisSize.min, diff --git a/third_party/shadcn_flutter/lib/src/components/menu/menu.dart b/third_party/shadcn_flutter/lib/src/components/menu/menu.dart index edc3aad..e405324 100644 --- a/third_party/shadcn_flutter/lib/src/components/menu/menu.dart +++ b/third_party/shadcn_flutter/lib/src/components/menu/menu.dart @@ -568,6 +568,7 @@ class MenuCheckbox extends StatelessWidget implements MenuItem { class _MenuButtonState extends State { final ValueNotifier> _children = ValueNotifier([]); + bool _justToggledOnTapDown = false; @override void initState() { @@ -769,7 +770,24 @@ class _MenuButtonState extends State { subFocusState.unfocus(); } }, + onTapDown: (details) { + if (menuBarData != null && + widget.subMenu != null && + widget.subMenu!.isNotEmpty) { + if (!menuData.popoverController.hasOpenPopover) { + _justToggledOnTapDown = true; + openSubMenu(context, false); + } else { + _justToggledOnTapDown = true; + menuData.popoverController.close(); + } + } + }, onPressed: () { + if (_justToggledOnTapDown) { + _justToggledOnTapDown = false; + return; + } widget.onPressed?.call(context); if (widget.subMenu != null && widget.subMenu!.isNotEmpty) { diff --git a/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart b/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart index eae3450..976898b 100644 --- a/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart +++ b/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart @@ -705,7 +705,7 @@ class PopoverOverlayWidgetState extends State offset: _offset, margin: _margin?.optionallyResolve(context) ?? EdgeInsets.all(densityGap), - scale: tweenValue(0.96, 1.0, widget.animation.value), + scale: tweenValue(0.98, 1.0, widget.animation.value), scaleAlignment: (widget.transitionAlignment ?? _alignment) .optionallyResolve(context), allowInvertVertical: _allowInvertVertical, From 2195433600e91fb4e26e57bd1dab69985612e6ec Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 21:49:52 +0300 Subject: [PATCH 47/47] chore(release): prepare release 0.4.14 --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b072090..b72a84b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.14] - 2026-08-30 + +Near-instantaneous menubar dropdowns, native macOS PlatformMenuBar integration, destructive query confirmation safety modals, comprehensive memory optimizations (string interning, compact storage), grid keyboard navigation, rich cell inspector, and driver resilience hardening. + +### Added + +- **Instantaneous Menubar Dropdowns (#672, #673)** — Accelerated dropdown open animation to 60ms with `Curves.easeOutCubic`, eliminated initial pointer-down delay, unhindered titlebar dragging from gesture arena interference, and added toggle-close support. +- **Native macOS PlatformMenuBar Integration (#612, #626)** — Full native macOS top system menu integration with standard application, file, edit, view, and window submenus. +- **Destructive Query Confirmation Modal (#608, #622, #638, #641)** — Safety confirmation dialog for high-risk DDL and DML operations (`DROP`, `TRUNCATE`, bulk `DELETE`/`UPDATE` without `WHERE`) with SQL preview, detected operations list, and explicit acknowledgement check for core and extension workspaces. +- **Full Data Grid Keyboard Navigation (#662, #664)** — Seamless keyboard traversal across virtual grid cells using arrow keys, Tab/Shift+Tab, Enter to commit, and Escape to cancel. +- **Rich Cell Inspector Dialog (#663, #665)** — Dedicated multi-format modal inspector with tabbed views for raw text, JSON, XML/HTML, and binary/hex with word wrap toggle and copy actions. +- **Binary & BLOB Support in DML (#658, #661)** — Added full support for binary BLOB data in cell editing, DML staging buffer, and dialect-specific SQL generation (`X'...'`, `\x...`). +- **Extension Driver Crash Recovery & Restart UI (#667, #669)** — Visual driver crash banner with automatic heartbeat monitoring and one-click manual driver restart button. +- **Interactive Welcome Tour Expansion (#606, #624)** — Expanded onboarding guide with 6 interactive steps, keyboard shortcuts reference matrix, and 1-click sample sandbox setup. +- **Bi-Directional Navigation (#630, #635)** — Quick navigation links between database overview statistics, home view, and active tables. +- **Active Selection Sync to Tree (#633, #637)** — Synchronized active table and view selection in tabs with the connections sidebar tree. +- **Extension Driver SDUI Tree Selection Highlight (#639, #642)** — Visual selection highlighting for SDUI trees rendered by extension drivers. +- **Querya Extension Driver Mutation Standard (#563, #628)** — Formalized mutation standard specification and test suite for extension drivers. + +### Changed & Performance + +- **String Interning Pool (#604, #625)** — Deduplicated low-cardinality string allocations in query result grids, slashing heap allocations during large dataset exploration. +- **Compact Typed Storage & Lazy Cell Stringification (#605, #627)** — Compact memory representation for primitive column vectors, deferring string conversions until render. +- **Schwartzian Transform Grid Sorting (#602, #615)** — Precomputed sort keys in `sortResultGridRows` to eliminate redundant comparisons during column header sorts. +- **QuickSelect Median Calculation (#603, #616)** — O(N) median computation in `GridSelectionCalcEngine` instead of O(N log N) sorting. +- **Background Selection Calculation Offloading (#652, #655)** — Offloaded massive cell selection statistics calculations to background compute workers to keep the UI at 60 FPS. +- **DataGrid Filter Bar Debouncing (#651, #654)** — Debounced keystroke evaluation in the filter bar to avoid stutter during rapid filter typing. +- **ResultsTab Filtering Memoization (#650, #653)** — Cached filtered row indices when filter predicates and dataset rows remain unchanged across rebuilds. +- **Modularized Shared SQL Editor & Workbench (#646, #649)** — Clean modular decoupling of SQL editor tabs, query runners, and workbench state. +- **Inverted Core Imports Cleanup (#644, #647)** — Eliminated circular/inverted core-to-feature dependencies and cleanly extracted connection models to core. +- **App Lifecycle & Dispose Resource Cleanup (#670, #671)** — Hardened teardown of timer subscriptions, workers, and active sockets upon window closure. +- **Connection Pool Delay Tuning (#597, #601)** — Reduced idle connection disposal delay to 4 seconds for rapid tab switching. + +### Fixed + +- **In-Memory Secret Scrubbing (#607, #623)** — Zeroed in-memory buffers for database passwords and sensitive connection parameters immediately after authentication. +- **Redis Safe Disconnect & Socket Resilience (#657, #660)** — Protected Redis clients against unhandled socket close exceptions and connection teardown race conditions. +- **SQLite WAL Mode & Busy Timeout (#611, #617, #656, #659)** — Enabled Write-Ahead Logging (WAL) and 5000ms busy timeout in LocalDb and SQLite workspaces to eliminate database lock errors. +- **LocalDb Single-Flight Initialization (#645, #648)** — Guarded `LocalDb._open` against concurrent re-initialization race conditions. +- **Plugin JSON-RPC Bridge Resilience (#666, #668)** — Added heartbeat ping-pong, buffered message queues, and graceful restart on extension pipe drops. +- **SQL History Monotonic Ordering & Pruning (#629)** — Guaranteed monotonic ordering by auto-incrementing ID in query history queries and batch prune operations. +- **Literal 'NULL' vs Database NULL Differentiation (#595, #599)** — Fixed DML preview and staging buffer improperly treating the literal text `'NULL'` as SQL `NULL`. +- **DML Leading Zeroes Preservation (#594, #598)** — Prevented numeric string values (such as postal codes or phone numbers with leading zeroes) from being coerced to integers in generated DML. +- **Missing Primary Key Warning in DML (#596, #600)** — Displayed explicit duplicate key warnings in the DML preview modal for tables lacking primary keys. +- **Motion Wobble Harmonization (#631, #634)** — Harmonized workspace transition curves to eliminate dual-axis wobble during connection switching. +- **MongoDB & Redis Breadcrumbs (#632, #636)** — Preserved persistent breadcrumbs and smooth transitions during NoSQL key and collection navigation. +- **Focus Traversal in Connection Forms (#610, #621)** — Configured explicit `FocusTraversalGroup`s across multi-field connection dialogs. +- **Light Theme Accent Contrast (#609, #619)** — Enhanced light theme accent and ring contrast ratios for WCAG compliance. +- **Monospace Font Stack (#613, #618)** — Added Cascadia Code and Consolas to default monospace font fallback list. +- **Linux GDK Log Spam (#614, #620)** — Suppressed benign synthetic pointer and cursor theme GDK warnings in Linux console logs. + + ## [0.4.13] - 2026-08-25 Production-ready UI polish, complete interactive Data Grid editing suite, advanced query filtering, fluid collapsible navigation, and platform hardening. diff --git a/pubspec.yaml b/pubspec.yaml index 851f7e6..cfda63f 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.14+1 +version: 0.4.14