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/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/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); + } + } } 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/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 3751761b..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; +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,11 +1456,165 @@ class _PgDatabasesNode extends StatefulWidget { String name, PostgresObjectKind kind, )? onPostgresObjectSelected; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; + final VoidCallback onRefreshDatabases; @override State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); } +/// 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, + this.leading, + this.icon, + this.iconSize = 13, + this.iconColor, + this.trailing, + this.onTap, + this.verticalPadding = 3, + required this.textStyle, + this.connection, + this.onContextRefresh, + this.onOpenSqlWorkspace, + }); + + 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; + 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; + final row = 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: _PgTreeRowLabel(label: label, textStyle: textStyle), + ), + if (trailing != null) trailing!, + ], + ), + ), + ), + ); + 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, + ); + } +} + class _PgDatabasesNodeState extends State<_PgDatabasesNode> { bool _expanded = true; @@ -1458,35 +1627,29 @@ 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), - Text('Databases (${widget.databases.length})').small(), - ], - ), + _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), + connection: widget.connection, + onContextRefresh: widget.onRefreshDatabases, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) for (final db in widget.databases) @@ -1494,6 +1657,7 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { connection: widget.connection, databaseName: db, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), ], ), @@ -1506,6 +1670,7 @@ class _PgDatabaseNode extends StatefulWidget { required this.connection, required this.databaseName, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, }); final ConnectionRow connection; @@ -1517,6 +1682,7 @@ class _PgDatabaseNode extends StatefulWidget { String name, PostgresObjectKind kind, )? onPostgresObjectSelected; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; @override State<_PgDatabaseNode> createState() => _PgDatabaseNodeState(); @@ -1568,45 +1734,29 @@ 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, + connection: widget.connection, + onContextRefresh: _loadSchemas, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) ...[ _PgDbToolRow( @@ -1616,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, @@ -1624,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( @@ -1648,6 +1802,8 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { databaseName: widget.databaseName, schemas: _schemas, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onRefreshSchemas: _loadSchemas, ), ], ], @@ -1664,6 +1820,8 @@ class _PgDbToolRow extends material.StatelessWidget { required this.icon, required this.kind, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + this.onContextRefresh, }); final ConnectionRow connection; @@ -1678,46 +1836,41 @@ 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) { final theme = Theme.of(context); + final muted = theme.colorScheme.mutedForeground; return material.Padding( - padding: const material.EdgeInsets.only(left: 12, 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: 4), - child: material.Row( - children: [ - material.Icon(icon, - size: 14, - color: theme.colorScheme.primary.withValues(alpha: 0.85)), - const Gap(6), - material.Expanded( - child: Text(label).small(), - ), - material.Icon( - material.Icons.chevron_right_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, + padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), + 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, ), + connection: connection, + onContextRefresh: onContextRefresh, + onOpenSqlWorkspace: onPostgresOpenSqlWorkspace, ), ); } @@ -1729,6 +1882,8 @@ class _PgSchemasNode extends StatefulWidget { required this.databaseName, required this.schemas, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + required this.onRefreshSchemas, }); final ConnectionRow connection; @@ -1741,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(); @@ -1758,34 +1915,28 @@ 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), - Text('Schemas (${widget.schemas.length})').muted().xSmall(), - ], - ), + _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), + connection: widget.connection, + onContextRefresh: widget.onRefreshSchemas, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) for (final schema in widget.schemas) @@ -1794,6 +1945,7 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { databaseName: widget.databaseName, schemaName: schema, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), ], ), @@ -1807,6 +1959,7 @@ class _PgSchemaNode extends StatefulWidget { required this.databaseName, required this.schemaName, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, }); final ConnectionRow connection; @@ -1819,6 +1972,7 @@ class _PgSchemaNode extends StatefulWidget { String name, PostgresObjectKind kind, )? onPostgresObjectSelected; + final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; @override State<_PgSchemaNode> createState() => _PgSchemaNodeState(); @@ -1891,43 +2045,28 @@ 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, + connection: widget.connection, + onContextRefresh: _loadObjects, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) ...[ if (_loading) @@ -1949,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, @@ -1963,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, @@ -1977,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, @@ -1991,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, @@ -2005,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, @@ -2026,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, @@ -2035,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, @@ -2044,6 +2202,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { icon: material.Icons.category_rounded, kind: PostgresObjectKind.schemaTypes, onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onContextRefresh: _loadObjects, ), ], ], @@ -2062,6 +2222,8 @@ class _PgSchemaToolRow extends material.StatelessWidget { required this.icon, required this.kind, this.onPostgresObjectSelected, + this.onPostgresOpenSqlWorkspace, + this.onContextRefresh, }); final ConnectionRow connection; @@ -2077,45 +2239,41 @@ 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) { 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, ), + connection: connection, + onContextRefresh: onContextRefresh, + onOpenSqlWorkspace: onPostgresOpenSqlWorkspace, ), ); } @@ -2123,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 @@ -2150,75 +2314,50 @@ 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), - Text('${widget.label} (${widget.items.length})') - .muted() - .xSmall(), - ], - ), + _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), + connection: widget.connection, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), 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, + 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_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..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(); @@ -20,6 +26,66 @@ 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 + 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 +111,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 +139,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/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/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', () { 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); + }); + }); } 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); + }); + }); +} 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: (_) {}, + ), + ), + ), + ); + }); + }); + } + }); +}