From f69461060afe3db3e5d0ed60a4bf4ccb30c30d3a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 15:31:06 +0300 Subject: [PATCH 1/9] feat(app): disconnect Postgres, Mongo, Redis on app exit Made-with: Cursor --- lib/app/app.dart | 5 ++- lib/app/app_lifecycle_cleanup.dart | 44 ++++++++++++++++++++++++++ lib/app/app_shutdown.dart | 11 +++++++ lib/core/database/mongodb_service.dart | 2 +- lib/core/database/redis_service.dart | 7 ++++ 5 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 lib/app/app_lifecycle_cleanup.dart create mode 100644 lib/app/app_shutdown.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 71f29318..479d353e 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,6 +1,7 @@ import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'app_lifecycle_cleanup.dart'; import '../features/main_screen/main_screen.dart'; class QueryaApp extends StatelessWidget { @@ -18,7 +19,9 @@ class QueryaApp extends StatelessWidget { enableThemeAnimation: false, // Avoids scroll interception fighting nested Scrollbars in data views. enableScrollInterception: false, - home: const MainScreen(), + home: const AppLifecycleCleanup( + child: MainScreen(), + ), ); } } diff --git a/lib/app/app_lifecycle_cleanup.dart b/lib/app/app_lifecycle_cleanup.dart new file mode 100644 index 00000000..def0bea2 --- /dev/null +++ b/lib/app/app_lifecycle_cleanup.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import 'app_shutdown.dart'; + +/// Closes pooled TCP connections when the app is shutting down. +/// +/// Uses [AppLifecycleState.detached] and [dispose] so desktop window close is +/// covered as reliably as the platform allows. +class AppLifecycleCleanup extends StatefulWidget { + const AppLifecycleCleanup({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _AppLifecycleCleanupState(); +} + +class _AppLifecycleCleanupState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(disconnectAllExternalServices()); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.detached) { + unawaited(disconnectAllExternalServices()); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/app/app_shutdown.dart b/lib/app/app_shutdown.dart new file mode 100644 index 00000000..67f050d2 --- /dev/null +++ b/lib/app/app_shutdown.dart @@ -0,0 +1,11 @@ +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/database/postgres_service.dart'; +import 'package:querya_desktop/core/database/redis_service.dart'; + +/// Disconnects all pooled / cached client connections (PostgreSQL pool, Mongo, +/// Redis). Safe to call when no connections exist. +Future disconnectAllExternalServices() async { + await PostgresService.instance.disconnectAll(); + await MongoService.instance.disconnectAll(); + await RedisService.instance.disconnectAll(); +} diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 8a7df0eb..7ea3e77a 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -60,7 +60,7 @@ class MongoService { /// Disconnects all connections. Future disconnectAll() async { - for (final connection in _connections.values) { + for (final connection in _connections.values.toList()) { await disconnect(connection); } } diff --git a/lib/core/database/redis_service.dart b/lib/core/database/redis_service.dart index e1bd6905..e8251ba4 100644 --- a/lib/core/database/redis_service.dart +++ b/lib/core/database/redis_service.dart @@ -45,4 +45,11 @@ class RedisService { await connection.disconnect(); _connections.remove(connection.id); } + + /// Disconnects all Redis connections (e.g. app shutdown). + Future disconnectAll() async { + for (final connection in _connections.values.toList()) { + await disconnect(connection); + } + } } From f4280371812728af5d152605f643e7f82eafa173 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 15:31:12 +0300 Subject: [PATCH 2/9] test(app): shutdown, AppLifecycleCleanup, Mongo/Redis disconnectAll Made-with: Cursor --- test/app/app_lifecycle_cleanup_test.dart | 30 ++++++++++++++++++++ test/app/app_shutdown_test.dart | 10 +++++++ test/core/database/mongo_service_test.dart | 32 ++++++++++++++++++++++ test/core/database/redis_service_test.dart | 32 ++++++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 test/app/app_lifecycle_cleanup_test.dart create mode 100644 test/app/app_shutdown_test.dart diff --git a/test/app/app_lifecycle_cleanup_test.dart b/test/app/app_lifecycle_cleanup_test.dart new file mode 100644 index 00000000..12d87f10 --- /dev/null +++ b/test/app/app_lifecycle_cleanup_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/app/app_lifecycle_cleanup.dart'; + +void main() { + testWidgets('AppLifecycleCleanup renders child', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: AppLifecycleCleanup( + child: Text('wrapped'), + ), + ), + ); + expect(find.text('wrapped'), findsOneWidget); + }); + + testWidgets('AppLifecycleCleanup disposes without throwing', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: AppLifecycleCleanup( + child: SizedBox(), + ), + ), + ); + await tester.pumpWidget( + const MaterialApp(home: SizedBox.shrink()), + ); + await tester.pump(); + }); +} diff --git a/test/app/app_shutdown_test.dart b/test/app/app_shutdown_test.dart new file mode 100644 index 00000000..d2fbb461 --- /dev/null +++ b/test/app/app_shutdown_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/app/app_shutdown.dart'; + +void main() { + group('disconnectAllExternalServices', () { + test('completes without open connections', () async { + await disconnectAllExternalServices(); + }); + }); +} diff --git a/test/core/database/mongo_service_test.dart b/test/core/database/mongo_service_test.dart index 0c018cbd..71bcd4e4 100644 --- a/test/core/database/mongo_service_test.dart +++ b/test/core/database/mongo_service_test.dart @@ -119,4 +119,36 @@ void main() { expect(MongoService.instance.getConnection(999998), isNull); }); }); + + group('MongoService.disconnectAll', () { + test('completes when no connections', () async { + await MongoService.instance.disconnectAll(); + }); + + test('clears all tracked connections', () async { + MongoService.instance.createConnection( + const ConnectionRow( + id: 301, + type: 'mongodb', + name: 'a', + createdAt: '2026-01-01T00:00:00Z', + ), + ); + MongoService.instance.createConnection( + const ConnectionRow( + id: 302, + type: 'mongodb', + name: 'b', + createdAt: '2026-01-01T00:00:00Z', + ), + ); + expect(MongoService.instance.getConnection(301), isNotNull); + expect(MongoService.instance.getConnection(302), isNotNull); + + await MongoService.instance.disconnectAll(); + + expect(MongoService.instance.getConnection(301), isNull); + expect(MongoService.instance.getConnection(302), isNull); + }); + }); } diff --git a/test/core/database/redis_service_test.dart b/test/core/database/redis_service_test.dart index 20ef6994..b14e1a23 100644 --- a/test/core/database/redis_service_test.dart +++ b/test/core/database/redis_service_test.dart @@ -129,4 +129,36 @@ void main() { expect(RedisService.instance.getConnection(77), isNull); }); }); + + group('RedisService.disconnectAll', () { + test('completes when no connections', () async { + await RedisService.instance.disconnectAll(); + }); + + test('clears all tracked connections', () async { + RedisService.instance.createConnection( + const ConnectionRow( + id: 201, + type: 'redis', + name: 'a', + createdAt: '2026-01-01T00:00:00Z', + ), + ); + RedisService.instance.createConnection( + const ConnectionRow( + id: 202, + type: 'redis', + name: 'b', + createdAt: '2026-01-01T00:00:00Z', + ), + ); + expect(RedisService.instance.getConnection(201), isNotNull); + expect(RedisService.instance.getConnection(202), isNotNull); + + await RedisService.instance.disconnectAll(); + + expect(RedisService.instance.getConnection(201), isNull); + expect(RedisService.instance.getConnection(202), isNull); + }); + }); } From 122e87920ce11e12a8ea939c71724973c96e8b7a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 15:47:03 +0300 Subject: [PATCH 3/9] feat(postgres): LRU eviction for PostgreSQL connection pool slots Made-with: Cursor --- .../database/postgres_connection_pool.dart | 40 +++++++++++-- lib/core/database/postgres_service.dart | 1 + .../postgres_connection_pool_test.dart | 57 +++++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart index 3a6bd5c1..03d19490 100644 --- a/lib/core/database/postgres_connection_pool.dart +++ b/lib/core/database/postgres_connection_pool.dart @@ -46,12 +46,18 @@ class PostgresConnectionPool { PostgresConnectionPool({ required this.createAndConnect, this.idleDisposeDelay = defaultIdleDisposeDelay, + this.maxEntries = defaultMaxEntries, }); static const Duration defaultIdleDisposeDelay = Duration(seconds: 8); + /// Max distinct pool keys `(connection id, database, mode)`. When full, + /// least-recently-used **idle** slots (`refs == 0`) are closed first. + static const int defaultMaxEntries = 32; + final PostgresPoolConnectionFactory createAndConnect; final Duration idleDisposeDelay; + final int maxEntries; final Map _pool = {}; @@ -67,6 +73,7 @@ class PostgresConnectionPool { final k = keyFor(row.id, database, mode); var entry = _pool[k]; if (entry != null) { + entry.touch(); entry.idleTimer?.cancel(); entry.idleTimer = null; entry.refs++; @@ -77,12 +84,35 @@ class PostgresConnectionPool { return PgLease._(this, k, entry.connection); } + _evictIfNeededBeforeNewSlot(); + final conn = await createAndConnect(row, database: database, mode: mode); entry = _PoolEntry(conn)..refs = 1; _pool[k] = entry; return PgLease._(this, k, conn); } + /// Drops idle LRU slots until there is room for one more key. + void _evictIfNeededBeforeNewSlot() { + while (_pool.length >= maxEntries) { + final idle = _pool.entries.where((e) => e.value.refs == 0).toList(); + if (idle.isEmpty) { + throw StateError( + 'PostgreSQL connection pool exhausted: $maxEntries slots in use.', + ); + } + idle.sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); + _removeEntryClosing(idle.first.key); + } + } + + void _removeEntryClosing(String k) { + final entry = _pool.remove(k); + if (entry == null) return; + entry.idleTimer?.cancel(); + unawaited(entry.connection.forceClose()); + } + void _release(String k) { final entry = _pool[k]; if (entry == null) return; @@ -106,10 +136,7 @@ class PostgresConnectionPool { PgSessionMode mode = PgSessionMode.readOnly, }) { final k = keyFor(row.id, database, mode); - final entry = _pool.remove(k); - if (entry == null) return; - entry.idleTimer?.cancel(); - unawaited(entry.connection.forceClose()); + _removeEntryClosing(k); } /// Closes all pooled connections (e.g. app shutdown). @@ -123,9 +150,12 @@ class PostgresConnectionPool { } class _PoolEntry { - _PoolEntry(this.connection); + _PoolEntry(this.connection) : lastUsed = DateTime.now(); final PostgresConnection connection; int refs = 0; Timer? idleTimer; + DateTime lastUsed; + + void touch() => lastUsed = DateTime.now(); } diff --git a/lib/core/database/postgres_service.dart b/lib/core/database/postgres_service.dart index 820aeeeb..940e1338 100644 --- a/lib/core/database/postgres_service.dart +++ b/lib/core/database/postgres_service.dart @@ -24,6 +24,7 @@ class PostgresService { PostgresService._() : _pool = PostgresConnectionPool( createAndConnect: _defaultCreateAndConnect, + maxEntries: PostgresConnectionPool.defaultMaxEntries, ); static final PostgresService instance = PostgresService._(); diff --git a/test/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart index ff41f7e9..eec675a0 100644 --- a/test/core/database/postgres_connection_pool_test.dart +++ b/test/core/database/postgres_connection_pool_test.dart @@ -253,6 +253,63 @@ void main() { a.release(); b.release(); }); + + test('evicts LRU idle slot when maxEntries is reached', () async { + final fakes = {}; + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final id = row.id ?? 0; + final c = FakePostgresConnection(id: id); + await c.connect(); + await c.setSessionReadOnly(mode == PgSessionMode.readOnly); + fakes[id] = c; + return c; + } + + final pool = PostgresConnectionPool( + createAndConnect: factory, + maxEntries: 2, + idleDisposeDelay: const Duration(hours: 1), + ); + final l1 = await pool.acquire(_row(id: 1), database: 'postgres'); + await Future.delayed(const Duration(milliseconds: 2)); + final l2 = await pool.acquire(_row(id: 2), database: 'postgres'); + expect(fakes.length, 2); + l1.release(); + l2.release(); + await Future.delayed(const Duration(milliseconds: 2)); + final l3 = await pool.acquire(_row(id: 3), database: 'postgres'); + expect(fakes[1]!.forceCloseCount, 1); + expect(fakes[2]!.forceCloseCount, 0); + l3.release(); + }); + + test('throws when pool full and all slots busy', () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(id: row.id ?? 0); + await c.connect(); + await c.setSessionReadOnly(mode == PgSessionMode.readOnly); + return c; + } + + final pool = PostgresConnectionPool( + createAndConnect: factory, + maxEntries: 1, + ); + final l1 = await pool.acquire(_row(id: 1), database: 'postgres'); + await expectLater( + pool.acquire(_row(id: 2), database: 'postgres'), + throwsA(isA()), + ); + l1.release(); + }); }); group('PgLease idempotency', () { From 76c80e67bb444679e6a14d456f86d609b643554a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 15:47:08 +0300 Subject: [PATCH 4/9] feat(postgres): persist SQL stmt timeout, app_settings onCreate, tab tx warning Made-with: Cursor --- lib/core/storage/app_settings.dart | 35 ++++++++ lib/core/storage/local_db.dart | 42 +++++++++- .../postgresql/postgres_sql_workspace.dart | 35 +++++++- .../postgresql/postgres_workspace_home.dart | 67 ++++++++++++--- pubspec.yaml | 2 + test/core/storage/app_settings_test.dart | 83 +++++++++++++++++++ 6 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 lib/core/storage/app_settings.dart create mode 100644 test/core/storage/app_settings_test.dart diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart new file mode 100644 index 00000000..c58a5834 --- /dev/null +++ b/lib/core/storage/app_settings.dart @@ -0,0 +1,35 @@ +import 'local_db.dart'; + +/// Typed keys for [LocalDb] app_settings. +abstract final class AppSettingsKeys { + static const postgresSqlStmtTimeoutSeconds = + 'postgres_sql_stmt_timeout_seconds'; +} + +/// User preferences backed by [LocalDb] (SQLite). +class AppSettings { + AppSettings._(); + static final AppSettings instance = AppSettings._(); + + /// `null` = use driver / URI default. + Future getPostgresSqlStmtTimeoutSeconds() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.postgresSqlStmtTimeoutSeconds, + ); + if (v == null || v.isEmpty) return null; + return int.tryParse(v); + } + + Future setPostgresSqlStmtTimeoutSeconds(int? seconds) async { + if (seconds == null) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.postgresSqlStmtTimeoutSeconds, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.postgresSqlStmtTimeoutSeconds, + seconds.toString(), + ); + } + } +} diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index e7dbda98..60686d1f 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -5,7 +5,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; -const _dbVersion = 3; +const _dbVersion = 4; /// Local SQLite database for folders and connections. /// File: [applicationSupport]/querya_desktop/querya.db @@ -65,6 +65,12 @@ class LocalDb { created_at TEXT NOT NULL ) '''); + await db.execute(''' + CREATE TABLE app_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ) + '''); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { @@ -104,6 +110,40 @@ class LocalDb { await db.execute('DROP TABLE connections'); await db.execute('ALTER TABLE connections_new RENAME TO connections'); } + if (oldVersion < 4) { + await db.execute(''' + CREATE TABLE app_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ) + '''); + } + } + + Future getAppSetting(String key) async { + final db = await _open(); + final rows = await db.query( + 'app_settings', + columns: ['value'], + where: 'key = ?', + whereArgs: [key], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String?; + } + + Future setAppSetting(String key, String value) async { + final db = await _open(); + await db.rawInsert( + 'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', + [key, value], + ); + } + + Future deleteAppSetting(String key) async { + final db = await _open(); + await db.delete('app_settings', where: 'key = ?', whereArgs: [key]); } Future> getFolders() async { diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 3503ed54..77c87269 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:postgres/postgres.dart' as pg; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -15,10 +18,14 @@ class PostgresSqlWorkspace extends material.StatefulWidget { const PostgresSqlWorkspace({ super.key, required this.connectionRow, + this.transactionOpenNotifier, }); final ConnectionRow connectionRow; + /// Updated when transaction state changes (for tab-switch warnings). + final material.ValueNotifier? transactionOpenNotifier; + @override material.State createState() => _PostgresSqlWorkspaceState(); @@ -47,6 +54,29 @@ class _PostgresSqlWorkspaceState extends material.State { /// `null` = unknown (older server or error). bool? _txOpen; + @override + void initState() { + super.initState(); + material.WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_loadStmtTimeoutSetting()); + }); + } + + Future _loadStmtTimeoutSetting() async { + final t = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); + if (!mounted) return; + setState(() => _queryTimeoutSeconds = t); + } + + void _onStmtTimeoutChanged(int? v) { + setState(() => _queryTimeoutSeconds = v); + unawaited(AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(v)); + } + + void _notifyTransactionOpen() { + widget.transactionOpenNotifier?.value = _txOpen; + } + Future _ensureLease() async { if (_lease != null && _lease!.connection.isConnected) return; _lease?.release(); @@ -70,10 +100,12 @@ class _PostgresSqlWorkspaceState extends material.State { final conn = _lease?.connection; if (conn == null || !conn.isConnected) { if (mounted) setState(() => _txOpen = null); + _notifyTransactionOpen(); return; } final v = await conn.inOpenTransaction(); if (mounted) setState(() => _txOpen = v); + _notifyTransactionOpen(); } Future _runTxCommand(String cmd) async { @@ -262,8 +294,7 @@ class _PostgresSqlWorkspaceState extends material.State { onAutocommitChanged: (v) => setState(() => _autocommit = v), queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: (v) => - setState(() => _queryTimeoutSeconds = v), + onQueryTimeoutChanged: _onStmtTimeoutChanged, txOpen: _txOpen, onBegin: _running ? null diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index b8f553f9..51614c53 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -20,6 +20,47 @@ class PostgresWorkspaceHome extends material.StatefulWidget { class _PostgresWorkspaceHomeState extends material.State { int _tab = 0; + late final material.ValueNotifier _sqlTxNotifier; + + @override + void initState() { + super.initState(); + _sqlTxNotifier = material.ValueNotifier(null); + } + + @override + void dispose() { + _sqlTxNotifier.dispose(); + super.dispose(); + } + + Future _selectTab(int i) async { + if (i == _tab) return; + if (_tab == 1 && i == 0 && _sqlTxNotifier.value == true) { + final ok = await material.showDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Open transaction'), + content: const material.Text( + 'The SQL tab has an open transaction. Leave anyway? ' + 'Uncommitted work may be lost if the session ends.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const material.Text('Stay'), + ), + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const material.Text('Leave'), + ), + ], + ), + ); + if (ok != true) return; + } + setState(() => _tab = i); + } @override material.Widget build(material.BuildContext context) { @@ -45,7 +86,7 @@ class _PostgresWorkspaceHomeState extends material.State child: material.MouseRegion( cursor: material.SystemMouseCursors.click, child: material.GestureDetector( - onTap: () => setState(() => _tab = i), + onTap: () => _selectTab(i), child: material.AnimatedContainer( duration: const Duration(milliseconds: 120), padding: const material.EdgeInsets.symmetric( @@ -73,15 +114,21 @@ class _PostgresWorkspaceHomeState extends material.State ), const Divider(height: 1), Expanded( - child: _tab == 0 - ? PostgresStatsView( - key: ValueKey('pg_stats_${widget.connectionRow.id}'), - connectionRow: widget.connectionRow, - ) - : PostgresSqlWorkspace( - key: ValueKey('pg_sql_${widget.connectionRow.id}'), - connectionRow: widget.connectionRow, - ), + child: material.IndexedStack( + index: _tab, + sizing: material.StackFit.expand, + children: [ + PostgresStatsView( + key: ValueKey('pg_stats_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + ), + PostgresSqlWorkspace( + key: ValueKey('pg_sql_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + transactionOpenNotifier: _sqlTxNotifier, + ), + ], + ), ), ], ); diff --git a/pubspec.yaml b/pubspec.yaml index 882b3eda..2f3c6169 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,8 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 + # Used in tests to mock paths (path_provider has no plugin in flutter test). + path_provider_platform_interface: ^2.1.2 dependency_overrides: # Patched ToastLayer (fixes InheritedNotifier crash on resize / hot reload). diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart new file mode 100644 index 00000000..72e48cc7 --- /dev/null +++ b/test/core/storage/app_settings_test.dart @@ -0,0 +1,83 @@ +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/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// path_provider has no implementation in plain `flutter test`; LocalDb needs a path. +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_app_settings_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(null); + }); + + group('AppSettings', () { + test('getPostgresSqlStmtTimeoutSeconds roundtrip', () async { + expect(await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(), isNull); + + await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(90); + expect(await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(), 90); + + await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(null); + expect(await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(), isNull); + }); + + test('getPostgresSqlStmtTimeoutSeconds returns null for invalid stored value', () async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.postgresSqlStmtTimeoutSeconds, + 'not-a-number', + ); + expect(await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(), isNull); + }); + }); +} From e1a993bb7375ff1faa206d5ea24cf6aea5c746e7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 15:50:06 +0300 Subject: [PATCH 5/9] fix(ui): prevent PG tree row overflow in connections panel Made-with: Cursor --- .../connections/connections_panel.dart | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 3751761b..54807962 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1482,7 +1482,17 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { size: 14, color: theme.colorScheme.primary.withValues(alpha: 0.7)), const Gap(6), - Text('Databases (${widget.databases.length})').small(), + material.Expanded( + child: material.Text( + 'Databases (${widget.databases.length})', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + ), + ), ], ), ), @@ -1781,7 +1791,17 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { material.Icon(material.Icons.account_tree_rounded, size: 13, color: theme.colorScheme.mutedForeground), const Gap(6), - Text('Schemas (${widget.schemas.length})').muted().xSmall(), + material.Expanded( + child: material.Text( + 'Schemas (${widget.schemas.length})', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ), ], ), ), @@ -2173,9 +2193,17 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { material.Icon(widget.icon, size: 13, color: theme.colorScheme.mutedForeground), const Gap(6), - Text('${widget.label} (${widget.items.length})') - .muted() - .xSmall(), + material.Expanded( + child: material.Text( + '${widget.label} (${widget.items.length})', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ), ], ), ), From 052732223b70faf76166832b738145226578aad6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 15:52:26 +0300 Subject: [PATCH 6/9] style(ui): align PG Extensions/Foreign data rows with tree (muted, indent) Made-with: Cursor --- .../connections/connections_panel.dart | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 54807962..90816404 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1692,8 +1692,9 @@ class _PgDbToolRow extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); + // Match _PgSchemaToolRow / _PgObjectGroup: muted xSmall, 13px icons, same indent as Schemas. return material.Padding( - padding: const material.EdgeInsets.only(left: 12, top: 2, bottom: 2), + padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), child: material.Material( color: material.Colors.transparent, child: material.InkWell( @@ -1709,19 +1710,26 @@ class _PgDbToolRow extends material.StatelessWidget { ), child: material.Padding( padding: - const material.EdgeInsets.symmetric(horizontal: 4, vertical: 4), + const material.EdgeInsets.symmetric(horizontal: 4, vertical: 3), child: material.Row( children: [ material.Icon(icon, - size: 14, - color: theme.colorScheme.primary.withValues(alpha: 0.85)), + size: 13, color: theme.colorScheme.mutedForeground), const Gap(6), material.Expanded( - child: Text(label).small(), + child: material.Text( + label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), ), material.Icon( material.Icons.chevron_right_rounded, - size: 14, + size: 13, color: theme.colorScheme.mutedForeground, ), ], From 346898e93904b35cd83762b683605582f76718eb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 15:57:36 +0300 Subject: [PATCH 7/9] refactor(ui): shared _PgTreeRow for PG browser with hover + tooltips Made-with: Cursor --- .../connections/connections_panel.dart | 511 ++++++++---------- 1 file changed, 225 insertions(+), 286 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 90816404..d068e1f1 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' as material show BuildContext, Widget, Padding, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, Column, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors; +import 'package:flutter/material.dart' as material show BuildContext, Widget, Padding, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, Column, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; @@ -1446,6 +1446,86 @@ class _PgDatabasesNode extends StatefulWidget { State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); } +/// Shared tree row: consistent ink hover, tooltips on truncated labels. +class _PgTreeRow extends material.StatelessWidget { + const _PgTreeRow({ + required this.label, + this.leading, + this.icon, + this.iconSize = 13, + this.iconColor, + this.trailing, + this.onTap, + this.verticalPadding = 3, + required this.textStyle, + }); + + final String label; + final material.Widget? leading; + final material.IconData? icon; + final double iconSize; + final material.Color? iconColor; + final material.Widget? trailing; + final void Function()? onTap; + final double verticalPadding; + final material.TextStyle textStyle; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final primary = theme.colorScheme.primary; + final muted = theme.colorScheme.mutedForeground; + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + borderRadius: material.BorderRadius.circular(4), + hoverColor: primary.withValues(alpha: 0.07), + 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, + ), + const Gap(6), + ], + material.Expanded( + child: material.Tooltip( + message: label, + waitDuration: const Duration(milliseconds: 450), + child: material.Text( + label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: textStyle, + ), + ), + ), + if (trailing != null) trailing!, + ], + ), + ), + ), + ); + } +} + class _PgDatabasesNodeState extends State<_PgDatabasesNode> { bool _expanded = true; @@ -1458,45 +1538,26 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: () => setState(() => _expanded = !_expanded), - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: material.Row( - children: [ - material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - const Gap(4), - material.Icon(material.Icons.dns_rounded, - size: 14, - color: theme.colorScheme.primary.withValues(alpha: 0.7)), - const Gap(6), - material.Expanded( - child: material.Text( - 'Databases (${widget.databases.length})', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - ), - ), - ], - ), + _PgTreeRow( + label: 'Databases (${widget.databases.length})', + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, ), ), + icon: material.Icons.dns_rounded, + iconSize: 14, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: () => setState(() => _expanded = !_expanded), ), if (_expanded) for (final db in widget.databases) @@ -1578,45 +1639,26 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: material.Row( - children: [ - material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - const Gap(4), - material.Icon(material.Icons.storage_rounded, - size: 14, - color: theme.colorScheme.primary.withValues(alpha: 0.7)), - const Gap(6), - material.Expanded( - child: material.Text( - widget.databaseName, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - ), - ), - ], - ), + _PgTreeRow( + label: widget.databaseName, + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, ), ), + icon: material.Icons.storage_rounded, + iconSize: 14, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: _toggle, ), if (_expanded) ...[ _PgDbToolRow( @@ -1692,49 +1734,31 @@ class _PgDbToolRow extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); - // Match _PgSchemaToolRow / _PgObjectGroup: muted xSmall, 13px icons, same indent as Schemas. + final muted = theme.colorScheme.mutedForeground; return material.Padding( padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), - child: material.Material( - color: material.Colors.transparent, - child: material.InkWell( - borderRadius: material.BorderRadius.circular(4), - onTap: onPostgresObjectSelected == null - ? null - : () => onPostgresObjectSelected!( - connection, - databaseName, - '', - '', - kind, - ), - child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 4, vertical: 3), - child: material.Row( - children: [ - material.Icon(icon, - size: 13, color: theme.colorScheme.mutedForeground), - const Gap(6), - material.Expanded( - child: material.Text( - label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ), - material.Icon( - material.Icons.chevron_right_rounded, - size: 13, - color: theme.colorScheme.mutedForeground, + child: _PgTreeRow( + label: label, + icon: icon, + iconSize: 13, + iconColor: muted, + trailing: material.Icon( + material.Icons.chevron_right_rounded, + size: 13, + color: muted, + ), + onTap: onPostgresObjectSelected == null + ? null + : () => onPostgresObjectSelected!( + connection, + databaseName, + '', + '', + kind, ), - ], - ), - ), + textStyle: material.TextStyle( + fontSize: 11, + color: muted, ), ), ); @@ -1776,44 +1800,25 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: () => setState(() => _expanded = !_expanded), - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 4, vertical: 3), - child: material.Row( - children: [ - material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - const Gap(4), - material.Icon(material.Icons.account_tree_rounded, - size: 13, color: theme.colorScheme.mutedForeground), - const Gap(6), - material.Expanded( - child: material.Text( - 'Schemas (${widget.schemas.length})', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ), - ], - ), + _PgTreeRow( + label: 'Schemas (${widget.schemas.length})', + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, ), ), + icon: material.Icons.account_tree_rounded, + iconSize: 13, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + onTap: () => setState(() => _expanded = !_expanded), ), if (_expanded) for (final schema in widget.schemas) @@ -1919,43 +1924,25 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 4, vertical: 3), - child: material.Row( - children: [ - material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - const Gap(4), - material.Icon(material.Icons.diamond_outlined, - size: 13, - color: theme.colorScheme.primary.withValues(alpha: 0.6)), - const Gap(6), - material.Expanded( - child: material.Text( - widget.schemaName, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, color: theme.colorScheme.foreground), - ), - ), - ], - ), + _PgTreeRow( + label: widget.schemaName, + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, ), ), + icon: material.Icons.diamond_outlined, + iconSize: 13, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.6), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + onTap: _toggle, ), if (_expanded) ...[ if (_loading) @@ -2109,40 +2096,31 @@ class _PgSchemaToolRow extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); + final muted = theme.colorScheme.mutedForeground; return material.Padding( padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), - child: material.Material( - color: material.Colors.transparent, - child: material.InkWell( - borderRadius: material.BorderRadius.circular(4), - onTap: onPostgresObjectSelected == null - ? null - : () => onPostgresObjectSelected!( - connection, - databaseName, - schemaName, - '', - kind, - ), - child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 4, vertical: 3), - child: material.Row( - children: [ - material.Icon(icon, - size: 13, color: theme.colorScheme.mutedForeground), - const Gap(6), - material.Expanded( - child: Text(label).muted().xSmall(), - ), - material.Icon( - material.Icons.chevron_right_rounded, - size: 13, - color: theme.colorScheme.mutedForeground, + child: _PgTreeRow( + label: label, + icon: icon, + iconSize: 13, + iconColor: muted, + trailing: material.Icon( + material.Icons.chevron_right_rounded, + size: 13, + color: muted, + ), + onTap: onPostgresObjectSelected == null + ? null + : () => onPostgresObjectSelected!( + connection, + databaseName, + schemaName, + '', + kind, ), - ], - ), - ), + textStyle: material.TextStyle( + fontSize: 11, + color: muted, ), ), ); @@ -2178,83 +2156,44 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: () => setState(() => _expanded = !_expanded), - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 4, vertical: 3), - child: material.Row( - children: [ - material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, - color: theme.colorScheme.mutedForeground, - ), - ), - const Gap(4), - material.Icon(widget.icon, - size: 13, color: theme.colorScheme.mutedForeground), - const Gap(6), - material.Expanded( - child: material.Text( - '${widget.label} (${widget.items.length})', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ), - ], - ), + _PgTreeRow( + label: '${widget.label} (${widget.items.length})', + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 13, + color: theme.colorScheme.mutedForeground, ), ), + icon: widget.icon, + iconSize: 13, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + onTap: () => setState(() => _expanded = !_expanded), ), if (_expanded) for (final item in widget.items) material.Padding( padding: const material.EdgeInsets.only(left: 22), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 4, vertical: 2), - child: material.MouseRegion( - cursor: widget.onItemTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.InkWell( - onTap: widget.onItemTap != null - ? () => widget.onItemTap!(item) - : null, - borderRadius: material.BorderRadius.circular(4), - child: material.Row( - children: [ - material.Icon(widget.icon, - size: 12, - color: theme.colorScheme.primary - .withValues(alpha: 0.5)), - const Gap(6), - material.Expanded( - child: material.Text( - item, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.foreground, - ), - ), - ), - ], - ), - ), + child: _PgTreeRow( + label: item, + icon: widget.icon, + iconSize: 12, + iconColor: + theme.colorScheme.primary.withValues(alpha: 0.5), + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, ), + verticalPadding: 2, + onTap: widget.onItemTap != null + ? () => widget.onItemTap!(item) + : null, ), ), ], From b57970c2b4f004aa3d66f6e7ae1d976e4a863d4f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 16:05:49 +0300 Subject: [PATCH 8/9] feat(connections): PG tree context menu, overflow tooltips, Open in SQL tab Made-with: Cursor --- .../connections/connections_panel.dart | 192 ++++++++++++++++-- lib/features/main_screen/main_screen.dart | 15 ++ lib/features/main_screen/workspace_panel.dart | 5 + .../postgresql/postgres_workspace_home.dart | 25 +++ 4 files changed, 223 insertions(+), 14 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index d068e1f1..b4c794a9 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,4 +1,5 @@ -import 'package:flutter/material.dart' as material show BuildContext, Widget, Padding, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, Column, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color; +import 'package:flutter/material.dart' as material show BuildContext, Widget, Padding, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, Column, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, LayoutBuilder, TextPainter, TextSpan, TextDirection; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; @@ -23,6 +24,7 @@ class ConnectionsPanel extends StatefulWidget { this.onRedisDatabaseSelected, this.onMongoDBDatabaseSelected, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, }); /// Called when the user taps a connection tile. @@ -45,6 +47,9 @@ class ConnectionsPanel extends StatefulWidget { PostgresObjectKind kind, )? onPostgresObjectSelected; + /// Opens the PostgreSQL workspace home and switches to the SQL tab (e.g. from tree context menu). + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; + @override State createState() => _ConnectionsPanelState(); } @@ -154,6 +159,7 @@ class _ConnectionsPanelState extends State { onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ); } else if (conn.type == 'redis') { return _RedisConnectionTile( @@ -337,7 +343,7 @@ class _EmptyState extends StatelessWidget { color: theme.colorScheme.mutedForeground, ), const Gap(10), - Expanded( + material.Expanded( child: Text(message).muted().small(), ), ], @@ -1205,6 +1211,7 @@ class _PostgresConnectionTile extends StatefulWidget { required this.onRemove, this.onTap, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, }); final ConnectionRow connection; @@ -1219,6 +1226,7 @@ class _PostgresConnectionTile extends StatefulWidget { String name, PostgresObjectKind kind, )? onPostgresObjectSelected; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; @override State<_PostgresConnectionTile> createState() => @@ -1416,6 +1424,11 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { connection: widget.connection, databases: _databases, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefreshDatabases: () { + setState(() => _databases = []); + _loadDatabases(); + }, ), ], ], @@ -1430,6 +1443,8 @@ class _PgDatabasesNode extends StatefulWidget { required this.connection, required this.databases, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + required this.onRefreshDatabases, }); final ConnectionRow connection; @@ -1441,12 +1456,52 @@ class _PgDatabasesNode extends StatefulWidget { String name, PostgresObjectKind kind, )? onPostgresObjectSelected; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; + final VoidCallback onRefreshDatabases; @override State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); } -/// Shared tree row: consistent ink hover, tooltips on truncated labels. +/// Ellipsis label; tooltip only when text overflows (intrinsic width > slot). +class _PgTreeRowLabel extends material.StatelessWidget { + const _PgTreeRowLabel({ + required this.label, + required this.textStyle, + }); + + final String label; + final material.TextStyle textStyle; + + @override + material.Widget build(material.BuildContext context) { + return material.LayoutBuilder( + builder: (context, constraints) { + final tp = material.TextPainter( + text: material.TextSpan(text: label, style: textStyle), + maxLines: 1, + textDirection: material.TextDirection.ltr, + ); + tp.layout(maxWidth: double.infinity); + final overflow = tp.width > constraints.maxWidth + 0.5; + final text = material.Text( + label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: textStyle, + ); + if (!overflow) return text; + return material.Tooltip( + message: label, + waitDuration: const Duration(milliseconds: 450), + child: text, + ); + }, + ); + } +} + +/// Shared tree row: consistent ink hover, optional context menu, tooltips when truncated. class _PgTreeRow extends material.StatelessWidget { const _PgTreeRow({ required this.label, @@ -1458,6 +1513,9 @@ class _PgTreeRow extends material.StatelessWidget { this.onTap, this.verticalPadding = 3, required this.textStyle, + this.connection, + this.onContextRefresh, + this.onOpenSqlWorkspace, }); final String label; @@ -1469,13 +1527,16 @@ class _PgTreeRow extends material.StatelessWidget { final void Function()? onTap; final double verticalPadding; final material.TextStyle textStyle; + final ConnectionRow? connection; + final VoidCallback? onContextRefresh; + final void Function(ConnectionRow connection)? onOpenSqlWorkspace; @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); final primary = theme.colorScheme.primary; final muted = theme.colorScheme.mutedForeground; - return material.Material( + final row = material.Material( color: material.Colors.transparent, child: material.InkWell( onTap: onTap, @@ -1506,16 +1567,7 @@ class _PgTreeRow extends material.StatelessWidget { const Gap(6), ], material.Expanded( - child: material.Tooltip( - message: label, - waitDuration: const Duration(milliseconds: 450), - child: material.Text( - label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: textStyle, - ), - ), + child: _PgTreeRowLabel(label: label, textStyle: textStyle), ), if (trailing != null) trailing!, ], @@ -1523,6 +1575,43 @@ class _PgTreeRow extends material.StatelessWidget { ), ), ); + if (connection == null) return row; + return ContextMenu( + items: [ + if (onContextRefresh != null) + MenuButton( + leading: material.Icon( + material.Icons.refresh_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) => onContextRefresh!(), + child: const Text('Refresh'), + ), + MenuButton( + leading: material.Icon( + material.Icons.copy_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) { + Clipboard.setData(ClipboardData(text: label)); + }, + child: const Text('Copy name'), + ), + if (onOpenSqlWorkspace != null) + MenuButton( + leading: material.Icon( + material.Icons.terminal_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) => onOpenSqlWorkspace!(connection!), + child: const Text('Open in SQL'), + ), + ], + child: row, + ); } } @@ -1558,6 +1647,9 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { ), verticalPadding: 4, onTap: () => setState(() => _expanded = !_expanded), + connection: widget.connection, + onContextRefresh: widget.onRefreshDatabases, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) for (final db in widget.databases) @@ -1565,6 +1657,7 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { connection: widget.connection, databaseName: db, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), ], ), @@ -1577,6 +1670,7 @@ class _PgDatabaseNode extends StatefulWidget { required this.connection, required this.databaseName, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, }); final ConnectionRow connection; @@ -1588,6 +1682,7 @@ class _PgDatabaseNode extends StatefulWidget { String name, PostgresObjectKind kind, )? onPostgresObjectSelected; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; @override State<_PgDatabaseNode> createState() => _PgDatabaseNodeState(); @@ -1659,6 +1754,9 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { ), verticalPadding: 4, onTap: _toggle, + connection: widget.connection, + onContextRefresh: _loadSchemas, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) ...[ _PgDbToolRow( @@ -1668,6 +1766,8 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { icon: material.Icons.extension_rounded, kind: PostgresObjectKind.databaseExtensions, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onContextRefresh: _loadSchemas, ), _PgDbToolRow( connection: widget.connection, @@ -1676,6 +1776,8 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { icon: material.Icons.public_rounded, kind: PostgresObjectKind.databaseForeignData, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onContextRefresh: _loadSchemas, ), if (_loading) material.Padding( @@ -1700,6 +1802,8 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { databaseName: widget.databaseName, schemas: _schemas, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefreshSchemas: _loadSchemas, ), ], ], @@ -1716,6 +1820,8 @@ class _PgDbToolRow extends material.StatelessWidget { required this.icon, required this.kind, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + this.onContextRefresh, }); final ConnectionRow connection; @@ -1730,6 +1836,8 @@ class _PgDbToolRow extends material.StatelessWidget { String name, PostgresObjectKind kind, )? onPostgresObjectSelected; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; + final VoidCallback? onContextRefresh; @override material.Widget build(material.BuildContext context) { @@ -1760,6 +1868,9 @@ class _PgDbToolRow extends material.StatelessWidget { fontSize: 11, color: muted, ), + connection: connection, + onContextRefresh: onContextRefresh, + onOpenSqlWorkspace: onPostgresOpenSqlWorkspace, ), ); } @@ -1771,6 +1882,8 @@ class _PgSchemasNode extends StatefulWidget { required this.databaseName, required this.schemas, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + required this.onRefreshSchemas, }); final ConnectionRow connection; @@ -1783,6 +1896,8 @@ class _PgSchemasNode extends StatefulWidget { String name, PostgresObjectKind kind, )? onPostgresObjectSelected; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; + final VoidCallback onRefreshSchemas; @override State<_PgSchemasNode> createState() => _PgSchemasNodeState(); @@ -1819,6 +1934,9 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { color: theme.colorScheme.mutedForeground, ), onTap: () => setState(() => _expanded = !_expanded), + connection: widget.connection, + onContextRefresh: widget.onRefreshSchemas, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) for (final schema in widget.schemas) @@ -1827,6 +1945,7 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { databaseName: widget.databaseName, schemaName: schema, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), ], ), @@ -1840,6 +1959,7 @@ class _PgSchemaNode extends StatefulWidget { required this.databaseName, required this.schemaName, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, }); final ConnectionRow connection; @@ -1852,6 +1972,7 @@ class _PgSchemaNode extends StatefulWidget { String name, PostgresObjectKind kind, )? onPostgresObjectSelected; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; @override State<_PgSchemaNode> createState() => _PgSchemaNodeState(); @@ -1943,6 +2064,9 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { color: theme.colorScheme.foreground, ), onTap: _toggle, + connection: widget.connection, + onContextRefresh: _loadObjects, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) ...[ if (_loading) @@ -1964,6 +2088,9 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { ), if (_loaded) ...[ _PgObjectGroup( + connection: widget.connection, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefresh: _loadObjects, label: 'Tables', icon: material.Icons.table_chart_rounded, items: _tables, @@ -1978,6 +2105,9 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { : null, ), _PgObjectGroup( + connection: widget.connection, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefresh: _loadObjects, label: 'Views', icon: material.Icons.view_agenda_rounded, items: _views, @@ -1992,6 +2122,9 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { : null, ), _PgObjectGroup( + connection: widget.connection, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefresh: _loadObjects, label: 'Materialized views', icon: material.Icons.dynamic_feed_rounded, items: _matviews, @@ -2006,6 +2139,9 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { : null, ), _PgObjectGroup( + connection: widget.connection, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefresh: _loadObjects, label: 'Functions', icon: material.Icons.functions_rounded, items: _functions, @@ -2020,6 +2156,9 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { : null, ), _PgObjectGroup( + connection: widget.connection, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefresh: _loadObjects, label: 'Sequences', icon: material.Icons.format_list_numbered_rounded, items: _sequences, @@ -2041,6 +2180,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { icon: material.Icons.table_rows_rounded, kind: PostgresObjectKind.schemaIndexes, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onContextRefresh: _loadObjects, ), _PgSchemaToolRow( connection: widget.connection, @@ -2050,6 +2191,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { icon: material.Icons.bolt_rounded, kind: PostgresObjectKind.schemaTriggers, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onContextRefresh: _loadObjects, ), _PgSchemaToolRow( connection: widget.connection, @@ -2059,6 +2202,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { icon: material.Icons.category_rounded, kind: PostgresObjectKind.schemaTypes, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onContextRefresh: _loadObjects, ), ], ], @@ -2077,6 +2222,8 @@ class _PgSchemaToolRow extends material.StatelessWidget { required this.icon, required this.kind, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + this.onContextRefresh, }); final ConnectionRow connection; @@ -2092,6 +2239,8 @@ class _PgSchemaToolRow extends material.StatelessWidget { String name, PostgresObjectKind kind, )? onPostgresObjectSelected; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; + final VoidCallback? onContextRefresh; @override material.Widget build(material.BuildContext context) { @@ -2122,6 +2271,9 @@ class _PgSchemaToolRow extends material.StatelessWidget { fontSize: 11, color: muted, ), + connection: connection, + onContextRefresh: onContextRefresh, + onOpenSqlWorkspace: onPostgresOpenSqlWorkspace, ), ); } @@ -2129,15 +2281,21 @@ class _PgSchemaToolRow extends material.StatelessWidget { class _PgObjectGroup extends StatefulWidget { const _PgObjectGroup({ + required this.connection, + required this.onRefresh, required this.label, required this.icon, required this.items, + this.onPostgresOpenSqlWorkspace, this.onItemTap, }); + final ConnectionRow connection; + final VoidCallback onRefresh; final String label; final material.IconData icon; final List items; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; final void Function(String itemName)? onItemTap; @override @@ -2175,6 +2333,9 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { color: theme.colorScheme.mutedForeground, ), onTap: () => setState(() => _expanded = !_expanded), + connection: widget.connection, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) for (final item in widget.items) @@ -2194,6 +2355,9 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { onTap: widget.onItemTap != null ? () => widget.onItemTap!(item) : null, + connection: widget.connection, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), ), ], diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 9c10bbea..acf1c8ad 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -40,6 +40,9 @@ class _MainScreenState extends State { ({String database, String schema, String name, PostgresObjectKind kind})? _selectedPostgresObject; + /// Bumped to tell [PostgresWorkspaceHome] to switch to the SQL tab. + int _postgresSqlTabRequestToken = 0; + void _onConnectionSelected(ConnectionRow connection) { setState(() { _activeConnection = connection; @@ -85,6 +88,16 @@ class _MainScreenState extends State { }); } + void _onPostgresOpenSqlWorkspace(ConnectionRow connection) { + setState(() { + _activeConnection = connection; + _activeRedisDb = null; + _activeMongoDB = null; + _selectedPostgresObject = null; + _postgresSqlTabRequestToken++; + }); + } + @override Widget build(BuildContext context) { final theme = AppTheme.dark.colorScheme; @@ -125,6 +138,7 @@ class _MainScreenState extends State { onRedisDatabaseSelected: _onRedisDatabaseSelected, onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, onPostgresObjectSelected: _onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, ), ), _VerticalResizeHandle( @@ -153,6 +167,7 @@ class _MainScreenState extends State { selectedRedisDb: _activeRedisDb, selectedMongoDb: _activeMongoDB, selectedPostgresObject: _selectedPostgresObject, + postgresSqlTabRequestToken: _postgresSqlTabRequestToken, ), ), ], diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 4965f412..a1ab3d18 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -116,6 +116,7 @@ class WorkspacePanel extends StatefulWidget { this.selectedRedisDb, this.selectedMongoDb, this.selectedPostgresObject, + this.postgresSqlTabRequestToken = 0, }); /// Currently selected connection from the sidebar. @@ -134,6 +135,9 @@ class WorkspacePanel extends StatefulWidget { final ({String database, String schema, String name, PostgresObjectKind kind})? selectedPostgresObject; + /// Incremented by [MainScreen] to switch the PostgreSQL home view to the SQL tab. + final int postgresSqlTabRequestToken; + @override State createState() => _WorkspacePanelState(); } @@ -158,6 +162,7 @@ class _WorkspacePanelState extends State { ? PostgresWorkspaceHome( key: ValueKey('pg_home_${widget.activeConnection!.id}'), connectionRow: widget.activeConnection!, + sqlTabRequestToken: widget.postgresSqlTabRequestToken, ) : _pgObjectWorkspace( connection: widget.activeConnection!, diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index 51614c53..f60f17fb 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -1,3 +1,5 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_workspace.dart'; @@ -9,10 +11,14 @@ class PostgresWorkspaceHome extends material.StatefulWidget { const PostgresWorkspaceHome({ super.key, required this.connectionRow, + this.sqlTabRequestToken = 0, }); final ConnectionRow connectionRow; + /// Parent increments this to request switching to the SQL tab (e.g. from browser context menu). + final int sqlTabRequestToken; + @override material.State createState() => _PostgresWorkspaceHomeState(); @@ -21,11 +27,30 @@ class PostgresWorkspaceHome extends material.StatefulWidget { class _PostgresWorkspaceHomeState extends material.State { int _tab = 0; late final material.ValueNotifier _sqlTxNotifier; + int _lastAppliedSqlTabToken = 0; @override void initState() { super.initState(); _sqlTxNotifier = material.ValueNotifier(null); + _lastAppliedSqlTabToken = widget.sqlTabRequestToken; + } + + @override + void didUpdateWidget(covariant PostgresWorkspaceHome oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _lastAppliedSqlTabToken = widget.sqlTabRequestToken; + return; + } + final t = widget.sqlTabRequestToken; + if (t > _lastAppliedSqlTabToken) { + _lastAppliedSqlTabToken = t; + material.WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_selectTab(1)); + }); + } } @override From 78f1370687af9d406b0a3d97e5accc639d2bef09 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 16:05:54 +0300 Subject: [PATCH 9/9] test(connections): layout overflow checks for narrow ConnectionsPanel Made-with: Cursor --- .../connections_panel_layout_test.dart | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 test/features/connections/connections_panel_layout_test.dart diff --git a/test/features/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart new file mode 100644 index 00000000..8099fa30 --- /dev/null +++ b/test/features/connections/connections_panel_layout_test.dart @@ -0,0 +1,93 @@ +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/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/layout_overflow.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_conn_panel_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('ConnectionsPanel layout', () { + final sizes = { + 'narrow_tall': const material.Size(320, 720), + 'very_narrow': const material.Size(280, 560), + 'medium': const material.Size(900, 640), + }; + + for (final entry in sizes.entries) { + testWidgets('no layout overflow at ${entry.key} ${entry.value}', + (tester) async { + await expectNoLayoutOverflow(() async { + await pumpWidgetWithSurfaceSize( + tester, + entry.value, + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.SizedBox.expand( + child: ConnectionsPanel( + onPostgresOpenSqlWorkspace: (_) {}, + ), + ), + ), + ); + }); + }); + } + }); +}