diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index bd7ce795..981538be 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -193,7 +193,8 @@ class LocalDb { } if (oldVersion < 7) { await db.execute('ALTER TABLE connections ADD COLUMN extension_id TEXT'); - await db.execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); + await db + .execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); } if (oldVersion < 8) { await db.execute('DROP INDEX IF EXISTS idx_sql_query_history_lookup'); @@ -441,7 +442,8 @@ class LocalDb { /// restored (best effort) and the error is rethrown. Future updateConnection(ConnectionRow row) async { if (row.id == null) { - throw ArgumentError('ConnectionRow.id cannot be null when calling updateConnection'); + throw ArgumentError( + 'ConnectionRow.id cannot be null when calling updateConnection'); } final db = await _open(); final previousMaps = await db.query( @@ -640,4 +642,46 @@ class ConnectionRow { sortOrder: _sqliteInt(m['sort_order']) ?? 0, createdAt: m['created_at'] as String, ); + + ConnectionRow copyWith({ + int? id, + String? type, + String? name, + String? host, + int? port, + String? username, + String? password, + String? databaseName, + String? authSource, + bool? useSSL, + String? connectionString, + String? extensionId, + String? driverOptions, + int? folderId, + int? sortOrder, + String? createdAt, + bool clearPassword = false, + bool clearConnectionString = false, + }) { + return ConnectionRow( + id: id ?? this.id, + type: type ?? this.type, + name: name ?? this.name, + host: host ?? this.host, + port: port ?? this.port, + username: username ?? this.username, + password: clearPassword ? null : (password ?? this.password), + databaseName: databaseName ?? this.databaseName, + authSource: authSource ?? this.authSource, + useSSL: useSSL ?? this.useSSL, + connectionString: clearConnectionString + ? null + : (connectionString ?? this.connectionString), + extensionId: extensionId ?? this.extensionId, + driverOptions: driverOptions ?? this.driverOptions, + folderId: folderId ?? this.folderId, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + ); + } } diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index 31e15cab..a2d74794 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -1,5 +1,8 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; - +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/extension_connection_form.dart'; @@ -61,3 +64,128 @@ Future promptCreateConnection( : null, }; } + +/// Opens the matching form prefilled for [existing] (type/driver fixed). +Future promptEditConnection( + material.BuildContext context, + ConnectionRow existing, +) async { + final dialogContext = _dialogAnchorContext(context); + if (!dialogContext.mounted) return null; + + if (ExtensionDriverCatalog.isExtensionDriverConnection(existing)) { + final manifest = ExtensionDriverCatalog.manifestForConnection(existing); + if (manifest == null) return null; + final driver = _driverForConnection(existing, manifest.contributedDrivers); + if (driver == null) return null; + return showExtensionConnectionForm( + dialogContext, + manifest: manifest, + driver: driver, + folderId: existing.folderId, + initial: existing, + ); + } + + return switch (existing.type) { + 'postgresql' => showPostgresConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'mysql' => showMysqlConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'mongodb' => showMongoConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'redis' => showRedisConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'sqlite' => showSqliteConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + _ => null, + }; +} + +DriverContribution? _driverForConnection( + ConnectionRow row, + Iterable drivers, +) { + final type = row.type.trim().toLowerCase(); + DriverContribution? first; + for (final driver in drivers) { + first ??= driver; + if (driver.driverId.trim().toLowerCase() == type) return driver; + } + return first; +} + +/// Keeps previous secure-store secrets when edit form fields are left blank. +/// +/// [ConnectionSecretsStore.writeForConnection] deletes empty values — callers +/// must merge before [LocalDb.updateConnection]. +Future mergeSecretsForConnectionUpdate( + ConnectionRow edited, +) async { + final id = edited.id; + if (id == null) { + throw ArgumentError('edited.id is required for secret merge'); + } + final prev = await ConnectionSecretsStore.readForConnection(id); + + final passwordEmpty = + edited.password == null || edited.password!.trim().isEmpty; + final password = passwordEmpty ? prev.password : edited.password; + + var connectionString = edited.connectionString; + if (connectionString == null || connectionString.trim().isEmpty) { + // Host-mode edit: do not resurrect a previous URI. + connectionString = null; + } else { + connectionString = injectUriPasswordIfMissing(connectionString, password); + } + + return edited.copyWith( + password: password, + connectionString: connectionString, + clearPassword: password == null, + clearConnectionString: connectionString == null, + ); +} + +/// Strips userinfo password so edit forms never show stored secrets. +String? redactUriPassword(String? uri) { + if (uri == null || uri.trim().isEmpty) return uri; + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return uri; + final info = parsed.userInfo; + if (info.isEmpty || !info.contains(':')) return uri; + final user = info.split(':').first; + return parsed.replace(userInfo: user).toString(); +} + +/// Puts [password] into URI userinfo when the URI has a user but no password. +@visibleForTesting +String injectUriPasswordIfMissing(String uri, String? password) { + if (password == null || password.isEmpty) return uri; + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return uri; + final info = parsed.userInfo; + if (info.isEmpty) return uri; + final parts = info.split(':'); + if (parts.length >= 2 && parts.sublist(1).join(':').isNotEmpty) { + return uri; + } + final user = parts.first; + return parsed.replace(userInfo: '$user:$password').toString(); +} diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart index 82ca3723..6ad7ed61 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -64,16 +64,15 @@ const _validPostgresSslModes = { if (!_validPostgresSslModes.contains(sslMode)) { return ( useSSL: null, - error: - 'Unsupported sslmode "$sslMode" for PostgreSQL. ' + error: 'Unsupported sslmode "$sslMode" for PostgreSQL. ' 'Supported: disable, require, verify-ca, verify-full.', ); } useSSL = sslMode != 'disable'; } } else if (type != 'sqlite') { - final sslQuery = uri.queryParameters['sslmode'] ?? - uri.queryParameters['ssl']; + final sslQuery = + uri.queryParameters['sslmode'] ?? uri.queryParameters['ssl']; if (sslQuery != null) { final lowerSsl = sslQuery.toLowerCase(); if (lowerSsl == 'true' || lowerSsl == 'require') { @@ -163,8 +162,8 @@ ConnectionRow? _buildConnectionRow( databaseName = null; } - authSource = uri.queryParameters['authSource'] ?? - uri.queryParameters['authsource']; + authSource = + uri.queryParameters['authSource'] ?? uri.queryParameters['authsource']; if (type == 'postgresql' || type == 'mysql' || type == 'mongodb') { connectionString = url; @@ -196,7 +195,9 @@ String _connectionName( int? defaultPort, ) { if (type == 'sqlite') { - return host == ':memory:' ? 'SQLite (Memory)' : 'SQLite (${host!.split('/').last})'; + return host == ':memory:' + ? 'SQLite (Memory)' + : 'SQLite (${host!.split('/').last})'; } final cleanHost = host ?? 'localhost'; diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 1730bc14..e801fefb 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -320,6 +320,31 @@ class ConnectionsPanelState extends State { } } + Future _editConnection(ConnectionRow conn) async { + final edited = await promptEditConnection(context, conn); + if (edited == null || !mounted) return; + final toSave = await mergeSecretsForConnectionUpdate(edited); + await LocalDb.instance.updateConnection(toSave); + await _loadData(); + if (!mounted) return; + ConnectionRow? updated; + for (final c in _connections) { + if (c.id == conn.id) { + updated = c; + break; + } + } + if (updated == null) return; + final shouldReconnect = _expandedConnections.contains(conn.id) || + widget.selectedConnectionId == conn.id; + if (shouldReconnect) { + await reconnect(updated); + } + if (widget.selectedConnectionId == conn.id) { + widget.onConnectionSelected?.call(updated); + } + } + Future _removeConnection(int id) async { await MongoService.instance.disconnectByConnectionId(id); await ExtensionDriverSession.instance.disconnect(id); @@ -426,6 +451,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -439,6 +465,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onMysqlObjectSelected: widget.onMysqlObjectSelected, onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, @@ -452,6 +479,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), isExpanded: isExpanded, @@ -464,6 +492,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), isExpanded: isExpanded, @@ -476,6 +505,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onSqliteObjectSelected: widget.onSqliteObjectSelected, onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, @@ -489,6 +519,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onObjectSelected: widget.onExtensionObjectSelected, isExpanded: isExpanded, @@ -501,6 +532,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), ); } diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 036a9dc0..e93bfa3f 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -8,6 +8,7 @@ class _ExtensionConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onObjectSelected, this.isExpanded = false, @@ -19,6 +20,7 @@ class _ExtensionConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; /// Fires when a table/view node is clicked in the schema tree. @@ -164,168 +166,184 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { ); } - return 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.Semantics( - button: true, - expanded: widget.isExpanded, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: widget.isExpanded ? 0.25 : 0, - duration: - context.motionDuration(QueryaMotion.treeExpand), - curve: - context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), + 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.Semantics( + button: true, + expanded: widget.isExpanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: widget.isExpanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), ), ), - ), - material.Expanded( - child: _sidebarConnectionShell( - context: context, - isSelected: widget.isSelected, - onTap: widget.onTap, - 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, - fontWeight: widget.isSelected - ? material.FontWeight.w600 - : material.FontWeight.w500, - color: theme.colorScheme.foreground, - ), - ), - if (widget.connection.host != null) + material.Expanded( + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + 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.host}:${widget.connection.port ?? ''}', + widget.connection.name, overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, + fontSize: 13, + fontWeight: widget.isSelected + ? material.FontWeight.w600 + : material.FontWeight.w500, + 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, + ), + ), + ], + ), ), - ), - material.Tooltip( - message: 'Remove', - child: material.InkWell( - onTap: widget.onRemove, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon( - material.Icons.close_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, + material.Tooltip( + message: 'Remove', + child: material.InkWell( + onTap: widget.onRemove, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + material.Icons.close_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), ), ), ), - ), - ], + ], + ), ), ), ), - ), - ], - ), - QueryaAnimatedExpand( - expanded: widget.isExpanded, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - 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, + ], + ), + QueryaAnimatedExpand( + expanded: widget.isExpanded, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + 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(), - ], - ), - ) - else if (_error != null) - TreeLoadError( - title: 'Could not load extension tree', - message: _error!, - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 8, + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ) + else if (_error != null) + TreeLoadError( + title: 'Could not load extension tree', + message: _error!, + padding: const material.EdgeInsets.only( + left: 28, + top: 4, + bottom: 8, + ), + onRetry: _loadTree, + ) + else if (_schema != null) + material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: _schema!.roots.isEmpty + ? material.Padding( + padding: const material.EdgeInsets.fromLTRB( + 0, 8, 8, 8), + child: const Text( + 'No databases found on this server.', + ).muted().small(), + ) + : SduiTreeBuilder( + schema: _schema!, + fetchChildren: _fetchChildren, + onNodeSelected: _onNodeSelected, + maxHeight: kConnectionTreeMaxVisibleRows * + kConnectionTreeRowExtent, + ), ), - onRetry: _loadTree, - ) - else if (_schema != null) - material.Padding( - padding: const material.EdgeInsets.only(left: 20), - child: _schema!.roots.isEmpty - ? material.Padding( - padding: - const material.EdgeInsets.fromLTRB(0, 8, 8, 8), - child: const Text( - 'No databases found on this server.', - ).muted().small(), - ) - : SduiTreeBuilder( - schema: _schema!, - fetchChildren: _fetchChildren, - onNodeSelected: _onNodeSelected, - maxHeight: kConnectionTreeMaxVisibleRows * - kConnectionTreeRowExtent, - ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index d745ccf8..2ee9b6a0 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -9,6 +9,7 @@ class _MongoConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onDatabaseTap, this.isExpanded = false, @@ -20,6 +21,7 @@ class _MongoConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function(String database)? onDatabaseTap; final bool isExpanded; @@ -172,6 +174,12 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { }, child: const Text('Refresh databases'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index a6f08dc1..abe2b92d 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -9,6 +9,7 @@ class _MysqlConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onMysqlObjectSelected, this.onMysqlOpenSqlWorkspace, @@ -21,6 +22,7 @@ class _MysqlConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -139,6 +141,12 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { widget.onMysqlOpenSqlWorkspace!(widget.connection), child: const Text('Open in SQL'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index c6ecc336..5a50066f 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -9,6 +9,7 @@ class _PostgresConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onPostgresObjectSelected, this.onPostgresOpenSqlWorkspace, @@ -21,6 +22,7 @@ class _PostgresConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -134,6 +136,12 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { }, child: const Text('Refresh databases'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index fb0f650b..6e6f509f 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -9,6 +9,7 @@ class _RedisConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onDatabaseTap, this.isExpanded = false, @@ -20,6 +21,7 @@ class _RedisConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function(int database)? onDatabaseTap; final bool isExpanded; @@ -149,6 +151,12 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { }, child: const Text('Refresh databases'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 8d981b0b..4540fe66 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -75,6 +75,7 @@ class _ConnectionTile extends StatelessWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, }); @@ -83,6 +84,7 @@ class _ConnectionTile extends StatelessWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; @override @@ -107,6 +109,12 @@ class _ConnectionTile extends StatelessWidget { ); return ContextMenu( items: [ + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), @@ -311,6 +319,7 @@ class _FolderTileState extends State<_FolderTile> { conn.type, ), onRemove: () => widget.onRemoveConnection(conn.id!), + onEdit: () {}, onTap: () => widget.onConnectionTap?.call(conn), ); }, diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 1b5dcb01..6be778e4 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -12,6 +12,7 @@ class _SqliteConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onSqliteObjectSelected, this.onSqliteOpenSqlWorkspace, @@ -24,6 +25,7 @@ class _SqliteConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -146,6 +148,12 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { widget.onSqliteOpenSqlWorkspace!(widget.connection), child: const Text('Open in SQL'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index 3ed40d4a..08ee32c1 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -18,6 +18,7 @@ Future showExtensionConnectionForm( required ExtensionManifest manifest, required DriverContribution driver, int? folderId, + ConnectionRow? initial, }) { return showAppDialog( context: context, @@ -28,6 +29,7 @@ Future showExtensionConnectionForm( manifest: manifest, driver: driver, folderId: folderId, + initial: initial, ), ), ); @@ -38,11 +40,13 @@ class _ExtensionConnectionFormContent extends material.StatefulWidget { required this.manifest, required this.driver, this.folderId, + this.initial, }); final ExtensionManifest manifest; final DriverContribution driver; final int? folderId; + final ConnectionRow? initial; @override material.State<_ExtensionConnectionFormContent> createState() => @@ -59,11 +63,21 @@ class _ExtensionConnectionFormContentState var _testing = false; String? _testMessage; bool _testSucceeded = false; + late final Map _initialValues; + + bool get _isEditing => widget.initial != null; @override void initState() { super.initState(); - _nameController.text = widget.driver.displayName; + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _initialValues = _sduiInitialValuesFromConnection(initial); + } else { + _nameController.text = widget.driver.displayName; + _initialValues = const {}; + } _loadSchema(); } @@ -111,6 +125,7 @@ class _ExtensionConnectionFormContentState name: name, values: values, folderId: widget.folderId, + initial: widget.initial, ); material.Navigator.of(context).pop(row); } @@ -144,6 +159,7 @@ class _ExtensionConnectionFormContentState driver: widget.driver, name: 'connection-test', values: values, + initial: widget.initial, ); final version = await ExtensionDriverSession.instance.testConnection( manifest: widget.manifest, @@ -171,6 +187,9 @@ class _ExtensionConnectionFormContentState material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; + final title = _isEditing + ? 'Edit ${widget.driver.displayName}' + : widget.driver.displayName; return material.Container( constraints: WindowLayout.dialogConstraints( context, @@ -193,7 +212,7 @@ class _ExtensionConnectionFormContentState child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - Text(widget.driver.displayName).large().semiBold(), + Text(title).large().semiBold(), const material.SizedBox(height: 6), Text( 'Extension driver · ${widget.manifest.id}', @@ -224,7 +243,11 @@ class _ExtensionConnectionFormContentState else if (_loadError != null) Text(_loadError!).muted().small() else if (_schema != null) - SduiFormBuilder(key: _formKey, schema: _schema!), + SduiFormBuilder( + key: _formKey, + schema: _schema!, + initialValues: _initialValues, + ), if (_testMessage != null) ...[ const material.SizedBox(height: 12), material.SelectableText( @@ -292,6 +315,54 @@ class _ExtensionConnectionFormContentState } } +/// Non-secret SDUI seed values from an existing [ConnectionRow] (no passwords). +Map _sduiInitialValuesFromConnection(ConnectionRow row) { + final values = {}; + + final optionsRaw = row.driverOptions; + if (optionsRaw != null && optionsRaw.trim().isNotEmpty) { + try { + final decoded = jsonDecode(optionsRaw); + if (decoded is Map) { + for (final entry in decoded.entries) { + final key = entry.key.toString(); + if (_isPasswordKey(key)) continue; + values[key] = entry.value; + } + } + } catch (_) { + // Ignore malformed driverOptions; host fields still apply. + } + } + + final host = row.host; + if (host != null && host.isNotEmpty) values['host'] = host; + if (row.port != null) values['port'] = row.port; + final username = row.username; + if (username != null && username.isNotEmpty) values['username'] = username; + final database = row.databaseName; + if (database != null && database.isNotEmpty) { + values['database'] = database; + values['databaseName'] = database; + } + values['useSSL'] = row.useSSL; + values['ssl'] = row.useSSL; + if (row.useSSL) { + values.putIfAbsent('sslMode', () => 'require'); + } + + values.removeWhere((key, _) => _isPasswordKey(key)); + return values; +} + +bool _isPasswordKey(String key) { + final lower = key.toLowerCase(); + return lower == 'password' || + lower.endsWith('password') || + lower.contains('secret') || + lower.contains('passwd'); +} + /// Loads SDUI form schema from the extension package (file path preferred). Future loadDriverConnectionFormSchema({ required ExtensionManifest manifest, @@ -321,6 +392,7 @@ ConnectionRow connectionRowFromExtensionForm({ required String name, required Map values, int? folderId, + ConnectionRow? initial, }) { final known = { 'host', @@ -358,7 +430,8 @@ ConnectionRow connectionRowFromExtensionForm({ } return ConnectionRow( - type: driver.driverId, + id: initial?.id, + type: initial?.type ?? driver.driverId, name: name, host: (host == null || host.isEmpty) ? null : host, port: port, @@ -366,9 +439,10 @@ ConnectionRow connectionRowFromExtensionForm({ password: (password == null || password.isEmpty) ? null : password, databaseName: database, useSSL: useSsl, - extensionId: manifest.id, + extensionId: initial?.extensionId ?? manifest.id, driverOptions: options.isEmpty ? null : jsonEncode(options), - folderId: folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + folderId: initial?.folderId ?? folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); } diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index 496dbc01..1ca2f717 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -13,21 +13,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showSqliteConnectionForm( material.BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _SqliteConnectionFormContent(folderId: folderId), + child: _SqliteConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _SqliteConnectionFormContent extends material.StatefulWidget { - const _SqliteConnectionFormContent({this.folderId}); + const _SqliteConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_SqliteConnectionFormContent> createState() => @@ -45,12 +50,22 @@ class _SqliteConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); _formValidNotifier = FormValidityNotifier(_computeFormValid); _formValidNotifier.listenTo(_nameController); _formValidNotifier.listenTo(_pathController); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _pathController.text = initial.host ?? ''; + _readOnly = initial.useSSL; + } + _formValidNotifier.seed(); } @@ -136,14 +151,18 @@ class _SqliteConnectionFormContentState void _save() { if (!_formValidNotifier.value) return; + final initial = widget.initial; final row = ConnectionRow( - id: null, - type: 'sqlite', + id: initial?.id, + type: initial?.type ?? 'sqlite', name: _nameController.text.trim(), host: _pathController.text.trim(), useSSL: _readOnly, // Store read-only toggle in useSSL field - createdAt: DateTime.now().toIso8601String(), - folderId: widget.folderId, + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, ); material.Navigator.of(context).pop(row); } @@ -163,11 +182,13 @@ class _SqliteConnectionFormContentState ), decoration: material.BoxDecoration( color: theme.popover, - borderRadius: material.BorderRadius.circular(Theme.of(context).radiusXxl), + borderRadius: + material.BorderRadius.circular(Theme.of(context).radiusXxl), border: material.Border.all(color: theme.muted), ), child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(Theme.of(context).radiusXxl), + borderRadius: + material.BorderRadius.circular(Theme.of(context).radiusXxl), child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, @@ -178,7 +199,11 @@ class _SqliteConnectionFormContentState child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - const Text('New SQLite Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit SQLite Connection' + : 'New SQLite Connection', + ).large().semiBold(), const Gap(6), const Text('Connect to a local SQLite database file.') .muted() @@ -193,7 +218,8 @@ class _SqliteConnectionFormContentState ), child: material.SingleChildScrollView( physics: const material.ClampingScrollPhysics(), - padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 12), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ @@ -229,7 +255,8 @@ class _SqliteConnectionFormContentState children: [ material.Checkbox( value: _readOnly, - onChanged: (v) => setState(() => _readOnly = v ?? false), + onChanged: (v) => + setState(() => _readOnly = v ?? false), ), const Gap(8), const Text('Read-only mode').small(), @@ -250,7 +277,8 @@ class _SqliteConnectionFormContentState onTap: _dismissResult, borderRadius: material.BorderRadius.circular(8), child: material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 10), decoration: material.BoxDecoration( color: _testResult == 'success' ? theme.primary.withValues(alpha: 0.12) @@ -270,7 +298,9 @@ class _SqliteConnectionFormContentState ? material.Icons.check_circle_outline : material.Icons.info_outline_rounded, size: 18, - color: _testResult == 'success' ? theme.primary : theme.destructive, + color: _testResult == 'success' + ? theme.primary + : theme.destructive, ), const Gap(10), material.Expanded( @@ -306,7 +336,8 @@ class _SqliteConnectionFormContentState ), // Footer material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), child: ValueListenableBuilder( valueListenable: _formValidNotifier.listenable, builder: (context, formValid, _) { @@ -316,7 +347,8 @@ class _SqliteConnectionFormContentState alignment: material.WrapAlignment.spaceBetween, children: [ OutlineButton( - onPressed: formValid && !_isTesting ? _testConnection : null, + onPressed: + formValid && !_isTesting ? _testConnection : null, leading: _isTesting ? material.SizedBox( width: 18, @@ -329,13 +361,17 @@ class _SqliteConnectionFormContentState : material.Icon( material.Icons.link_rounded, size: 18, - color: formValid ? theme.primary : theme.mutedForeground, + color: formValid + ? theme.primary + : theme.mutedForeground, ), child: Text( 'Test Connection', style: material.TextStyle( fontWeight: material.FontWeight.w500, - color: formValid ? theme.primary : theme.mutedForeground, + color: formValid + ? theme.primary + : theme.mutedForeground, ), ), ), @@ -343,7 +379,8 @@ class _SqliteConnectionFormContentState mainAxisSize: material.MainAxisSize.min, children: [ GhostButton( - onPressed: () => material.Navigator.of(context).pop(), + onPressed: () => + material.Navigator.of(context).pop(), child: const Text('Cancel'), ), const Gap(12), diff --git a/lib/features/connections/ssl_certificate_support.dart b/lib/features/connections/ssl_certificate_support.dart index 3d304331..bcb98239 100644 --- a/lib/features/connections/ssl_certificate_support.dart +++ b/lib/features/connections/ssl_certificate_support.dart @@ -25,7 +25,8 @@ class SslCertificatePaths { bool get hasAny => _nonEmpty(rootCert) || _nonEmpty(clientCert) || _nonEmpty(clientKey); - static bool _nonEmpty(String? value) => value != null && value.trim().isNotEmpty; + static bool _nonEmpty(String? value) => + value != null && value.trim().isNotEmpty; } SslCertificatePaths extractSslCertificatePaths(Uri uri) { diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index b1586c18..8ca301f4 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -46,21 +47,26 @@ class MongoConnectionData { Future showMongoConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _MongoConnectionFormContent(folderId: folderId), + child: _MongoConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _MongoConnectionFormContent extends material.StatefulWidget { - const _MongoConnectionFormContent({this.folderId}); + const _MongoConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_MongoConnectionFormContent> createState() => @@ -89,6 +95,8 @@ class _MongoConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -105,6 +113,23 @@ class _MongoConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? 'localhost'; + _portController.text = (initial.port ?? 27017).toString(); + _usernameController.text = initial.username ?? ''; + _databaseController.text = initial.databaseName ?? ''; + _authSourceController.text = initial.authSource ?? ''; + _useSSL = initial.useSSL; + final redacted = redactUriPassword(initial.connectionString) ?? ''; + _connectionStringController.text = redacted; + if (redacted.isNotEmpty) { + _useConnectionString = true; + } + } + _formValidNotifier.seed(); } @@ -279,8 +304,10 @@ class _MongoConnectionFormContentState final displayName = data.name.isNotEmpty ? data.name : 'MongoDB ${data.host}:${data.port}'; + final initial = widget.initial; final row = ConnectionRow( - type: 'mongodb', + id: initial?.id, + type: initial?.type ?? 'mongodb', name: displayName, host: data.host, port: data.port, @@ -290,8 +317,11 @@ class _MongoConnectionFormContentState authSource: data.authSource, useSSL: data.useSSL, connectionString: data.connectionString, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); @@ -331,7 +361,11 @@ class _MongoConnectionFormContentState color: theme.primary, ), const Gap(12), - const Text('MongoDB Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit MongoDB Connection' + : 'MongoDB Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -455,7 +489,11 @@ class _MongoConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 2bd146c1..7c1d6af1 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -14,21 +15,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showMysqlConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _MysqlConnectionFormContent(folderId: folderId), + child: _MysqlConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _MysqlConnectionFormContent extends material.StatefulWidget { - const _MysqlConnectionFormContent({this.folderId}); + const _MysqlConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_MysqlConnectionFormContent> createState() => @@ -55,6 +61,8 @@ class _MysqlConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -73,6 +81,19 @@ class _MysqlConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? ''; + _portController.text = (initial.port ?? 3306).toString(); + _usernameController.text = initial.username ?? ''; + _databaseController.text = initial.databaseName ?? ''; + _useSSL = initial.useSSL; + _connectionStringController.text = + redactUriPassword(initial.connectionString) ?? ''; + } + _formValidNotifier.seed(); } @@ -215,8 +236,10 @@ class _MysqlConnectionFormContentState : (uri.isNotEmpty ? 'MySQL (URI)' : 'MySQL $host:$port${database.isNotEmpty ? '/$database' : ''}'); + final initial = widget.initial; final row = ConnectionRow( - type: 'mysql', + id: initial?.id, + type: initial?.type ?? 'mysql', name: displayName, host: uri.isNotEmpty ? null : host, port: uri.isNotEmpty ? null : port, @@ -229,8 +252,11 @@ class _MysqlConnectionFormContentState uri.isNotEmpty ? null : (database.isEmpty ? null : database), useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } @@ -308,7 +334,11 @@ class _MysqlConnectionFormContentState ), ), const Gap(12), - const Text('MySQL Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit MySQL Connection' + : 'MySQL Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -408,7 +438,11 @@ class _MysqlConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index a373fb1a..98bec1cf 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -13,21 +14,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showPostgresConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _PostgresConnectionFormContent(folderId: folderId), + child: _PostgresConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _PostgresConnectionFormContent extends material.StatefulWidget { - const _PostgresConnectionFormContent({this.folderId}); + const _PostgresConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_PostgresConnectionFormContent> createState() => @@ -54,6 +60,8 @@ class _PostgresConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -72,6 +80,20 @@ class _PostgresConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? ''; + _portController.text = (initial.port ?? 5432).toString(); + _usernameController.text = initial.username ?? ''; + _databaseController.text = initial.databaseName ?? ''; + _useSSL = initial.useSSL; + _connectionStringController.text = + redactUriPassword(initial.connectionString) ?? ''; + // Password left empty — mergeSecretsForConnectionUpdate keeps existing. + } + _formValidNotifier.seed(); } @@ -159,8 +181,10 @@ class _PostgresConnectionFormContentState String? sslKey, }) { final userInfoParts = [ - if (username != null && username.isNotEmpty) Uri.encodeComponent(username), - if (password != null && password.isNotEmpty) Uri.encodeComponent(password), + if (username != null && username.isNotEmpty) + Uri.encodeComponent(username), + if (password != null && password.isNotEmpty) + Uri.encodeComponent(password), ]; final queryParams = { if (sslRootCert != null && sslRootCert.isNotEmpty) @@ -298,8 +322,10 @@ class _PostgresConnectionFormContentState : (effectiveUri.isNotEmpty ? 'PostgreSQL: $effectiveHost:$effectivePort' : 'PostgreSQL $host:$port/$database'); + final initial = widget.initial; final row = ConnectionRow( - type: 'postgresql', + id: initial?.id, + type: initial?.type ?? 'postgresql', name: displayName, host: uriHost ?? (effectiveUri.isEmpty ? host : null), port: uriPort ?? (effectiveUri.isEmpty ? port : null), @@ -312,8 +338,11 @@ class _PostgresConnectionFormContentState effectiveUri.isNotEmpty ? null : (database.isEmpty ? null : database), useSSL: effectiveUseSSL, connectionString: effectiveUri.isEmpty ? null : effectiveUri, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } @@ -329,15 +358,15 @@ class _PostgresConnectionFormContentState Text(label).xSmall().muted(), const Gap(4), material.Row( - children: [ - material.Expanded( - child: TextField( - key: Key(label), - controller: controller, - placeholder: const Text('/path/to/file.pem'), - onChanged: (_) => _syncUriSslParams(), - ), - ), + children: [ + material.Expanded( + child: TextField( + key: Key(label), + controller: controller, + placeholder: const Text('/path/to/file.pem'), + onChanged: (_) => _syncUriSslParams(), + ), + ), const Gap(8), GhostButton( onPressed: () => _pickCertificateFile(controller), @@ -423,7 +452,11 @@ class _PostgresConnectionFormContentState ), ), const Gap(12), - const Text('PostgreSQL Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit PostgreSQL Connection' + : 'PostgreSQL Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -530,7 +563,11 @@ class _PostgresConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index d864cd8a..8c2fbe1a 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -13,21 +14,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showRedisConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _RedisConnectionFormContent(folderId: folderId), + child: _RedisConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _RedisConnectionFormContent extends material.StatefulWidget { - const _RedisConnectionFormContent({this.folderId}); + const _RedisConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_RedisConnectionFormContent> createState() => @@ -53,6 +59,8 @@ class _RedisConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -69,6 +77,18 @@ class _RedisConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? ''; + _portController.text = (initial.port ?? 6379).toString(); + _usernameController.text = initial.username ?? ''; + _useSSL = initial.useSSL; + _connectionStringController.text = + redactUriPassword(initial.connectionString) ?? ''; + } + _formValidNotifier.seed(); } @@ -195,8 +215,10 @@ class _RedisConnectionFormContentState final port = int.tryParse(_portController.text.trim()) ?? 6379; final uri = _effectiveConnectionUri(); final displayName = name.isNotEmpty ? name : 'Redis $host:$port'; + final initial = widget.initial; final row = ConnectionRow( - type: 'redis', + id: initial?.id, + type: initial?.type ?? 'redis', name: displayName, host: uri.isNotEmpty ? null : host, port: uri.isNotEmpty ? null : port, @@ -207,8 +229,11 @@ class _RedisConnectionFormContentState _passwordController.text.isEmpty ? null : _passwordController.text, useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } @@ -272,7 +297,11 @@ class _RedisConnectionFormContentState material.Icon(material.Icons.memory_rounded, size: 24, color: theme.primary), const Gap(12), - const Text('Redis Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit Redis Connection' + : 'Redis Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -360,7 +389,11 @@ class _RedisConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( diff --git a/test/core/storage/local_db_secrets_test.dart b/test/core/storage/local_db_secrets_test.dart index 51032fd4..dacae451 100644 --- a/test/core/storage/local_db_secrets_test.dart +++ b/test/core/storage/local_db_secrets_test.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import '../../memory_secrets_backend.dart'; @@ -117,7 +118,9 @@ void main() { expect(s.connectionString, isNull); }); - test('updateConnection atomically updates SQLite row and secure-store secrets', () async { + test( + 'updateConnection atomically updates SQLite row and secure-store secrets', + () async { const initialRow = ConnectionRow( type: 'postgres', name: 'PG_Init', @@ -137,7 +140,8 @@ void main() { port: 5433, username: 'root', password: 'new-secret-password', - connectionString: 'postgres://root:new-secret-password@db.example.com:5433/mydb', + connectionString: + 'postgres://root:new-secret-password@db.example.com:5433/mydb', createdAt: '2026-01-01T00:00:00Z', ); await LocalDb.instance.updateConnection(updatedRow); @@ -149,14 +153,18 @@ void main() { expect(loaded.port, 5433); expect(loaded.username, 'root'); expect(loaded.password, 'new-secret-password'); - expect(loaded.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + expect(loaded.connectionString, + 'postgres://root:new-secret-password@db.example.com:5433/mydb'); final secrets = await ConnectionSecretsStore.readForConnection(id); expect(secrets.password, 'new-secret-password'); - expect(secrets.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + expect(secrets.connectionString, + 'postgres://root:new-secret-password@db.example.com:5433/mydb'); }); - test('removeConnection still deletes SQLite row when secure-store delete fails', () async { + test( + 'removeConnection still deletes SQLite row when secure-store delete fails', + () async { const row = ConnectionRow( type: 'redis', name: 'R3', @@ -174,7 +182,8 @@ void main() { expect(list.where((c) => c.id == id), isEmpty); }); - test('addConnection rolls back SQLite row when secure-store write fails', () async { + test('addConnection rolls back SQLite row when secure-store write fails', + () async { testMemorySecrets.failNextWrite = StateError('keychain write failed'); const row = ConnectionRow( type: 'redis', @@ -194,7 +203,9 @@ void main() { expect(list.where((c) => c.name == 'R4'), isEmpty); }); - test('updateConnection rolls back SQLite and secrets when secure-store write fails', () async { + test( + 'updateConnection rolls back SQLite and secrets when secure-store write fails', + () async { const initialRow = ConnectionRow( type: 'postgres', name: 'PG_Before', @@ -231,5 +242,41 @@ void main() { expect(loaded.username, 'admin'); expect(loaded.password, 'old-password'); }); + + test( + 'mergeSecretsForConnectionUpdate keeps password when form leaves it blank', + () async { + const initialRow = ConnectionRow( + type: 'postgresql', + name: 'PG', + host: 'localhost', + port: 5432, + username: 'admin', + password: 'keep-me', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(initialRow); + + final edited = ConnectionRow( + id: id, + type: 'postgresql', + name: 'PG Renamed', + host: 'db.example.com', + port: 5432, + username: 'admin', + password: null, + createdAt: '2026-01-01T00:00:00Z', + ); + final merged = await mergeSecretsForConnectionUpdate(edited); + expect(merged.password, 'keep-me'); + expect(merged.name, 'PG Renamed'); + expect(merged.host, 'db.example.com'); + + await LocalDb.instance.updateConnection(merged); + final loaded = (await LocalDb.instance.getConnections()) + .singleWhere((c) => c.id == id); + expect(loaded.password, 'keep-me'); + expect(loaded.name, 'PG Renamed'); + }); }); } diff --git a/test/features/connections/connection_edit_helpers_test.dart b/test/features/connections/connection_edit_helpers_test.dart new file mode 100644 index 00000000..2560c2b6 --- /dev/null +++ b/test/features/connections/connection_edit_helpers_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; + +void main() { + group('redactUriPassword', () { + test('strips password from userinfo', () { + expect( + redactUriPassword('postgresql://alice:s3cret@db.example:5432/app'), + 'postgresql://alice@db.example:5432/app', + ); + }); + + test('leaves uri without password unchanged', () { + const uri = 'postgresql://alice@db.example:5432/app'; + expect(redactUriPassword(uri), uri); + }); + }); + + group('injectUriPasswordIfMissing', () { + test('injects password when user has no password', () { + expect( + injectUriPasswordIfMissing( + 'postgresql://alice@db.example:5432/app', + 's3cret', + ), + 'postgresql://alice:s3cret@db.example:5432/app', + ); + }); + + test('keeps existing password', () { + const uri = 'postgresql://alice:keep@db.example:5432/app'; + expect(injectUriPasswordIfMissing(uri, 'other'), uri); + }); + }); + + group('ConnectionRow.copyWith', () { + test('can clear password with flag', () { + const row = ConnectionRow( + id: 1, + type: 'postgresql', + name: 'n', + password: 'x', + createdAt: 't', + ); + expect(row.copyWith(clearPassword: true).password, isNull); + expect(row.copyWith(password: 'y').password, 'y'); + }); + }); +} diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index adda7db4..a3461d88 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -90,7 +90,8 @@ void main() { expect(result, isNull); }); - testWidgets('Save from URI extracts host and port for display', (tester) async { + testWidgets('Save from URI extracts host and port for display', + (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); ConnectionRow? result; await tester.pumpWidget( @@ -114,7 +115,11 @@ void main() { await tester.enterText( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == + 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), 'postgresql://u:p@remote.example.com:5433/db', ); @@ -125,7 +130,8 @@ void main() { expect(result!.host, 'remote.example.com'); expect(result!.port, 5433); expect(result!.name, 'PostgreSQL: remote.example.com:5433'); - expect(result!.connectionString, 'postgresql://u:p@remote.example.com:5433/db'); + expect(result!.connectionString, + 'postgresql://u:p@remote.example.com:5433/db'); }); testWidgets('SSL certificate path is appended to the URI', (tester) async { @@ -149,7 +155,11 @@ void main() { await tester.enterText( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == + 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), 'postgresql://u:p@remote.example.com:5433/db', ); @@ -176,14 +186,19 @@ void main() { final uriField = tester.widget( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == + 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), ); expect(uriField.controller?.text, contains('sslrootcert')); expect(uriField.controller?.text, contains('root.pem')); }); - testWidgets('Save with SSL certs and no URI builds a connection URI', (tester) async { + testWidgets('Save with SSL certs and no URI builds a connection URI', + (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); ConnectionRow? result; await tester.pumpWidget( @@ -207,31 +222,52 @@ void main() { await tester.enterText( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'My PostgreSQL Server', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'My PostgreSQL Server', ), 'Cert PG', ); await tester.enterText( - find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'localhost', - ).first, + find + .byWidgetPredicate( + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'localhost', + ) + .first, 'pg.example.com', ); await tester.enterText( - find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgres', - ).first, + find + .byWidgetPredicate( + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'postgres', + ) + .first, 'appdb', ); await tester.enterText( - find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgres', - ).last, + find + .byWidgetPredicate( + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'postgres', + ) + .last, 'admin', ); await tester.enterText( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'Password', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'Password', ), 'secret', ); @@ -268,5 +304,63 @@ void main() { expect(result!.connectionString, contains('secret')); expect(result!.useSSL, true); }); + + testWidgets('edit mode prefills fields and keeps password empty', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + const initial = ConnectionRow( + id: 42, + type: 'postgresql', + name: 'Prod', + host: 'db.example.com', + port: 5433, + username: 'app', + password: 'must-not-appear', + databaseName: 'appdb', + createdAt: '2026-01-01T00:00:00Z', + ); + ConnectionRow? 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, + initial: initial, + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Edit PostgreSQL Connection'), findsOneWidget); + expect(find.text('Leave blank to keep existing'), findsOneWidget); + expect(find.text('must-not-appear'), findsNothing); + expect(find.text('Prod'), findsOneWidget); + expect(find.text('db.example.com'), findsOneWidget); + expect(find.text('5433'), findsOneWidget); + expect(find.text('appdb'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.id, 42); + expect(result!.type, 'postgresql'); + expect(result!.name, 'Prod'); + expect(result!.host, 'db.example.com'); + expect(result!.password, isNull); + expect(result!.createdAt, '2026-01-01T00:00:00Z'); + }); }); }