diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index 74e6c78..01455aa 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -465,6 +465,16 @@ class ExtensionDriverSession { } } + /// Restarts the extension driver process for [row] by cleanly disconnecting + /// the active bridge and re-establishing the connection. + Future restart(ConnectionRow row) async { + final id = row.id; + if (id != null) { + await disconnect(id); + } + return ensureConnected(row); + } + Future disconnectAll() async { final ids = _bridges.keys.toList(); for (final id in ids) { diff --git a/lib/features/extensions/extension_driver_recovery_banner.dart b/lib/features/extensions/extension_driver_recovery_banner.dart new file mode 100644 index 0000000..1dd6f15 --- /dev/null +++ b/lib/features/extensions/extension_driver_recovery_banner.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Banner displayed when an extension driver process crashes, deadlocks, +/// or disconnects unexpectedly, allowing the user to restart the driver process +/// with a single click and retry the pending action. +class ExtensionDriverRecoveryBanner extends material.StatelessWidget { + const ExtensionDriverRecoveryBanner({ + super.key, + required this.onRestart, + this.isRestarting = false, + this.customMessage, + }); + + final material.VoidCallback onRestart; + final bool isRestarting; + final String? customMessage; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == material.Brightness.dark; + + final bgColor = isDark + ? theme.colorScheme.destructive.withValues(alpha: 0.12) + : theme.colorScheme.destructive.withValues(alpha: 0.08); + + final borderColor = theme.colorScheme.destructive.withValues(alpha: 0.35); + + return material.Container( + margin: const material.EdgeInsets.fromLTRB(12, 8, 12, 8), + padding: const material.EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: material.BoxDecoration( + color: bgColor, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: borderColor), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.power_off_rounded, + size: 20, + color: theme.colorScheme.destructive, + ), + const Gap(12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text( + 'Driver Process Terminated or Unresponsive', + ).semiBold().small(), + const Gap(2), + Text( + customMessage ?? + 'The background driver process exited or lost communication. ' + 'Restart the driver to restore the connection.', + ).muted().xSmall(), + ], + ), + ), + const Gap(12), + OutlineButton( + size: ButtonSize.small, + onPressed: isRestarting ? null : onRestart, + leading: isRestarting + ? const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const material.Icon( + material.Icons.restart_alt_rounded, + size: 16, + ), + child: Text(isRestarting ? 'Restarting...' : 'Restart Driver'), + ), + ], + ), + ); + } +} diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index c9d91ac..52a2a16 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/extensions/extension_driver_recovery_banner.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -44,6 +45,7 @@ class _ExtensionSqlWorkspaceState final ValueNotifier _topFraction = ValueNotifier(0.6); bool _running = false; + bool _restartingDriver = false; String? _error; List _columns = []; List> _rows = []; @@ -55,6 +57,52 @@ class _ExtensionSqlWorkspaceState static const _previewRowLimit = 200; + bool get _isDriverError { + final err = _error; + if (err == null) return false; + return err.contains('PluginCrashedException') || + err.contains('PluginDeadlockException') || + err.contains('PluginProtocolTimeoutException') || + err.contains('TimeoutException') || + err.contains('SocketException') || + err.contains('Broken pipe') || + err.contains('JsonRpcStdioClient') || + err.contains('Connection') || + err.contains('is not started'); + } + + Future _restartDriver() async { + if (_restartingDriver) return; + setState(() { + _restartingDriver = true; + }); + try { + await ExtensionDriverSession.instance.restart(widget.connectionRow); + if (!mounted) return; + showAppToast( + context: context, + message: 'Driver restarted successfully', + variant: AppToastVariant.success, + ); + setState(() { + _restartingDriver = false; + _error = null; + }); + await _execute(); + } catch (e) { + if (!mounted) return; + setState(() { + _error = 'Driver restart failed: $e'; + _restartingDriver = false; + }); + showAppToast( + context: context, + message: 'Driver restart failed: $e', + variant: AppToastVariant.error, + ); + } + } + @override void initState() { super.initState(); @@ -313,6 +361,8 @@ class _ExtensionSqlWorkspaceState connectionName: widget.connectionRow.name, onExecute: _running ? null : () => unawaited(_execute()), running: _running, + isRestarting: _restartingDriver, + onRestartDriver: _running ? null : () => unawaited(_restartDriver()), onOpenSqlFile: () => unawaited(_openSqlFile()), onSaveSqlFile: () => unawaited(_saveSqlFile()), onOpenHistory: widget.connectionRow.id != null && !_running @@ -355,6 +405,12 @@ class _ExtensionSqlWorkspaceState errorMessage: _error, isLoading: _running, statusLine: _statusLine, + errorAction: _isDriverError + ? ExtensionDriverRecoveryBanner( + onRestart: () => unawaited(_restartDriver()), + isRestarting: _restartingDriver, + ) + : null, ), ), ], @@ -373,6 +429,8 @@ class _ExtensionSqlToolbar extends material.StatelessWidget { required this.onOpenSqlFile, required this.onSaveSqlFile, this.onOpenHistory, + this.onRestartDriver, + this.isRestarting = false, }); final String connectionName; @@ -381,6 +439,8 @@ class _ExtensionSqlToolbar extends material.StatelessWidget { final VoidCallback onOpenSqlFile; final VoidCallback onSaveSqlFile; final VoidCallback? onOpenHistory; + final VoidCallback? onRestartDriver; + final bool isRestarting; @override material.Widget build(material.BuildContext context) { @@ -394,6 +454,26 @@ class _ExtensionSqlToolbar extends material.StatelessWidget { child: Text('Query ยท $connectionName').semiBold().small(), ), const Spacer(), + if (onRestartDriver != null) ...[ + IconButton.ghost( + onPressed: running || isRestarting ? null : onRestartDriver, + icon: isRestarting + ? material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: accent, + ), + ) + : material.Icon( + material.Icons.restart_alt_rounded, + size: 18, + color: accent, + ), + ), + const Gap(4), + ], IconButton.ghost( onPressed: running ? null : onOpenSqlFile, icon: material.Icon( diff --git a/lib/features/extensions/extension_table_toolbar.dart b/lib/features/extensions/extension_table_toolbar.dart index d1e6421..73391ce 100644 --- a/lib/features/extensions/extension_table_toolbar.dart +++ b/lib/features/extensions/extension_table_toolbar.dart @@ -23,6 +23,8 @@ class ExtensionTableToolbar extends material.StatelessWidget { this.onCopyFormat, this.onSaveFormat, this.onNavigateHome, + this.onRestartDriver, + this.isRestarting = false, }); final String title; @@ -42,6 +44,8 @@ class ExtensionTableToolbar extends material.StatelessWidget { final material.ValueChanged? onCopyFormat; final material.ValueChanged? onSaveFormat; final VoidCallback? onNavigateHome; + final VoidCallback? onRestartDriver; + final bool isRestarting; @override material.Widget build(material.BuildContext context) { @@ -193,6 +197,26 @@ class ExtensionTableToolbar extends material.StatelessWidget { child: const Text('Next'), ), const Gap(8), + if (onRestartDriver != null) ...[ + OutlineButton( + size: ButtonSize.small, + onPressed: loading || isRestarting ? null : onRestartDriver, + leading: isRestarting + ? const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const material.Icon( + material.Icons.restart_alt_rounded, + size: 15, + ), + child: Text(isRestarting ? 'Restarting...' : 'Restart Driver'), + ), + const Gap(4), + ], OutlineButton( size: ButtonSize.small, onPressed: loading ? null : onRefresh, diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index fc0aad6..91b459c 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -5,6 +5,7 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/extensions/extension_driver_recovery_banner.dart'; import 'package:querya_desktop/features/extensions/extension_table_toolbar.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; @@ -52,6 +53,8 @@ class _ExtensionTableViewState extends material.State { DataGridStagingBuffer? _stagingBuffer; bool _isSaving = false; + bool _restartingDriver = false; + String get _qualifiedName => '`${widget.database}`.`${widget.tableName}`'; String get _whereClause { @@ -59,6 +62,52 @@ class _ExtensionTableViewState extends material.State { return text.isEmpty ? '' : ' WHERE $text'; } + bool get _isDriverError { + final err = _error; + if (err == null) return false; + return err.contains('PluginCrashedException') || + err.contains('PluginDeadlockException') || + err.contains('PluginProtocolTimeoutException') || + err.contains('TimeoutException') || + err.contains('SocketException') || + err.contains('Broken pipe') || + err.contains('JsonRpcStdioClient') || + err.contains('Connection') || + err.contains('is not started'); + } + + Future _restartDriver() async { + if (_restartingDriver) return; + setState(() { + _restartingDriver = true; + }); + try { + await ExtensionDriverSession.instance.restart(widget.connectionRow); + if (!mounted) return; + showAppToast( + context: context, + message: 'Driver restarted successfully', + variant: AppToastVariant.success, + ); + setState(() { + _restartingDriver = false; + _error = null; + }); + await _loadPage(refreshCount: true); + } catch (e) { + if (!mounted) return; + setState(() { + _error = 'Driver restart failed: $e'; + _restartingDriver = false; + }); + showAppToast( + context: context, + message: 'Driver restart failed: $e', + variant: AppToastVariant.error, + ); + } + } + @override void initState() { super.initState(); @@ -414,6 +463,8 @@ class _ExtensionTableViewState extends material.State { onGoPrevious: _previousPage, onGoNext: _nextPage, onRefresh: () => _loadPage(refreshCount: true), + onRestartDriver: () => unawaited(_restartDriver()), + isRestarting: _restartingDriver, onCopyFormat: (format) { unawaited(() async { await DataExportService.copyToClipboard( @@ -495,6 +546,12 @@ class _ExtensionTableViewState extends material.State { stagingBuffer: _stagingBuffer, onApplyChanges: _stagingBuffer != null ? _onApplyChanges : null, isSaving: _isSaving, + errorAction: _isDriverError + ? ExtensionDriverRecoveryBanner( + onRestart: () => unawaited(_restartDriver()), + isRestarting: _restartingDriver, + ) + : null, ), ), ], diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 6335a3e..20bd286 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -35,6 +35,7 @@ class ResultsTab extends material.StatefulWidget { this.stagingBuffer, this.onApplyChanges, this.isSaving = false, + this.errorAction, }); final List columns; @@ -47,6 +48,7 @@ class ResultsTab extends material.StatefulWidget { final DataGridStagingBuffer? stagingBuffer; final material.VoidCallback? onApplyChanges; final bool isSaving; + final material.Widget? errorAction; @override material.State createState() => _ResultsTabState(); @@ -118,13 +120,21 @@ class _ResultsTabState extends material.State { if (widget.errorMessage != null && widget.errorMessage!.isNotEmpty) { return material.KeyedSubtree( key: const material.ValueKey('results_mode_error'), - child: VirtualSelectableTextView( - text: widget.errorMessage!, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: Theme.of(context).colorScheme.destructive, - ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (widget.errorAction != null) widget.errorAction!, + material.Expanded( + child: VirtualSelectableTextView( + text: widget.errorMessage!, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Theme.of(context).colorScheme.destructive, + ), + ), + ), + ], ), ); } diff --git a/test/core/extensions/extension_driver_session_test.dart b/test/core/extensions/extension_driver_session_test.dart index 0c16ec6..7f7fce0 100644 --- a/test/core/extensions/extension_driver_session_test.dart +++ b/test/core/extensions/extension_driver_session_test.dart @@ -150,5 +150,23 @@ void main() { expect(limits.timeoutSeconds, 600); expect(limits.toJson()['timeout_seconds'], 600); }); + + test('restart disconnects active session and validates extension driver presence', () async { + const row = ConnectionRow( + id: 888, + type: 'postgresql', + name: 'PG', + createdAt: '2026-01-01T00:00:00Z', + ); + + await expectLater( + ExtensionDriverSession.instance.restart(row), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('not backed by an installed extension driver'), + )), + ); + }); }); } diff --git a/test/features/extensions/extension_driver_recovery_banner_test.dart b/test/features/extensions/extension_driver_recovery_banner_test.dart new file mode 100644 index 0000000..ae7d86e --- /dev/null +++ b/test/features/extensions/extension_driver_recovery_banner_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/extensions/extension_driver_recovery_banner.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + testWidgets('ExtensionDriverRecoveryBanner renders and triggers onRestart callback', (tester) async { + var restartClicked = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: ExtensionDriverRecoveryBanner( + onRestart: () { + restartClicked = true; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Driver Process Terminated or Unresponsive'), findsOneWidget); + expect(find.text('Restart Driver'), findsOneWidget); + + await tester.tap(find.text('Restart Driver')); + await tester.pumpAndSettle(); + + expect(restartClicked, isTrue); + }); + + testWidgets('ExtensionDriverRecoveryBanner displays custom message and restarting spinner', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const ExtensionDriverRecoveryBanner( + onRestart: _noop, + isRestarting: true, + customMessage: 'Custom crash diagnostic message', + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Custom crash diagnostic message'), findsOneWidget); + expect(find.text('Restarting...'), findsOneWidget); + expect(find.byType(material.CircularProgressIndicator), findsOneWidget); + }); +} + +void _noop() {} diff --git a/test/features/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index d6ac4eb..835759f 100644 --- a/test/features/workspace/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -470,6 +470,20 @@ void main() { ); expect(find.textContaining('syntax error'), findsOneWidget); + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab( + errorMessage: 'driver connection failed', + errorAction: material.Text('RESTART_ACTION_BUTTON'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('RESTART_ACTION_BUTTON'), findsOneWidget); + expect(find.textContaining('driver connection failed'), findsOneWidget); + await tester.pumpWidget( resultsShell( child: const material.Scaffold(