diff --git a/pkgs/dart_services/lib/src/common.dart b/pkgs/dart_services/lib/src/common.dart index 578fc6837..01bb3f885 100644 --- a/pkgs/dart_services/lib/src/common.dart +++ b/pkgs/dart_services/lib/src/common.dart @@ -21,6 +21,7 @@ import 'dart:ui_web' as ui_web; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; +import 'package:flutter/foundation.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'generated_plugin_registrant.dart' as pluginRegistrant; @@ -29,7 +30,16 @@ import 'main.dart' as entrypoint; @JS('window') external JSObject get _window; +@JS('reportFlutterError') +external void _reportFlutterError(String message); + Future main() async { + // Capture errors and throw them to the JS console so DartPad can report them + // correctly. + FlutterError.onError = (details) { + _reportFlutterError(details.toString()); + }; + // Mock DWDS indicators to allow Flutter to register hot reload 'reassemble' // extension. _window[r'$dwdsVersion'] = true.toJS; diff --git a/pkgs/dartpad_ui/lib/console.dart b/pkgs/dartpad_ui/lib/console.dart index d3cd8c6ef..849a1c879 100644 --- a/pkgs/dartpad_ui/lib/console.dart +++ b/pkgs/dartpad_ui/lib/console.dart @@ -10,14 +10,15 @@ import 'enable_gen_ai.dart'; import 'model.dart'; import 'suggest_fix.dart'; import 'theme.dart'; +import 'utils.dart'; import 'widgets.dart'; class ConsoleWidget extends StatefulWidget { final bool showDivider; - final ValueNotifier output; + final ConsoleNotifier output; const ConsoleWidget({ - this.showDivider = true, + this.showDivider = false, required this.output, super.key, }); @@ -64,62 +65,66 @@ class _ConsoleWidgetState extends State { : null, ), padding: const EdgeInsets.all(denseSpacing), - child: ValueListenableBuilder( - valueListenable: widget.output, - builder: - (context, consoleOutput, _) => Stack( - children: [ - SizedBox.expand( - child: SingleChildScrollView( - controller: scrollController, - child: SelectableText( - consoleOutput, - maxLines: null, - style: GoogleFonts.robotoMono( - fontSize: theme.textTheme.bodyMedium?.fontSize, - ), + child: ListenableBuilder( + listenable: widget.output, + builder: (context, _) { + return Stack( + children: [ + SizedBox.expand( + child: SingleChildScrollView( + controller: scrollController, + child: SelectableText( + widget.output.valueToDisplay, + maxLines: null, + style: GoogleFonts.robotoMono( + fontSize: theme.textTheme.bodyMedium?.fontSize, + color: switch (widget.output.hasError) { + false => theme.textTheme.bodyMedium?.color, + true => theme.colorScheme.error.darker, + }, ), ), ), - Padding( - padding: const EdgeInsets.all(denseSpacing), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (genAiEnabled && appModel.consoleShowingError) - MiniIconButton( - icon: Image.asset( - 'gemini_sparkle_192.png', - width: 16, - height: 16, - ), - tooltip: 'Suggest fix', - onPressed: - () => suggestFix( - context: context, - appType: appModel.appType, - errorMessage: consoleOutput, - ), - ), + ), + Padding( + padding: const EdgeInsets.all(denseSpacing), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (genAiEnabled && appModel.consoleShowingError) MiniIconButton( - icon: const Icon(Icons.playlist_remove), - tooltip: 'Clear console', - onPressed: consoleOutput.isEmpty ? null : _clearConsole, + icon: Image.asset( + 'gemini_sparkle_192.png', + width: 16, + height: 16, + ), + tooltip: 'Suggest fix', + onPressed: + () => suggestFix( + context: context, + appType: appModel.appType, + errorMessage: widget.output.error, + ), ), - ], - ), + MiniIconButton( + icon: const Icon(Icons.playlist_remove), + tooltip: 'Clear console', + onPressed: + widget.output.isEmpty + ? null + : () => widget.output.clear(), + ), + ], ), - ], - ), + ), + ], + ); + }, ), ); } - void _clearConsole() { - widget.output.value = ''; - } - void _scrollToEnd() { if (!mounted) return; final controller = scrollController; diff --git a/pkgs/dartpad_ui/lib/execution/frame.dart b/pkgs/dartpad_ui/lib/execution/frame.dart index 03db84f8e..b8e3df9f6 100644 --- a/pkgs/dartpad_ui/lib/execution/frame.dart +++ b/pkgs/dartpad_ui/lib/execution/frame.dart @@ -14,6 +14,8 @@ import '../model.dart'; class ExecutionServiceImpl implements ExecutionService { final StreamController _stdoutController = StreamController.broadcast(); + final StreamController _stderrController = + StreamController.broadcast(); web.HTMLIFrameElement _frame; late String _frameSrc; @@ -53,6 +55,9 @@ class ExecutionServiceImpl implements ExecutionService { @override Stream get onStdout => _stdoutController.stream; + @override + Stream get onStderr => _stderrController.stream; + @override set ignorePointer(bool ignorePointer) { _frame.style.pointerEvents = ignorePointer ? 'none' : 'auto'; @@ -254,7 +259,7 @@ require(["dartpad_main", "dart_sdk"], function(dartpad_main, dart_sdk) { // Ignore any exceptions before the iframe has completed // initialization. if (_readyCompleter.isCompleted) { - _stdoutController.add(data['message'] as String); + _stderrController.add(data['message'] as String); } } else if (type == 'ready' && !_readyCompleter.isCompleted) { _readyCompleter.complete(); diff --git a/pkgs/dartpad_ui/lib/main.dart b/pkgs/dartpad_ui/lib/main.dart index 69dbbcc54..42f3c5751 100644 --- a/pkgs/dartpad_ui/lib/main.dart +++ b/pkgs/dartpad_ui/lib/main.dart @@ -230,9 +230,12 @@ class _DartPadMainPageState extends State late final AppModel appModel; late final AppServices appServices; late final SplitViewController mainSplitter; + late final SplitViewController consoleSplitter; late final TabController tabController; - final Key _executionWidgetKey = GlobalKey(debugLabel: 'execution-widget'); + final GlobalKey _executionWidgetKey = GlobalKey( + debugLabel: 'execution-widget', + ); final ValueKey _loadingOverlayKey = const ValueKey( 'loading-overlay-widget', ); @@ -259,6 +262,13 @@ class _DartPadMainPageState extends State appModel.splitDragStateManager.handleSplitChanged(); }); + consoleSplitter = SplitViewController( + weights: [0.7, 0.3], + limits: [WeightLimit(max: 0.9), WeightLimit(min: 0.1)], + )..addListener(() { + appModel.splitDragStateManager.handleSplitChanged(); + }); + final channel = widget.initialChannel != null ? Channel.forName(widget.initialChannel!) @@ -338,28 +348,34 @@ class _DartPadMainPageState extends State ValueListenableBuilder( valueListenable: appModel.layoutMode, builder: (context, LayoutMode mode, _) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final domHeight = mode.calcDomHeight(constraints.maxHeight); - final consoleHeight = mode.calcConsoleHeight( - constraints.maxHeight, - ); - - return Column( - children: [ - SizedBox(height: domHeight, child: executionWidget), - SizedBox( - height: consoleHeight, - child: ConsoleWidget( - output: appModel.consoleOutput, - showDivider: mode == LayoutMode.both, - key: _consoleKey, - ), + return switch (mode) { + LayoutMode.both => SplitView( + viewMode: SplitViewMode.Vertical, + gripColor: theme.colorScheme.surface, + gripColorActive: theme.colorScheme.surface, + gripSize: defaultGripSize, + controller: consoleSplitter, + children: [ + executionWidget, + ConsoleWidget( + key: _consoleKey, + output: appModel.consoleNotifier, + ), + ], + ), + LayoutMode.justDom => executionWidget, + LayoutMode.justConsole => Column( + children: [ + SizedBox(height: 0, width: 0, child: executionWidget), + Expanded( + child: ConsoleWidget( + key: _consoleKey, + output: appModel.consoleNotifier, ), - ], - ); - }, - ); + ), + ], + ), + }; }, ), loadingOverlay, @@ -495,7 +511,7 @@ class _DartPadMainPageState extends State appServices.editorService!.focus(); } catch (error) { appModel.editorStatus.showToast('Error formatting code'); - appModel.appendLineToConsole('Formatting issue: $error'); + appModel.appendError('Formatting issue: $error'); return; } } @@ -709,7 +725,7 @@ class DartPadAppBar extends StatelessWidget implements PreferredSizeWidget { appServices.performCompileAndReloadOrRun(); } catch (error) { appModel.editorStatus.showToast('Error generating code'); - appModel.appendLineToConsole('Generating code issue: $error'); + appModel.appendError('Generating code issue: $error'); } } @@ -782,7 +798,7 @@ class DartPadAppBar extends StatelessWidget implements PreferredSizeWidget { appServices.performCompileAndReloadOrRun(); } catch (error) { appModel.editorStatus.showToast('Error updating code'); - appModel.appendLineToConsole('Updating code issue: $error'); + appModel.appendError('Updating code issue: $error'); } } } @@ -957,7 +973,7 @@ class EditorWithButtons extends StatelessWidget { appServices.editorService!.focus(); } catch (error) { appModel.editorStatus.showToast('Error retrieving docs'); - appModel.appendLineToConsole('$error'); + appModel.appendError('$error'); return; } } diff --git a/pkgs/dartpad_ui/lib/model.dart b/pkgs/dartpad_ui/lib/model.dart index 7e64cb0de..58ca350cf 100644 --- a/pkgs/dartpad_ui/lib/model.dart +++ b/pkgs/dartpad_ui/lib/model.dart @@ -27,6 +27,7 @@ abstract class ExecutionService { required bool isFlutter, }); Stream get onStdout; + Stream get onStderr; Future reset(); Future tearDown(); set ignorePointer(bool ignorePointer); @@ -53,7 +54,7 @@ class AppModel { final ValueNotifier title = ValueNotifier(''); final TextEditingController sourceCodeController = TextEditingController(); - final ValueNotifier consoleOutput = ValueNotifier(''); + final ConsoleNotifier consoleNotifier = ConsoleNotifier('', ''); final ValueNotifier formattingBusy = ValueNotifier(false); final ValueNotifier compilingState = ValueNotifier( @@ -80,15 +81,14 @@ class AppModel { final ValueNotifier vimKeymapsEnabled = ValueNotifier(false); - bool _consoleShowingError = false; - bool get consoleShowingError => _consoleShowingError; + bool get consoleShowingError => consoleNotifier.error.isNotEmpty; final ValueNotifier showReload = ValueNotifier(false); final ValueNotifier useNewDDC = ValueNotifier(false); final ValueNotifier currentDeltaDill = ValueNotifier(null); AppModel() { - consoleOutput.addListener(_recalcLayout); + consoleNotifier.addListener(_recalcLayout); void updateCanReload() => canReload.value = hasRun.value && @@ -112,32 +112,25 @@ class AppModel { }); } - static final _errorRe = RegExp( - r'\b(unhandled|exception)\b', - caseSensitive: false, - ); - void appendLineToConsole(String str) { - consoleOutput.value += '$str\n'; + consoleNotifier.output += '$str\n'; + } - // NOTE(csells): workaround for https://github.com/dart-lang/dart-pad/issues/3148; - // this heuristic is not foolproof, but seems to work well for both Dart and - // Flutter unhandled exceptions based on limited testing. - if (_errorRe.hasMatch(str)) _consoleShowingError = true; + void appendError(String str) { + consoleNotifier.error += '$str\n'; } void clearConsole() { - consoleOutput.value = ''; - _consoleShowingError = false; + consoleNotifier.clear(); } void dispose() { - consoleOutput.removeListener(_recalcLayout); + consoleNotifier.removeListener(_recalcLayout); _splitSubscription.cancel(); } void _recalcLayout() { - final hasConsoleText = consoleOutput.value.isNotEmpty; + final hasConsoleText = !consoleNotifier.isEmpty; final isFlutter = _appIsFlutter.value; final usesPackageWeb = _usesPackageWeb; @@ -191,6 +184,7 @@ class AppServices { EditorService? _editorService; StreamSubscription? stdoutSub; + StreamSubscription? stderrSub; // TODO: Consider using DebounceStreamTransformer from package:rxdart. Timer? reanalysisDebouncer; @@ -303,7 +297,7 @@ class AppServices { appModel.editorStatus.showToast('Error loading sample'); progress.close(); - appModel.appendLineToConsole('Error loading sample: $e'); + appModel.appendError('Error loading sample: $e'); appModel.sourceCodeController.text = getFallback(); appModel.appReady.value = true; @@ -348,7 +342,7 @@ class AppServices { appModel.editorStatus.showToast('Error loading gist'); progress.close(); - appModel.appendLineToConsole('Error loading gist: $e'); + appModel.appendError('Error loading gist: $e'); appModel.sourceCodeController.text = getFallback(); appModel.appReady.value = true; @@ -390,7 +384,7 @@ class AppServices { } else { response = await _compileNewDDC(CompileRequest(source: source)); } - if (!reload || appModel._consoleShowingError) { + if (!reload || appModel.consoleShowingError) { appModel.clearConsole(); } _executeJavaScript( @@ -409,12 +403,11 @@ class AppServices { appModel.editorStatus.showToast('Compilation failed'); if (error is ApiRequestError) { - appModel.appendLineToConsole(error.message); - appModel.appendLineToConsole(error.body); + appModel.appendError(error.message); + appModel.appendError(error.body); } else { - appModel.appendLineToConsole('$error'); + appModel.appendError('$error'); } - appModel._consoleShowingError = true; } finally { progress.close(); } @@ -502,14 +495,18 @@ class AppServices { void registerExecutionService(ExecutionService? executionService) { // unregister the old stdoutSub?.cancel(); + stderrSub?.cancel(); // replace the service _executionService = executionService; // register the new if (_executionService != null) { - stdoutSub = _executionService!.onStdout.listen( - appModel.appendLineToConsole, + stdoutSub = _executionService!.onStdout.listen((msg) { + appModel.appendLineToConsole(msg); + }); + stderrSub = _executionService!.onStderr.listen( + (msg) => appModel.appendError(msg), ); } } @@ -662,3 +659,33 @@ class PromptDialogResponse { final String prompt; final List attachments; } + +class ConsoleNotifier extends ChangeNotifier { + String _output; + String _error; + + ConsoleNotifier(this._output, this._error); + + String get output => _output; + + set output(String value) { + _output = value; + notifyListeners(); + } + + String get error => _error; + set error(String value) { + _error = value; + notifyListeners(); + } + + void clear() { + _output = ''; + _error = ''; + notifyListeners(); + } + + bool get isEmpty => _output.isEmpty && _error.isEmpty; + bool get hasError => _error.isNotEmpty; + String get valueToDisplay => hasError ? _error : _output; +} diff --git a/pkgs/dartpad_ui/web/frame.js b/pkgs/dartpad_ui/web/frame.js index d88ce0ac5..fc0fab9f3 100644 --- a/pkgs/dartpad_ui/web/frame.js +++ b/pkgs/dartpad_ui/web/frame.js @@ -15,7 +15,7 @@ function replaceJavaScript(value) { scriptNode.async = false; scriptNode.text = value; document.head.appendChild(scriptNode); -}; +} // Handles incoming messages. function messageHandler(e) { @@ -26,7 +26,17 @@ function messageHandler(e) { } else if (obj.command === 'executeReload') { runFlutterApp(obj.js, obj.canvasKitBaseUrl, true); } -}; +} + +// Used by the bootstrapped flutter script to report Flutter errors to DartPad +// separately from console output. +function reportFlutterError(e) { + parent.postMessage({ + 'sender': 'frame', + 'type': 'stderr', + 'message': e + }, '*'); +} function runFlutterApp(compiledScript, canvasKitBaseUrl, reload) { var blob = new Blob([compiledScript], { type: 'text/javascript' });