diff --git a/lib/data/error_presentation.dart b/lib/data/error_presentation.dart index 536fc1f..1e7421f 100644 --- a/lib/data/error_presentation.dart +++ b/lib/data/error_presentation.dart @@ -109,6 +109,27 @@ void showFaiProcessError( ); } +/// Like [showFaiProcessError] but as a persistent modal dialog. Use for +/// failures the operator needs time to read and copy (a daemon that +/// won't start) — a SnackBar flashes away before they can act on it. +Future showFaiProcessErrorDialog( + BuildContext context, + String source, + String stdout, + String stderr, { + String? title, +}) { + final detail = [stderr.trim(), stdout.trim()] + .where((s) => s.isNotEmpty) + .join('\n\n'); + return showFaiErrorDialog( + context, + source, + detail.isEmpty ? 'process exited non-zero (no output)' : detail, + title: title, + ); +} + /// Show [error] as a centered modal dialog. Use this when the /// failure blocks meaningful interaction (auth missing, hub /// unreachable) — a SnackBar would be too easy to dismiss. diff --git a/lib/main.dart b/lib/main.dart index fd3791a..350b9f4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -275,20 +275,29 @@ class StudioShellState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.daemonStartRequested)), ); - } else { - // Surface the daemon's stderr/stdout as a copyable error — this - // is the message the operator most often needs to paste back. - showFaiProcessError( - context, - 'daemon.start', - r.stdout, - r.stderr, - title: l.daemonStartFailed(''), - ); + Future.delayed(const Duration(seconds: 1), _checkHealth); + return; } - // Probe again shortly so the UI flips to "connected" without - // waiting for the next 5 s tick. - Future.delayed(const Duration(seconds: 1), _checkHealth); + // The start command reported failure — but very often the daemon is + // simply already running (port in use). Probe before crying error, + // so "tap to start" on an already-up hub just connects. + final alive = + await HubService.instance.healthy().catchError((_) => false); + if (!mounted) return; + if (alive) { + await _checkHealth(); + return; + } + // Genuinely down: a persistent, copyable dialog — a SnackBar flashes + // away before the operator can read or copy the daemon's stderr. + await showFaiProcessErrorDialog( + context, + 'daemon.start', + r.stdout, + r.stderr, + title: l.daemonStartFailed(''), + ); + if (mounted) Future.delayed(const Duration(seconds: 1), _checkHealth); } /// Switch the sidebar's selected page by its registered id @@ -702,17 +711,23 @@ class _SidebarState extends State<_Sidebar> ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.daemonStartRequested)), ); - } else { - // Surface the daemon's stderr/stdout as a copyable error — this - // is the message the operator most often needs to paste back. - showFaiProcessError( - context, - 'daemon.start', - r.stdout, - r.stderr, - title: l.daemonStartFailed(''), - ); + return; } + // The daemon may already be running (port in use) — probe before + // showing an error, so this just connects on an already-up hub. + final alive = + await HubService.instance.healthy().catchError((_) => false); + if (!context.mounted) return; + if (alive) return; + // Genuinely down: persistent, copyable dialog (a SnackBar flashes + // away before the operator can read or copy the daemon's stderr). + await showFaiProcessErrorDialog( + context, + 'daemon.start', + r.stdout, + r.stderr, + title: l.daemonStartFailed(''), + ); } @override