diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 8acd192c..70009b2e 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.2+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_macos-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_linux-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_windows-0.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-10 05:45:19.165964","version":"3.41.4","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.2+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_macos-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_linux-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_windows-0.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-14 09:50:21.245489","version":"3.41.4","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..bd375a14 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +# Сборка релизов и публикация в GitHub Releases. +# Запуск: создайте и запушьте тег, например v0.1.0: +# git tag v0.1.0 +# git push origin v0.1.0 + +name: Release + +on: + push: + tags: + - 'v*' # v0.1.0, v1.0.0, etc. + +jobs: + build-windows: + name: Build Windows + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.38.5' + channel: 'stable' + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Build Windows (release) + run: flutter build windows --release + + - name: Zip Windows artifact + run: | + $tag = "${{ github.ref_name }}" + $dir = "build\windows\x64\runner\Release" + Compress-Archive -Path "$dir\*" -DestinationPath "Querya-Desktop-$tag-windows.zip" + + - name: Create Release and upload Windows + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + generate_release_notes: true + files: Querya-Desktop-${{ github.ref_name }}-windows.zip + allow_updates: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-linux: + name: Build Linux + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.38.5' + channel: 'stable' + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Build Linux (release) + run: flutter build linux --release + + - name: Zip Linux artifact + run: | + cd build/linux/x64/release/bundle + zip -r ../../../../../../Querya-Desktop-${{ github.ref_name }}-linux.zip . + + - name: Upload to Release (Linux) + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + generate_release_notes: true + files: Querya-Desktop-${{ github.ref_name }}-linux.zip + allow_updates: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart new file mode 100644 index 00000000..eb208b19 --- /dev/null +++ b/lib/core/database/postgres_connection.dart @@ -0,0 +1,283 @@ +import 'package:postgres/postgres.dart'; + +/// PostgreSQL connection using the pure-Dart `postgres` package. +class PostgresConnection { + PostgresConnection({ + required this.id, + required this.name, + required this.host, + this.port = 5432, + this.username, + this.password, + this.database, + this.useSSL = false, + this.connectionString, + }); + + final int id; + final String name; + final String host; + final int port; + final String? username; + final String? password; + final String? database; + final bool useSSL; + final String? connectionString; + + Connection? _conn; + bool _isConnected = false; + + bool get isConnected => _isConnected && _conn != null; + + Endpoint _buildEndpoint() { + return Endpoint( + host: host, + port: port, + database: database ?? 'postgres', + username: username, + password: password, + ); + } + + ConnectionSettings _buildSettings() { + return ConnectionSettings( + sslMode: useSSL ? SslMode.require : SslMode.disable, + connectTimeout: const Duration(seconds: 10), + queryTimeout: const Duration(seconds: 30), + ); + } + + Future connect() async { + if (_isConnected && _conn != null) return; + try { + _conn = await Connection.open( + _buildEndpoint(), + settings: _buildSettings(), + ); + _isConnected = true; + } catch (e) { + _isConnected = false; + _conn = null; + rethrow; + } + } + + Future disconnect() async { + _isConnected = false; + final c = _conn; + _conn = null; + try { + await c?.close(); + } catch (_) {} + } + + Future testConnection() async { + try { + await connect(); + if (_conn != null) { + await _conn!.execute('SELECT 1'); + return true; + } + return false; + } catch (_) { + return false; + } finally { + await disconnect(); + } + } + + Future execute(String sql) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + return _conn!.execute(sql); + } + + Future> listDatabases() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname", + ); + return result.map((row) => row[0] as String).toList(); + } + + Future> listSchemas() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + "SELECT schema_name FROM information_schema.schemata " + "WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') " + "ORDER BY schema_name", + ); + return result.map((row) => row[0] as String).toList(); + } + + Future> listTables({String schema = 'public'}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + Sql.named( + "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = @schema AND table_type = 'BASE TABLE' " + "ORDER BY table_name", + ), + parameters: {'schema': schema}, + ); + return result.map((row) => row[0] as String).toList(); + } + + Future> listViews({String schema = 'public'}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + Sql.named( + "SELECT table_name FROM information_schema.views " + "WHERE table_schema = @schema " + "ORDER BY table_name", + ), + parameters: {'schema': schema}, + ); + return result.map((row) => row[0] as String).toList(); + } + + Future> listFunctions({String schema = 'public'}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + Sql.named( + "SELECT routine_name FROM information_schema.routines " + "WHERE routine_schema = @schema AND routine_type = 'FUNCTION' " + "ORDER BY routine_name", + ), + parameters: {'schema': schema}, + ); + return result.map((row) => row[0] as String).toList(); + } + + Future> listSequences({String schema = 'public'}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + Sql.named( + "SELECT sequence_name FROM information_schema.sequences " + "WHERE sequence_schema = @schema " + "ORDER BY sequence_name", + ), + parameters: {'schema': schema}, + ); + return result.map((row) => row[0] as String).toList(); + } + + Future serverVersion() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute('SELECT version()'); + return result.first[0] as String; + } + + /// Fetches key server statistics for the stats dashboard. + Future> serverStats() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final stats = {}; + + final ver = await _conn!.execute('SELECT version()'); + stats['version'] = ver.first[0] as String; + + final settings = await _conn!.execute( + "SELECT name, setting FROM pg_settings " + "WHERE name IN ('max_connections','shared_buffers','work_mem'," + "'effective_cache_size','server_version','data_directory'," + "'listen_addresses','port','server_encoding','timezone')", + ); + final settingsMap = {}; + for (final row in settings) { + settingsMap[row[0] as String] = row[1] as String; + } + stats['settings'] = settingsMap; + + final activity = await _conn!.execute( + "SELECT count(*) AS total, " + "count(*) FILTER (WHERE state = 'active') AS active, " + "count(*) FILTER (WHERE state = 'idle') AS idle " + "FROM pg_stat_activity", + ); + if (activity.isNotEmpty) { + stats['connections_total'] = activity.first[0]; + stats['connections_active'] = activity.first[1]; + stats['connections_idle'] = activity.first[2]; + } + + final dbStats = await _conn!.execute( + "SELECT datname, pg_database_size(datname) AS size, " + "numbackends, xact_commit, xact_rollback, blks_read, blks_hit, " + "tup_returned, tup_fetched, tup_inserted, tup_updated, tup_deleted " + "FROM pg_stat_database WHERE datname NOT LIKE 'template%' " + "ORDER BY datname", + ); + final dbList = >[]; + for (final row in dbStats) { + dbList.add({ + 'datname': row[0], + 'size': row[1], + 'numbackends': row[2], + 'xact_commit': row[3], + 'xact_rollback': row[4], + 'blks_read': row[5], + 'blks_hit': row[6], + 'tup_returned': row[7], + 'tup_fetched': row[8], + 'tup_inserted': row[9], + 'tup_updated': row[10], + 'tup_deleted': row[11], + }); + } + stats['databases'] = dbList; + + try { + final uptime = await _conn!.execute( + "SELECT extract(epoch from (now() - pg_postmaster_start_time()))::bigint", + ); + stats['uptime_seconds'] = uptime.first[0]; + } catch (_) {} + + try { + final dbSize = await _conn!.execute( + "SELECT pg_database_size(current_database())", + ); + stats['current_db_size'] = dbSize.first[0]; + } catch (_) {} + + return stats; + } + + /// Connect to a specific database (creates a new connection). + Future connectToDatabase(String dbName) async { + return PostgresConnection( + id: id, + name: name, + host: host, + port: port, + username: username, + password: password, + database: dbName, + useSSL: useSSL, + ); + } +} + +class PostgresConnectionException implements Exception { + PostgresConnectionException(this.message); + final String message; + @override + String toString() => message; +} diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index a25f025b..bd629e92 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material show 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; import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; @@ -8,6 +9,7 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mongodb/mongo_database_dialog.dart'; import 'package:querya_desktop/features/mongodb/mongodb_connection_form.dart'; +import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:querya_desktop/features/redis/redis_connection_form.dart'; import 'new_connection_dialog.dart'; import 'new_folder_dialog.dart'; @@ -19,6 +21,7 @@ class ConnectionsPanel extends StatefulWidget { this.onConnectionSelected, this.onRedisDatabaseSelected, this.onMongoDBDatabaseSelected, + this.onPostgresTableSelected, }); /// Called when the user taps a connection tile. @@ -32,6 +35,10 @@ class ConnectionsPanel extends StatefulWidget { final void Function(ConnectionRow connection, String database)? onMongoDBDatabaseSelected; + /// Called when the user taps a PostgreSQL table or view in the tree. + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; + @override State createState() => _ConnectionsPanelState(); } @@ -76,8 +83,7 @@ class _ConnectionsPanelState extends State { } static bool _isStubConnection(ConnectionRow c) { - return (c.type == 'postgresql' && c.name == 'PostgreSQL connection') || - (c.type == 'mysql' && c.name == 'MySQL connection'); + return c.type == 'mysql' && c.name == 'MySQL connection'; } Future _createFolder(BuildContext menuContext) async { @@ -90,12 +96,13 @@ class _ConnectionsPanelState extends State { Future _createConnection(ConnectionType type, {int? folderId}) async { ConnectionRow? row; - if (type == ConnectionType.mongodb) { + if (type == ConnectionType.postgresql) { + row = await showPostgresConnectionForm(context, folderId: folderId); + } else if (type == ConnectionType.mongodb) { row = await showMongoConnectionForm(context, folderId: folderId); } else if (type == ConnectionType.redis) { row = await showRedisConnectionForm(context, folderId: folderId); } else { - // PostgreSQL / MySQL: no connection form yet — do not create a stub row = null; } @@ -132,6 +139,44 @@ class _ConnectionsPanelState extends State { }; } + Widget _buildConnectionTile(ConnectionRow conn) { + if (conn.type == 'postgresql') { + return _PostgresConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onPostgresTableSelected: widget.onPostgresTableSelected, + ); + } else if (conn.type == 'redis') { + return _RedisConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), + ); + } else if (conn.type == 'mongodb') { + return _MongoConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), + ); + } + return _ConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -202,34 +247,11 @@ class _ConnectionsPanelState extends State { onConnectionTap: widget.onConnectionSelected, onRedisDatabaseTap: widget.onRedisDatabaseSelected, onMongoDBDatabaseTap: widget.onMongoDBDatabaseSelected, + buildConnectionTile: _buildConnectionTile, ), // Root connections (no folder) for (final conn in rootConnections) - conn.type == 'redis' - ? _RedisConnectionTile( - connection: conn, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), - onRemove: () => _removeConnection(conn.id!), - onTap: () => widget.onConnectionSelected?.call(conn), - onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), - ) - : conn.type == 'mongodb' - ? _MongoConnectionTile( - connection: conn, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), - onRemove: () => _removeConnection(conn.id!), - onTap: () => widget.onConnectionSelected?.call(conn), - onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), - ) - : _ConnectionTile( - connection: conn, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), - onRemove: () => _removeConnection(conn.id!), - onTap: () => widget.onConnectionSelected?.call(conn), - ), + _buildConnectionTile(conn), // Empty state if (_connections.isEmpty && _folders.isEmpty) const material.Padding( @@ -421,6 +443,7 @@ class _FolderTile extends StatelessWidget { this.onConnectionTap, this.onRedisDatabaseTap, this.onMongoDBDatabaseTap, + this.buildConnectionTile, }); final String name; @@ -434,6 +457,7 @@ class _FolderTile extends StatelessWidget { final void Function(ConnectionRow connection)? onConnectionTap; final void Function(ConnectionRow connection, int database)? onRedisDatabaseTap; final void Function(ConnectionRow connection, String database)? onMongoDBDatabaseTap; + final Widget Function(ConnectionRow conn)? buildConnectionTile; @override Widget build(BuildContext context) { @@ -499,31 +523,15 @@ class _FolderTile extends StatelessWidget { for (final conn in connections) material.Padding( padding: const material.EdgeInsets.only(left: 24), - child: conn.type == 'redis' - ? _RedisConnectionTile( + child: buildConnectionTile != null + ? buildConnectionTile!(conn) + : _ConnectionTile( connection: conn, icon: iconForType(conn.type), iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), onRemove: () => onRemoveConnection(conn.id!), onTap: () => onConnectionTap?.call(conn), - onDatabaseTap: (db) => onRedisDatabaseTap?.call(conn, db), - ) - : conn.type == 'mongodb' - ? _MongoConnectionTile( - connection: conn, - icon: iconForType(conn.type), - iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => onRemoveConnection(conn.id!), - onTap: () => onConnectionTap?.call(conn), - onDatabaseTap: (db) => onMongoDBDatabaseTap?.call(conn, db), - ) - : _ConnectionTile( - connection: conn, - icon: iconForType(conn.type), - iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => onRemoveConnection(conn.id!), - onTap: () => onConnectionTap?.call(conn), - ), + ), ), ], ), @@ -1180,3 +1188,800 @@ class _MongoDatabaseNode extends StatelessWidget { ); } } + +// ─── PostgreSQL connection tile with expandable database tree ──────────────── + +class _PostgresConnectionTile extends StatefulWidget { + const _PostgresConnectionTile({ + required this.connection, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onPostgresTableSelected, + }); + + final ConnectionRow connection; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; + + @override + State<_PostgresConnectionTile> createState() => + _PostgresConnectionTileState(); +} + +class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { + bool _expanded = false; + bool _loading = false; + String? _error; + List _databases = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _databases.isEmpty && !_loading) { + _loadDatabases(); + } + } + + Future _loadDatabases() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final c = widget.connection; + final conn = PostgresConnection( + id: -1, + name: 'sidebar_probe', + host: c.host ?? 'localhost', + port: c.port ?? 5432, + username: c.username, + password: c.password, + database: c.databaseName ?? 'postgres', + useSSL: c.useSSL, + ); + await conn.connect(); + final dbs = await conn.listDatabases(); + await conn.disconnect(); + + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = widget.iconAsset != null + ? material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); + + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + _databases = []; + _loadDatabases(); + }, + child: const Text('Refresh databases'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + material.Expanded( + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: widget.onTap, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: + const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Row( + children: [ + const material.SizedBox( + width: 12, + height: 12, + child: + material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_error != null) + material.Padding( + padding: + const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Text( + 'Error', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), + ), + if (_databases.isNotEmpty) + _PgDatabasesNode( + connection: widget.connection, + databases: _databases, + onPostgresTableSelected: widget.onPostgresTableSelected, + ), + ], + ], + ), + ), + ); + } +} + +class _PgDatabasesNode extends StatefulWidget { + const _PgDatabasesNode({ + required this.connection, + required this.databases, + this.onPostgresTableSelected, + }); + + final ConnectionRow connection; + final List databases; + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; + + @override + State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); +} + +class _PgDatabasesNodeState extends State<_PgDatabasesNode> { + bool _expanded = true; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: material.Column( + 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(), + ], + ), + ), + ), + ), + if (_expanded) + for (final db in widget.databases) + _PgDatabaseNode( + connection: widget.connection, + databaseName: db, + onPostgresTableSelected: widget.onPostgresTableSelected, + ), + ], + ), + ); + } +} + +class _PgDatabaseNode extends StatefulWidget { + const _PgDatabaseNode({ + required this.connection, + required this.databaseName, + this.onPostgresTableSelected, + }); + + final ConnectionRow connection; + final String databaseName; + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; + + @override + State<_PgDatabaseNode> createState() => _PgDatabaseNodeState(); +} + +class _PgDatabaseNodeState extends State<_PgDatabaseNode> { + bool _expanded = false; + bool _loading = false; + List _schemas = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _schemas.isEmpty && !_loading) { + _loadSchemas(); + } + } + + Future _loadSchemas() async { + if (!mounted) return; + setState(() => _loading = true); + try { + final c = widget.connection; + final conn = PostgresConnection( + id: -1, + name: 'probe', + host: c.host ?? 'localhost', + port: c.port ?? 5432, + username: c.username, + password: c.password, + database: widget.databaseName, + useSSL: c.useSSL, + ); + await conn.connect(); + final schemas = await conn.listSchemas(); + await conn.disconnect(); + if (!mounted) return; + setState(() { + _schemas = schemas; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + 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, + ), + ), + ), + ], + ), + ), + ), + ), + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: + const material.EdgeInsets.only(left: 24, top: 2, bottom: 2), + child: material.Row( + children: [ + const material.SizedBox( + width: 10, + height: 10, + child: + material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(6), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_schemas.isNotEmpty) + _PgSchemasNode( + connection: widget.connection, + databaseName: widget.databaseName, + schemas: _schemas, + onPostgresTableSelected: widget.onPostgresTableSelected, + ), + ], + ], + ), + ); + } +} + +class _PgSchemasNode extends StatefulWidget { + const _PgSchemasNode({ + required this.connection, + required this.databaseName, + required this.schemas, + this.onPostgresTableSelected, + }); + + final ConnectionRow connection; + final String databaseName; + final List schemas; + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; + + @override + State<_PgSchemasNode> createState() => _PgSchemasNodeState(); +} + +class _PgSchemasNodeState extends State<_PgSchemasNode> { + bool _expanded = true; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + 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(), + ], + ), + ), + ), + ), + if (_expanded) + for (final schema in widget.schemas) + _PgSchemaNode( + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: schema, + onPostgresTableSelected: widget.onPostgresTableSelected, + ), + ], + ), + ); + } +} + +class _PgSchemaNode extends StatefulWidget { + const _PgSchemaNode({ + required this.connection, + required this.databaseName, + required this.schemaName, + this.onPostgresTableSelected, + }); + + final ConnectionRow connection; + final String databaseName; + final String schemaName; + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; + + @override + State<_PgSchemaNode> createState() => _PgSchemaNodeState(); +} + +class _PgSchemaNodeState extends State<_PgSchemaNode> { + bool _expanded = false; + bool _loading = false; + List _tables = []; + List _views = []; + List _functions = []; + List _sequences = []; + bool _loaded = false; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && !_loaded && !_loading) { + _loadObjects(); + } + } + + Future _loadObjects() async { + if (!mounted) return; + setState(() => _loading = true); + try { + final c = widget.connection; + final conn = PostgresConnection( + id: -1, + name: 'probe', + host: c.host ?? 'localhost', + port: c.port ?? 5432, + username: c.username, + password: c.password, + database: widget.databaseName, + useSSL: c.useSSL, + ); + await conn.connect(); + final tables = await conn.listTables(schema: widget.schemaName); + final views = await conn.listViews(schema: widget.schemaName); + final functions = await conn.listFunctions(schema: widget.schemaName); + final sequences = await conn.listSequences(schema: widget.schemaName); + await conn.disconnect(); + if (!mounted) return; + setState(() { + _tables = tables; + _views = views; + _functions = functions; + _sequences = sequences; + _loading = false; + _loaded = true; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: material.Column( + 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), + ), + ), + ], + ), + ), + ), + ), + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: + const material.EdgeInsets.only(left: 24, top: 2, bottom: 2), + child: material.Row( + children: [ + const material.SizedBox( + width: 10, + height: 10, + child: + material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(6), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_loaded) ...[ + _PgObjectGroup( + label: 'Tables', + icon: material.Icons.table_chart_rounded, + items: _tables, + onItemTap: widget.onPostgresTableSelected != null + ? (name) => widget.onPostgresTableSelected!( + widget.connection, + widget.databaseName, + widget.schemaName, + name, + false, + ) + : null, + ), + _PgObjectGroup( + label: 'Views', + icon: material.Icons.view_agenda_rounded, + items: _views, + onItemTap: widget.onPostgresTableSelected != null + ? (name) => widget.onPostgresTableSelected!( + widget.connection, + widget.databaseName, + widget.schemaName, + name, + true, + ) + : null, + ), + _PgObjectGroup( + label: 'Functions', + icon: material.Icons.functions_rounded, + items: _functions), + _PgObjectGroup( + label: 'Sequences', + icon: material.Icons.format_list_numbered_rounded, + items: _sequences), + ], + ], + ], + ), + ); + } +} + +class _PgObjectGroup extends StatefulWidget { + const _PgObjectGroup({ + required this.label, + required this.icon, + required this.items, + this.onItemTap, + }); + + final String label; + final material.IconData icon; + final List items; + final void Function(String itemName)? onItemTap; + + @override + State<_PgObjectGroup> createState() => _PgObjectGroupState(); +} + +class _PgObjectGroupState extends State<_PgObjectGroup> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + 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(), + ], + ), + ), + ), + ), + 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, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 661de173..6dbd2e00 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -30,11 +30,31 @@ class _MainScreenState extends State { /// Currently selected MongoDB database (null = show stats). String? _activeMongoDB; + /// When set, user selected a PostgreSQL table/view in the tree. + ({String database, String schema, String tableName, bool isView})? + _activePostgresTable; + void _onConnectionSelected(ConnectionRow connection) { setState(() { _activeConnection = connection; - _activeRedisDb = null; // Reset: connection click → stats + _activeRedisDb = null; + _activeMongoDB = null; + _activePostgresTable = null; + }); + } + + void _onPostgresTableSelected(ConnectionRow connection, String database, + String schema, String tableOrViewName, bool isView) { + setState(() { + _activeConnection = connection; + _activeRedisDb = null; _activeMongoDB = null; + _activePostgresTable = ( + database: database, + schema: schema, + tableName: tableOrViewName, + isView: isView, + ); }); } @@ -77,6 +97,7 @@ class _MainScreenState extends State { onConnectionSelected: _onConnectionSelected, onRedisDatabaseSelected: _onRedisDatabaseSelected, onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, + onPostgresTableSelected: _onPostgresTableSelected, ), ), _VerticalResizeHandle( @@ -92,6 +113,7 @@ class _MainScreenState extends State { activeConnection: _activeConnection, selectedRedisDb: _activeRedisDb, selectedMongoDb: _activeMongoDB, + selectedPostgresTable: _activePostgresTable, ), ), ], diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index cfddbe6a..fa822136 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -4,6 +4,8 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_stats_view.dart'; +import 'package:querya_desktop/features/postgresql/postgres_stats_view.dart'; +import 'package:querya_desktop/features/postgresql/postgres_table_view.dart'; import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'query_editor_tab.dart'; @@ -16,6 +18,7 @@ class WorkspacePanel extends StatefulWidget { this.activeConnection, this.selectedRedisDb, this.selectedMongoDb, + this.selectedPostgresTable, }); /// Currently selected connection from the sidebar. @@ -29,6 +32,11 @@ class WorkspacePanel extends StatefulWidget { /// null = show stats, non-null = show data explorer for that db. final String? selectedMongoDb; + /// When set, the user selected a PostgreSQL table/view in the sidebar tree. + /// null = show stats, non-null = show table data view. + final ({String database, String schema, String tableName, bool isView})? + selectedPostgresTable; + @override State createState() => _WorkspacePanelState(); } @@ -42,6 +50,31 @@ class _WorkspacePanelState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); + // If a PostgreSQL connection is selected → stats or table data + if (widget.activeConnection != null && + widget.activeConnection!.type == 'postgresql') { + final pgTable = widget.selectedPostgresTable; + return material.Container( + color: theme.colorScheme.background, + child: material.SizedBox.expand( + child: pgTable != null + ? PostgresTableView( + key: ValueKey( + 'pg_table_${widget.activeConnection!.id}_${pgTable.schema}_${pgTable.tableName}'), + connectionRow: widget.activeConnection!, + database: pgTable.database, + schema: pgTable.schema, + tableName: pgTable.tableName, + isView: pgTable.isView, + ) + : PostgresStatsView( + key: ValueKey('pg_stats_${widget.activeConnection!.id}'), + connectionRow: widget.activeConnection!, + ), + ), + ); + } + // If a MongoDB connection is selected if (widget.activeConnection != null && widget.activeConnection!.type == 'mongodb') { diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart new file mode 100644 index 00000000..c9bc798e --- /dev/null +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -0,0 +1,538 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +const _pollInterval = Duration(seconds: 5); +const _summaryChipHeight = 72.0; +const _gridCardHeight = 220.0; + +class PostgresStatsView extends material.StatefulWidget { + const PostgresStatsView({ + super.key, + required this.connectionRow, + }); + + final ConnectionRow connectionRow; + + @override + material.State createState() => _PostgresStatsViewState(); +} + +class _PostgresStatsViewState extends material.State { + PostgresConnection? _connection; + Map? _stats; + bool _loading = true; + String? _error; + Timer? _timer; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant PostgresStatsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _timer?.cancel(); + _disconnectCurrent(); + _load(); + } + } + + @override + void dispose() { + _timer?.cancel(); + _disconnectCurrent(); + super.dispose(); + } + + void _disconnectCurrent() { + final conn = _connection; + _connection = null; + conn?.disconnect(); + } + + Future _load() async { + _timer?.cancel(); + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _stats = null; + }); + try { + final c = widget.connectionRow; + final conn = PostgresConnection( + id: c.id ?? 0, + name: c.name, + host: c.host ?? 'localhost', + port: c.port ?? 5432, + username: c.username, + password: c.password, + database: c.databaseName ?? 'postgres', + useSSL: c.useSSL, + ); + await conn.connect(); + if (!mounted) { + conn.disconnect(); + return; + } + _connection = conn; + await _fetch(); + if (mounted) _startTimer(); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + Future _fetch() async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final stats = await c.serverStats(); + if (!mounted) return; + setState(() { + _stats = stats; + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(_pollInterval, (_) async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final stats = await c.serverStats(); + if (!mounted) return; + setState(() => _stats = stats); + } catch (_) {} + }); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final width = MediaQuery.sizeOf(context).width; + + if (_loading) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Connecting...').muted().small(), + ], + ), + ); + } + + if (_error != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('Connection Error').large().semiBold(), + const Gap(8), + material.SelectableText(_error!, + style: material.TextStyle( + color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + final stats = _stats; + if (stats == null) return material.Container(color: cs.background); + + return material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: width, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), + const Gap(24), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: _connectionsCard(context, stats)), + const Gap(16), + material.Expanded( + child: _serverSettingsCard(context, stats)), + ], + ), + const Gap(16), + _databasesCard(context, stats), + ], + ), + ), + ), + ), + ); + } + + material.Widget _header(material.BuildContext context) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(12), + ), + child: material.SizedBox( + width: 28, + height: 28, + child: material.Image.asset( + 'assets/images/postgresql_icon.png', + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + material.Icons.storage_rounded, + size: 28, + color: cs.primary), + ), + ), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(widget.connectionRow.name).large().semiBold(), + const Gap(4), + Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 5432}') + .muted() + .small(), + ], + ), + ), + OutlineButton( + onPressed: _load, + leading: + const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh'), + ), + ], + ); + } + + material.Widget _summaryChips( + material.BuildContext context, Map stats) { + final cs = shadcn.Theme.of(context).colorScheme; + final versionFull = stats['version'] as String? ?? '—'; + final versionShort = _extractPgVersion(versionFull); + final settings = stats['settings'] as Map? ?? {}; + final maxConn = settings['max_connections'] ?? '—'; + final totalConn = stats['connections_total'] ?? 0; + final uptimeSec = stats['uptime_seconds'] as int?; + final uptimeStr = uptimeSec != null + ? '${uptimeSec ~/ 86400}d ${(uptimeSec % 86400) ~/ 3600}h' + : '—'; + final dbSize = stats['current_db_size']; + final dbSizeStr = dbSize != null ? _formatBytes(dbSize as int) : '—'; + + material.Widget chip( + String label, String value, material.IconData icon) { + return material.Expanded( + child: material.SizedBox( + height: _summaryChipHeight, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.5)), + ), + child: material.Row( + children: [ + material.Icon(icon, size: 20, color: cs.primary), + const Gap(12), + material.Expanded( + child: material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).muted().xSmall(), + const Gap(2), + Text(value).semiBold().small(), + ], + ), + ), + ], + ), + ), + ), + ); + } + + return material.Row( + children: [ + chip('Version', versionShort, material.Icons.tag_rounded), + const Gap(12), + chip('Uptime', uptimeStr, material.Icons.schedule_rounded), + const Gap(12), + chip('Connections', '$totalConn / $maxConn', + material.Icons.people_outline_rounded), + const Gap(12), + chip('DB Size', dbSizeStr, material.Icons.storage_rounded), + ], + ); + } + + material.Widget _card(material.BuildContext context, String title, + material.Widget body, + {double? height}) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Container( + width: double.infinity, + height: height, + padding: const material.EdgeInsets.all(20), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(12), + border: + material.Border.all(color: cs.border.withValues(alpha: 0.4)), + ), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text(title).semiBold(), + const Gap(12), + body, + ], + ), + ); + } + + material.Widget _connectionsCard( + material.BuildContext context, Map stats) { + final total = stats['connections_total'] ?? 0; + final active = stats['connections_active'] ?? 0; + final idle = stats['connections_idle'] ?? 0; + final settings = stats['settings'] as Map? ?? {}; + final maxConn = settings['max_connections'] ?? '—'; + return _card( + context, + 'Connections', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Total', '$total'), + _row(context, 'Active', '$active'), + _row(context, 'Idle', '$idle'), + _row(context, 'Max connections', maxConn), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _serverSettingsCard( + material.BuildContext context, Map stats) { + final settings = stats['settings'] as Map? ?? {}; + return _card( + context, + 'Server Settings', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Shared buffers', settings['shared_buffers'] ?? '—'), + _row(context, 'Work mem', settings['work_mem'] ?? '—'), + _row(context, 'Effective cache', + settings['effective_cache_size'] ?? '—'), + _row(context, 'Encoding', settings['server_encoding'] ?? '—'), + _row(context, 'Timezone', settings['timezone'] ?? '—'), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _databasesCard( + material.BuildContext context, Map stats) { + final databases = + stats['databases'] as List>? ?? []; + if (databases.isEmpty) return const material.SizedBox.shrink(); + + final cs = shadcn.Theme.of(context).colorScheme; + return _card( + context, + 'Databases', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Padding( + padding: const material.EdgeInsets.only(bottom: 8), + child: material.Row( + children: [ + material.SizedBox( + width: 140, child: const Text('Name').muted().xSmall()), + material.SizedBox( + width: 80, child: const Text('Size').muted().xSmall()), + material.SizedBox( + width: 70, + child: const Text('Backends').muted().xSmall()), + material.SizedBox( + width: 80, + child: const Text('Commits').muted().xSmall()), + material.SizedBox( + width: 80, + child: const Text('Rollbacks').muted().xSmall()), + material.Expanded( + child: const Text('Hit ratio').muted().xSmall()), + ], + ), + ), + material.Divider( + height: 1, color: cs.border.withValues(alpha: 0.3)), + for (final db in databases) + material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 5), + child: material.Row( + children: [ + material.SizedBox( + width: 140, + child: material.Text( + '${db['datname']}', + style: material.TextStyle( + fontSize: 13, color: cs.foreground), + overflow: material.TextOverflow.ellipsis, + ), + ), + material.SizedBox( + width: 80, + child: Text(_formatBytes( + (db['size'] as int?) ?? 0)) + .muted() + .xSmall(), + ), + material.SizedBox( + width: 70, + child: Text('${db['numbackends'] ?? 0}') + .muted() + .xSmall(), + ), + material.SizedBox( + width: 80, + child: Text('${db['xact_commit'] ?? 0}') + .muted() + .xSmall(), + ), + material.SizedBox( + width: 80, + child: Text('${db['xact_rollback'] ?? 0}') + .muted() + .xSmall(), + ), + material.Expanded( + child: Text(_hitRatio(db)).muted().xSmall(), + ), + ], + ), + ), + ], + ), + ); + } + + material.Widget _row( + material.BuildContext context, String key, String value) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 4), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox(width: 160, child: Text(key).muted().small()), + material.Expanded( + child: material.SelectableText(value, + style: material.TextStyle( + fontSize: 13, color: cs.foreground))), + ], + ), + ); + } + + String _extractPgVersion(String full) { + final match = RegExp(r'PostgreSQL\s+([\d.]+)').firstMatch(full); + return match?.group(1) ?? full; + } + + String _hitRatio(Map db) { + final hits = (db['blks_hit'] as int?) ?? 0; + final reads = (db['blks_read'] as int?) ?? 0; + final total = hits + reads; + if (total == 0) return '—'; + return '${(hits / total * 100).toStringAsFixed(1)}%'; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} diff --git a/lib/features/postgresql/postgres_table_utils.dart b/lib/features/postgresql/postgres_table_utils.dart new file mode 100644 index 00000000..bf50e816 --- /dev/null +++ b/lib/features/postgresql/postgres_table_utils.dart @@ -0,0 +1,19 @@ +/// Utilities for PostgreSQL table data view (quoting, row conversion). +library; + +/// Quotes a PostgreSQL identifier (e.g. schema or table name). +/// Doubles any internal double-quote. +String quotePostgresIdentifier(String name) { + return '"${name.replaceAll('"', '""')}"'; +} + +/// Converts raw result rows (list of dynamic values per row) to list of string rows. +List> convertResultRowsToStrings(List> rawRows) { + return rawRows.map((row) { + return row.map((value) { + if (value == null) return 'NULL'; + if (value is DateTime) return value.toIso8601String(); + return value.toString(); + }).toList(); + }).toList(); +} diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart new file mode 100644 index 00000000..24211c1c --- /dev/null +++ b/lib/features/postgresql/postgres_table_view.dart @@ -0,0 +1,447 @@ +import 'package:flutter/foundation.dart' show compute; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const _defaultLimit = 200; + +class PostgresTableView extends material.StatefulWidget { + const PostgresTableView({ + super.key, + required this.connectionRow, + required this.database, + required this.schema, + required this.tableName, + this.isView = false, + this.limit = _defaultLimit, + }); + + final ConnectionRow connectionRow; + final String database; + final String schema; + final String tableName; + final bool isView; + final int limit; + + @override + material.State createState() => + _PostgresTableViewState(); +} + +class _PostgresTableViewState extends material.State { + PostgresConnection? _connection; + bool _loading = true; + String? _error; + + List _columnNames = []; + List> _rows = []; + int _totalRows = 0; + + final _verticalController = material.ScrollController(); + final _horizontalController = material.ScrollController(); + + @override + void initState() { + super.initState(); + _connectAndLoad(); + } + + @override + void didUpdateWidget(covariant PostgresTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database || + oldWidget.schema != widget.schema || + oldWidget.tableName != widget.tableName) { + _disconnectCurrent(); + _connectAndLoad(); + } + } + + @override + void dispose() { + _verticalController.dispose(); + _horizontalController.dispose(); + _disconnectCurrent(); + super.dispose(); + } + + void _disconnectCurrent() { + final conn = _connection; + _connection = null; + conn?.disconnect(); + } + + Future _connectAndLoad() async { + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _columnNames = []; + _rows = []; + _totalRows = 0; + }); + try { + final c = widget.connectionRow; + final conn = PostgresConnection( + id: c.id ?? 0, + name: c.name, + host: c.host ?? 'localhost', + port: c.port ?? 5432, + username: c.username, + password: c.password, + database: widget.database, + useSSL: c.useSSL, + ); + await conn.connect(); + if (!mounted) { + conn.disconnect(); + return; + } + _connection = conn; + await _fetch(); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + Future _fetch() async { + final conn = _connection; + if (conn == null || !conn.isConnected) return; + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + final schemaQ = quotePostgresIdentifier(widget.schema); + final tableQ = quotePostgresIdentifier(widget.tableName); + final sql = 'SELECT * FROM $schemaQ.$tableQ LIMIT ${widget.limit}'; + try { + final result = await conn.execute(sql); + if (!mounted) return; + + final colNames = List.generate( + result.schema.columns.length, + (i) => result.schema.columns[i].columnName ?? 'col_$i', + ); + + // Convert to plain strings in isolate to avoid blocking UI + final rawRows = result.map((row) { + return List.generate(row.length, (i) => row[i]); + }).toList(); + + final stringRows = await compute(convertResultRowsToStrings, rawRows); + + if (!mounted) return; + setState(() { + _columnNames = colNames; + _rows = stringRows; + _totalRows = stringRows.length; + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_loading) { + return material.Container( + color: cs.background, + child: material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 28, + height: 28, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(12), + const Text('Loading data...').muted().small(), + ], + ), + ), + ); + } + + if (_error != null) { + return material.Container( + color: cs.background, + child: material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('Query Error').large().semiBold(), + const Gap(8), + material.SelectableText(_error!, + style: material.TextStyle( + color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: _fetch, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + + if (_columnNames.isEmpty) { + return material.Container(color: cs.background); + } + + const double rowHeight = 36; + const double headerHeight = 40; + final colCount = _columnNames.length; + + return material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Toolbar + material.Container( + padding: const material.EdgeInsets.fromLTRB(16, 10, 16, 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.5)), + ), + ), + child: material.Row( + children: [ + material.Icon( + widget.isView + ? material.Icons.view_agenda_rounded + : material.Icons.table_chart_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: material.Text( + '${widget.schema}.${widget.tableName}' + '${widget.isView ? ' (view)' : ''}', + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + overflow: material.TextOverflow.ellipsis, + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + '$_totalRows rows', + style: material.TextStyle( + fontSize: 11, color: cs.mutedForeground), + ), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _fetch, + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14), + child: const Text('Refresh'), + ), + ], + ), + ), + // Table grid + material.Expanded( + child: material.LayoutBuilder( + builder: (context, constraints) { + return material.Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (_) => true, + child: material.SingleChildScrollView( + controller: _horizontalController, + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: _calcTableWidth(colCount, constraints.maxWidth), + child: material.Column( + children: [ + // Header + material.Container( + height: headerHeight, + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.5)), + ), + ), + child: material.Row( + children: [ + _rowNumberCell(cs, '#', isHeader: true), + for (var i = 0; i < colCount; i++) + _headerCell(cs, _columnNames[i]), + ], + ), + ), + // Data rows via ListView.builder for virtualization + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: material.ListView.builder( + controller: _verticalController, + itemCount: _totalRows, + itemExtent: rowHeight, + itemBuilder: (context, rowIdx) { + final row = _rows[rowIdx]; + final isEven = rowIdx % 2 == 0; + return material.Container( + height: rowHeight, + decoration: material.BoxDecoration( + color: isEven + ? material.Colors.transparent + : cs.muted.withValues(alpha: 0.12), + border: material.Border( + bottom: material.BorderSide( + color: + cs.border.withValues(alpha: 0.15), + ), + ), + ), + child: material.Row( + children: [ + _rowNumberCell(cs, + '${rowIdx + 1}'), + for (var c = 0; + c < colCount; + c++) + _dataCell( + cs, + row.length > c + ? row[c] + : ''), + ], + ), + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + double _calcTableWidth(int colCount, double availableWidth) { + const double rowNumWidth = 52; + const double minColWidth = 150; + final calculated = rowNumWidth + colCount * minColWidth; + return calculated > availableWidth ? calculated : availableWidth; + } + + material.Widget _rowNumberCell(ColorScheme cs, String text, + {bool isHeader = false}) { + return material.Container( + width: 52, + padding: const material.EdgeInsets.symmetric(horizontal: 8), + alignment: material.Alignment.centerRight, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide(color: cs.border.withValues(alpha: 0.3)), + ), + ), + child: material.Text( + text, + style: material.TextStyle( + fontSize: 11, + fontWeight: + isHeader ? material.FontWeight.w600 : material.FontWeight.normal, + color: cs.mutedForeground.withValues(alpha: 0.7), + fontFamily: 'monospace', + ), + ), + ); + } + + material.Widget _headerCell(ColorScheme cs, String name) { + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + child: material.Text( + name, + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ); + } + + material.Widget _dataCell(ColorScheme cs, String value) { + final isNull = value == 'NULL'; + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + child: material.Text( + value, + style: material.TextStyle( + fontSize: 12, + color: isNull + ? cs.mutedForeground.withValues(alpha: 0.5) + : cs.foreground, + fontStyle: + isNull ? material.FontStyle.italic : material.FontStyle.normal, + ), + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ); + } +} diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart new file mode 100644 index 00000000..1903754d --- /dev/null +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -0,0 +1,459 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows PostgreSQL connection form dialog. +/// Returns ConnectionRow if saved, null if cancelled. +Future showPostgresConnectionForm( + BuildContext context, { + int? folderId, +}) async { + return showDialog( + context: context, + barrierColor: material.Colors.black54, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: + const material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), + child: _PostgresConnectionFormContent(folderId: folderId), + ), + ); +} + +class _PostgresConnectionFormContent extends material.StatefulWidget { + const _PostgresConnectionFormContent({this.folderId}); + + final int? folderId; + + @override + material.State<_PostgresConnectionFormContent> createState() => + _PostgresConnectionFormContentState(); +} + +class _PostgresConnectionFormContentState + extends material.State<_PostgresConnectionFormContent> { + final _nameController = material.TextEditingController(); + final _hostController = material.TextEditingController(text: 'localhost'); + final _portController = material.TextEditingController(text: '5432'); + final _databaseController = material.TextEditingController(text: 'postgres'); + final _usernameController = material.TextEditingController(text: 'postgres'); + final _passwordController = material.TextEditingController(); + + bool _useSSL = false; + bool _showPassword = false; + bool _isTesting = false; + String? _testResult; + Timer? _dismissTimer; + + @override + void initState() { + super.initState(); + _nameController.addListener(_onFieldChanged); + _hostController.addListener(_onFieldChanged); + _portController.addListener(_onFieldChanged); + _databaseController.addListener(_onFieldChanged); + _usernameController.addListener(_onFieldChanged); + } + + void _onFieldChanged() => setState(() {}); + + bool get _formValid { + final host = _hostController.text.trim(); + final db = _databaseController.text.trim(); + return host.isNotEmpty && db.isNotEmpty; + } + + void _showTestResult(String result) { + _dismissTimer?.cancel(); + setState(() { + _isTesting = false; + _testResult = result; + }); + _dismissTimer = Timer(const Duration(seconds: 5), () { + if (mounted) setState(() => _testResult = null); + }); + } + + void _dismissResult() { + _dismissTimer?.cancel(); + _dismissTimer = null; + setState(() => _testResult = null); + } + + Future _testConnection() async { + if (!_formValid) return; + _dismissTimer?.cancel(); + _dismissTimer = null; + setState(() { + _isTesting = true; + _testResult = null; + }); + try { + final conn = PostgresConnection( + id: 0, + name: _nameController.text.trim().isEmpty + ? 'test' + : _nameController.text.trim(), + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 5432, + database: _databaseController.text.trim().isEmpty + ? null + : _databaseController.text.trim(), + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: _passwordController.text.isEmpty + ? null + : _passwordController.text, + useSSL: _useSSL, + ); + final ok = await conn.testConnection(); + if (mounted) _showTestResult(ok ? 'success' : 'failed'); + } catch (e) { + if (mounted) _showTestResult('error: $e'); + } + } + + void _save() { + if (!_formValid) return; + final name = _nameController.text.trim(); + final host = _hostController.text.trim(); + final port = int.tryParse(_portController.text.trim()) ?? 5432; + final database = _databaseController.text.trim(); + final displayName = + name.isNotEmpty ? name : 'PostgreSQL $host:$port/$database'; + final row = ConnectionRow( + type: 'postgresql', + name: displayName, + host: host, + port: port, + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: + _passwordController.text.isEmpty ? null : _passwordController.text, + databaseName: database.isEmpty ? null : database, + useSSL: _useSSL, + folderId: widget.folderId, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); + material.Navigator.of(context).pop(row); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _nameController.removeListener(_onFieldChanged); + _hostController.removeListener(_onFieldChanged); + _portController.removeListener(_onFieldChanged); + _databaseController.removeListener(_onFieldChanged); + _usernameController.removeListener(_onFieldChanged); + _nameController.dispose(); + _hostController.dispose(); + _portController.dispose(); + _databaseController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + + return material.Container( + constraints: + const material.BoxConstraints(maxWidth: 600, maxHeight: 640), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + material.SizedBox( + width: 24, + height: 24, + child: material.Image.asset( + 'assets/images/postgresql_icon.png', + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + material.Icons.storage_rounded, + size: 24, + color: theme.primary, + ), + ), + ), + const Gap(12), + const Text('PostgreSQL Connection').large().semiBold(), + ], + ), + const Gap(8), + const Text('Configure your PostgreSQL connection settings') + .muted() + .small(), + ], + ), + ), + const material.Divider(height: 1), + // Form body + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Connection Name + const Text('Connection Name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('My PostgreSQL Server'), + ), + const Gap(16), + // Host + Port + material.Row( + children: [ + material.Expanded( + flex: 3, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Host').small().semiBold(), + const Gap(8), + TextField( + controller: _hostController, + placeholder: const Text('localhost'), + ), + ], + ), + ), + const Gap(12), + material.Expanded( + flex: 1, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Port').small().semiBold(), + const Gap(8), + TextField( + controller: _portController, + placeholder: const Text('5432'), + ), + ], + ), + ), + ], + ), + const Gap(16), + // Database + const Text('Database').small().semiBold(), + const Gap(8), + TextField( + controller: _databaseController, + placeholder: const Text('postgres'), + ), + const Gap(16), + // Username + const Text('Username').small().semiBold(), + const Gap(8), + TextField( + controller: _usernameController, + placeholder: const Text('postgres'), + ), + const Gap(16), + // Password + const Text('Password').small().semiBold(), + const Gap(8), + material.Stack( + children: [ + TextField( + controller: _passwordController, + placeholder: const Text('Password'), + obscureText: !_showPassword, + ), + material.Positioned( + right: 8, + top: 0, + bottom: 0, + child: material.Center( + child: material.IconButton( + icon: material.Icon( + _showPassword + ? material.Icons.visibility_off + : material.Icons.visibility, + size: 20, + ), + onPressed: () => + setState(() => _showPassword = !_showPassword), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(), + ), + ), + ), + ], + ), + const Gap(16), + // SSL toggle + material.Row( + children: [ + material.Checkbox( + value: _useSSL, + onChanged: (v) => + setState(() => _useSSL = v ?? false), + ), + const Gap(8), + const Text('Use SSL/TLS').small(), + ], + ), + ], + ), + ), + ), + const material.Divider(height: 1), + // Test result banner + if (_testResult != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 8, 16, 8), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: _dismissResult, + borderRadius: material.BorderRadius.circular(8), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: material.BoxDecoration( + color: _testResult == 'success' + ? theme.primary.withValues(alpha: 0.12) + : theme.destructive.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _testResult == 'success' + ? theme.primary.withValues(alpha: 0.35) + : theme.destructive.withValues(alpha: 0.35), + width: 1, + ), + ), + child: material.Row( + children: [ + material.Icon( + _testResult == 'success' + ? material.Icons.check_circle_outline + : material.Icons.info_outline_rounded, + size: 18, + color: _testResult == 'success' + ? theme.primary + : theme.destructive, + ), + const Gap(10), + material.Expanded( + child: Text( + _testResult == 'success' + ? 'Connection successful!' + : _testResult!.startsWith('error:') + ? _testResult!.substring(7) + : 'Connection failed', + style: material.TextStyle( + fontSize: 13, + color: theme.foreground, + ), + ).small(), + ), + material.IconButton( + icon: material.Icon( + material.Icons.close, + size: 18, + color: theme.mutedForeground, + ), + onPressed: _dismissResult, + style: material.IconButton.styleFrom( + minimumSize: const material.Size(28, 28), + padding: material.EdgeInsets.zero, + ), + ), + ], + ), + ), + ), + ), + ), + // Footer — Wrap avoids overflow on narrow viewports (e.g. in tests) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, + children: [ + OutlineButton( + onPressed: + _formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: _formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: + _formValid ? theme.primary : theme.mutedForeground, + ), + ), + ), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: _formValid ? _save : null, + child: const Text('Save'), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 69136f29..7a4bd2d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: sqflite_common_ffi: ^2.3.2 mongo_dart: ^0.10.8 redis: ^4.0.0 + postgres: ^3.5.6 fl_chart: ^0.69.0 dev_dependencies: diff --git a/test/core/database/mongodb_connection_test.dart b/test/core/database/mongodb_connection_test.dart index 51a4254a..d3dfbec3 100644 --- a/test/core/database/mongodb_connection_test.dart +++ b/test/core/database/mongodb_connection_test.dart @@ -70,6 +70,16 @@ void main() { expect(conn.buildConnectionUri(), 'mongodb://localhost/mydb'); }); + test('empty database is omitted from path', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + database: '', + ); + expect(conn.buildConnectionUri(), 'mongodb://localhost'); + }); + test('authSource is added as query parameter', () { final conn = MongoConnection( id: 1, diff --git a/test/core/database/mongodb_uri_replacement_test.dart b/test/core/database/mongodb_uri_replacement_test.dart index f98fc459..2ac7531c 100644 --- a/test/core/database/mongodb_uri_replacement_test.dart +++ b/test/core/database/mongodb_uri_replacement_test.dart @@ -188,5 +188,16 @@ void main() { expect(uri, contains('/testdb')); expect(uri, isNot(contains('authSource'))); }); + + test('buildUriForDatabase with empty database name uses root path', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + final uri = conn.buildUriForDatabase(''); + expect(uri, contains('mongodb://')); + expect(Uri.parse(uri).path, '/'); + }); }); } diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart new file mode 100644 index 00000000..1d53e00b --- /dev/null +++ b/test/core/database/postgres_connection_test.dart @@ -0,0 +1,287 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/postgres_connection.dart'; + +void main() { + group('PostgresConnection initial state', () { + test('isConnected is false before connect()', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + expect(conn.isConnected, false); + }); + + test('default port is 5432', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + expect(conn.port, 5432); + }); + + test('default useSSL is false', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + expect(conn.useSSL, false); + }); + + test('custom port and useSSL are stored', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + port: 5433, + useSSL: true, + ); + expect(conn.port, 5433); + expect(conn.useSSL, true); + }); + + test('stores all constructor parameters', () { + final conn = PostgresConnection( + id: 42, + name: 'My PostgreSQL', + host: 'pg.example.com', + port: 5433, + username: 'admin', + password: 's3cret', + database: 'appdb', + useSSL: true, + ); + expect(conn.id, 42); + expect(conn.name, 'My PostgreSQL'); + expect(conn.host, 'pg.example.com'); + expect(conn.port, 5433); + expect(conn.username, 'admin'); + expect(conn.password, 's3cret'); + expect(conn.database, 'appdb'); + expect(conn.useSSL, true); + }); + + test('username, password, database default to null', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + expect(conn.username, isNull); + expect(conn.password, isNull); + expect(conn.database, isNull); + }); + + test('connectionString is stored when provided', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: 'postgresql://user:pass@host/db', + ); + expect(conn.connectionString, 'postgresql://user:pass@host/db'); + }); + + test('connectionString defaults to null', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + expect(conn.connectionString, isNull); + }); + }); + + group('PostgresConnection.disconnect', () { + test('disconnect on a never-connected instance does not throw', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + await conn.disconnect(); + expect(conn.isConnected, false); + }); + + test('double disconnect does not throw', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + await conn.disconnect(); + await conn.disconnect(); + expect(conn.isConnected, false); + }); + }); + + group('PostgresConnection when not connected', () { + late PostgresConnection conn; + + setUp(() { + conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + }); + + test('execute throws StateError', () { + expect( + () => conn.execute('SELECT 1'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listDatabases throws StateError', () { + expect( + () => conn.listDatabases(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listSchemas throws StateError', () { + expect( + () => conn.listSchemas(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listTables throws StateError', () { + expect( + () => conn.listTables(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listViews throws StateError', () { + expect( + () => conn.listViews(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listFunctions throws StateError', () { + expect( + () => conn.listFunctions(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listSequences throws StateError', () { + expect( + () => conn.listSequences(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('serverVersion throws StateError', () { + expect( + () => conn.serverVersion(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('serverStats throws StateError', () { + expect( + () => conn.serverStats(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + }); + + group('PostgresConnection.connectToDatabase', () { + test('returns new PostgresConnection with same params and given database', () async { + final conn = PostgresConnection( + id: 10, + name: 'Server', + host: 'pg.local', + port: 5433, + username: 'u', + password: 'p', + database: 'postgres', + useSSL: true, + ); + final newConn = await conn.connectToDatabase('otherdb'); + + expect(newConn.id, conn.id); + expect(newConn.name, conn.name); + expect(newConn.host, conn.host); + expect(newConn.port, conn.port); + expect(newConn.username, conn.username); + expect(newConn.password, conn.password); + expect(newConn.useSSL, conn.useSSL); + expect(newConn.database, 'otherdb'); + expect(newConn.isConnected, false); + }); + + test('original connection is unchanged', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + database: 'mydb', + ); + await conn.connectToDatabase('other'); + expect(conn.database, 'mydb'); + }); + + test('returned connection has connectionString null', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: 'postgresql://u:p@h/db', + ); + final newConn = await conn.connectToDatabase('targetdb'); + expect(newConn.connectionString, isNull); + expect(newConn.database, 'targetdb'); + }); + }); + + group('PostgresConnectionException', () { + test('stores message and toString returns it', () { + final ex = PostgresConnectionException('connection refused'); + expect(ex.message, 'connection refused'); + expect(ex.toString(), 'connection refused'); + }); + }); +} diff --git a/test/core/database/redis_info_test.dart b/test/core/database/redis_info_test.dart index d7457c08..f559be34 100644 --- a/test/core/database/redis_info_test.dart +++ b/test/core/database/redis_info_test.dart @@ -187,5 +187,30 @@ void main() { final info = parseRedisInfo(_sampleInfo); expect(sectionDouble(info, 'Server', 'nope'), isNull); }); + + test('returns null for missing section', () { + final info = parseRedisInfo(_sampleInfo); + expect(sectionDouble(info, 'NoSuchSection', 'key'), isNull); + }); + }); + + group('parseRedisInfo edge cases', () { + test('handles value containing colon', () { + const raw = '# Server\nredis_version:7.0.0:patch1\n'; + final info = parseRedisInfo(raw); + expect(sectionValue(info, 'Server', 'redis_version'), '7.0.0:patch1'); + }); + + test('handles multiple sections with same-name keys', () { + const raw = ''' +# A +x:1 +# B +x:2 +'''; + final info = parseRedisInfo(raw); + expect(sectionValue(info, 'A', 'x'), '1'); + expect(sectionValue(info, 'B', 'x'), '2'); + }); }); } diff --git a/test/core/drivers/driver_urls_test.dart b/test/core/drivers/driver_urls_test.dart new file mode 100644 index 00000000..80d801d9 --- /dev/null +++ b/test/core/drivers/driver_urls_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/drivers/driver_urls.dart'; +import 'package:querya_desktop/core/drivers/driver_storage.dart'; + +void main() { + group('driver_urls constants', () { + test('postgresqlDriverUrl is valid HTTPS URL', () { + expect(postgresqlDriverUrl, startsWith('https://')); + expect(Uri.tryParse(postgresqlDriverUrl), isNotNull); + }); + + test('postgresqlJarFileName ends with .jar', () { + expect(postgresqlJarFileName, endsWith('.jar')); + expect(postgresqlJarFileName, contains('postgresql')); + }); + }); + + group('DownloadableDriver extension', () { + test('postgresql url matches constant', () { + expect(DownloadableDriver.postgresql.url, postgresqlDriverUrl); + }); + + test('postgresql jarFileName matches constant', () { + expect(DownloadableDriver.postgresql.jarFileName, postgresqlJarFileName); + }); + + test('all enum values have url and jarFileName', () { + for (final d in DownloadableDriver.values) { + expect(d.url, isNotEmpty); + expect(d.jarFileName, isNotEmpty); + expect(d.jarFileName, endsWith('.jar')); + } + }); + }); + + group('DriverDownloadException', () { + test('stores message and toString returns it', () { + final ex = DriverDownloadException('HTTP 404'); + expect(ex.message, 'HTTP 404'); + expect(ex.toString(), 'HTTP 404'); + }); + }); +} diff --git a/test/core/storage/connection_row_test.dart b/test/core/storage/connection_row_test.dart index afe40230..200aa166 100644 --- a/test/core/storage/connection_row_test.dart +++ b/test/core/storage/connection_row_test.dart @@ -169,5 +169,33 @@ void main() { final map = row.toMap(); expect(map.containsKey('id'), false); }); + + test('fromMap restores postgresql connection with databaseName', () { + final map = { + 'id': 1, + 'type': 'postgresql', + 'name': 'PG', + 'host': 'localhost', + 'port': 5432, + 'database_name': 'mydb', + 'use_ssl': 0, + 'sort_order': 0, + 'created_at': '2026-01-01T00:00:00Z', + }; + final row = ConnectionRow.fromMap(map); + expect(row.type, 'postgresql'); + expect(row.databaseName, 'mydb'); + }); + + test('toMap includes database_name for postgresql', () { + const row = ConnectionRow( + type: 'postgresql', + name: 'PG', + host: 'localhost', + databaseName: 'appdb', + createdAt: '2026-01-01T00:00:00Z', + ); + expect(row.toMap()['database_name'], 'appdb'); + }); }); } diff --git a/test/features/postgresql/postgres_table_utils_test.dart b/test/features/postgresql/postgres_table_utils_test.dart new file mode 100644 index 00000000..372b26b4 --- /dev/null +++ b/test/features/postgresql/postgres_table_utils_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; + +void main() { + group('quotePostgresIdentifier', () { + test('quotes simple identifier', () { + expect(quotePostgresIdentifier('users'), '"users"'); + expect(quotePostgresIdentifier('public'), '"public"'); + }); + + test('doubles internal double-quotes', () { + expect(quotePostgresIdentifier('say "hello"'), '"say ""hello"""'); + expect(quotePostgresIdentifier('a""b'), '"a""""b"'); + }); + + test('handles empty string', () { + expect(quotePostgresIdentifier(''), '""'); + }); + + test('handles mixed case and special chars', () { + expect(quotePostgresIdentifier('MyTable'), '"MyTable"'); + expect(quotePostgresIdentifier('schema.name'), '"schema.name"'); + }); + }); + + group('convertResultRowsToStrings', () { + test('converts null to "NULL"', () { + final result = convertResultRowsToStrings([ + [null, 1], + [2, null], + ]); + expect(result, [ + ['NULL', '1'], + ['2', 'NULL'], + ]); + }); + + test('converts int and string', () { + final result = convertResultRowsToStrings([ + [42, 'hello'], + [0, ''], + ]); + expect(result, [ + ['42', 'hello'], + ['0', ''], + ]); + }); + + test('converts DateTime to ISO8601', () { + final dt = DateTime.utc(2026, 3, 15, 12, 30, 45); + final result = convertResultRowsToStrings([ + [dt], + ]); + expect(result, [ + [dt.toIso8601String()], + ]); + }); + + test('converts double and bool', () { + final result = convertResultRowsToStrings([ + [3.14, true], + [0.0, false], + ]); + expect(result, [ + ['3.14', 'true'], + ['0.0', 'false'], + ]); + }); + + test('empty list returns empty list', () { + expect(convertResultRowsToStrings([]), []); + }); + + test('empty row returns list of empty strings for nulls', () { + final result = convertResultRowsToStrings([ + [null, null], + ]); + expect(result, [ + ['NULL', 'NULL'], + ]); + }); + + test('single row with mixed types', () { + final result = convertResultRowsToStrings([ + [1, null, 'x', 2.5, true, DateTime.utc(2026, 1, 1)], + ]); + expect(result.length, 1); + expect(result.first, ['1', 'NULL', 'x', '2.5', 'true', '2026-01-01T00:00:00.000Z']); + }); + }); +} diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart new file mode 100644 index 00000000..2c8819ae --- /dev/null +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('showPostgresConnectionForm', () { + testWidgets('dialog shows PostgreSQL Connection title and main actions', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showPostgresConnectionForm(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('PostgreSQL Connection'), findsOneWidget); + expect(find.text('Connection Name'), findsOneWidget); + expect(find.text('Host'), findsOneWidget); + expect(find.text('Port'), findsOneWidget); + expect(find.text('Database'), findsOneWidget); + expect(find.text('Username'), findsOneWidget); + expect(find.text('Password'), findsAtLeast(1)); + expect(find.text('Test Connection'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('dialog shows Use SSL/TLS option', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showPostgresConnectionForm(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Use SSL/TLS'), findsOneWidget); + }); + + testWidgets('Cancel closes dialog and returns null', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + dynamic result; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showPostgresConnectionForm(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isNull); + }); + }); +}