diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_view.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_view.dart index 3687b02c914..1a44da58283 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_view.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_view.dart @@ -3,7 +3,6 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:async'; -import 'dart:math' as math; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; @@ -65,40 +64,21 @@ class _TimelineEventsTabViewState extends State } void _insertOverlay() { - final width = math.min( - _overlaySize.width, - MediaQuery.of(context).size.width - 2 * denseSpacing, - ); - final height = math.min( - _overlaySize.height, - MediaQuery.of(context).size.height - 2 * denseSpacing, - ); final theme = Theme.of(context); _refreshingOverlay?.remove(); Overlay.of(context).insert( _refreshingOverlay = OverlayEntry( maintainState: true, builder: (context) { - return Center( - child: Padding( - padding: const EdgeInsets.only(top: _overlayOffset), - child: RoundedOutlinedBorder( - clip: true, - child: Container( - width: width, - height: height, - color: theme.colorScheme.semiTransparentOverlayColor, - child: Center( - child: Text( - 'Refreshing the timeline...\n\n' - 'This may take a few seconds. Please do not\n' - 'refresh the page.', - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium, - ), - ), - ), - ), + return DevToolsOverlay( + topOffset: _overlayOffset, + maxSize: _overlaySize, + content: Text( + 'Refreshing the timeline...\n\n' + 'This may take a few seconds. Please do not\n' + 'refresh the page.', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, ), ); }, diff --git a/packages/devtools_app/lib/src/shared/editor/editor_client.dart b/packages/devtools_app/lib/src/shared/editor/editor_client.dart index 58ff98cfdb1..dffc8ae6881 100644 --- a/packages/devtools_app/lib/src/shared/editor/editor_client.dart +++ b/packages/devtools_app/lib/src/shared/editor/editor_client.dart @@ -317,6 +317,27 @@ class EditorClient extends DisposableController } } + Future isClientClosed() async { + try { + // Make an empty request to DTD. + await _dtd.call('', ''); + } on StateError catch (e) { + // TODO(https://github.com/flutter/devtools/issues/9028): Replace with a + // check for whether DTD is closed. Requires a change to package:dtd. + // + // This is only a temporary fix. If the error in package:json_rpc_2 is + // changed, this will no longer catch it. See: + // https://github.com/dart-lang/tools/blob/b55643dadafd3ac6b2bd20823802f75929ebf98e/pkgs/json_rpc_2/lib/src/client.dart#L151 + if (e.message.contains('The client is closed.')) { + return true; + } + } catch (e) { + // Ignore other exceptions. If the client is open, we expect this to fail + // with the error: 'Unknown method "."'. + } + return false; + } + Future _call( EditorMethod method, { Map? params, diff --git a/packages/devtools_app/lib/src/shared/ui/common_widgets.dart b/packages/devtools_app/lib/src/shared/ui/common_widgets.dart index fb8bf822149..20f5610ad8a 100644 --- a/packages/devtools_app/lib/src/shared/ui/common_widgets.dart +++ b/packages/devtools_app/lib/src/shared/ui/common_widgets.dart @@ -2283,3 +2283,58 @@ class _PositiveIntegerSettingState extends State ); } } + +/// Creates an overlay with the provided [content]. +/// +/// Set [fullScreen] to true to take up the entire screen. Otherwise, a +/// [maxSize] and [topOffset] can be provided to determine the overlay's size +/// and location. +class DevToolsOverlay extends StatelessWidget { + const DevToolsOverlay({ + super.key, + required this.content, + this.fullScreen = false, + this.maxSize, + this.topOffset, + }) : assert(maxSize != null || topOffset != null ? !fullScreen : true); + + final Widget content; + final bool fullScreen; + final Size? maxSize; + final double? topOffset; + + @override + Widget build(BuildContext context) { + final parentSize = MediaQuery.of(context).size; + + final overlayContent = Container( + width: _overlayWidth(parentSize), + height: _overlayHeight(parentSize), + color: Theme.of(context).colorScheme.semiTransparentOverlayColor, + child: Center(child: content), + ); + + return fullScreen + ? overlayContent + : Center( + child: Padding( + padding: EdgeInsets.only(top: topOffset ?? 0.0), + child: RoundedOutlinedBorder(clip: true, child: overlayContent), + ), + ); + } + + double _overlayWidth(Size parentSize) { + if (fullScreen) return parentSize.width; + final defaultWidth = parentSize.width - largeSpacing; + return maxSize != null ? min(maxSize!.width, defaultWidth) : defaultWidth; + } + + double _overlayHeight(Size parentSize) { + if (fullScreen) return parentSize.height; + final defaultHeight = parentSize.height - largeSpacing; + return maxSize != null + ? min(maxSize!.height, defaultHeight) + : defaultHeight; + } +} diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart index 5f8a493a845..572d1509e41 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:async'; + import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; @@ -37,17 +39,31 @@ class PropertyEditorController extends DisposableController _editableWidgetData; final _editableWidgetData = ValueNotifier(null); + ValueListenable get shouldReconnect => _shouldReconnect; + final _shouldReconnect = ValueNotifier(false); + + bool get waitingForFirstEvent => _waitingForFirstEvent; + bool _waitingForFirstEvent = true; + late final Debouncer _editableArgsDebouncer; + late final Timer _checkConnectionTimer; + static const _editableArgsDebounceDuration = Duration(milliseconds: 600); + static const _checkConnectionInterval = Duration(minutes: 1); + @override void init() { super.init(); _editableArgsDebouncer = Debouncer(duration: _editableArgsDebounceDuration); + _checkConnectionTimer = _periodicallyCheckConnection( + _checkConnectionInterval, + ); autoDisposeStreamSubscription( editorClient.activeLocationChangedStream.listen((event) async { + if (_waitingForFirstEvent) _waitingForFirstEvent = false; final textDocument = event.textDocument; final cursorPosition = event.selections.first.active; // Don't do anything if the text document is null. @@ -78,6 +94,7 @@ class PropertyEditorController extends DisposableController @override void dispose() { _editableArgsDebouncer.dispose(); + _checkConnectionTimer.cancel(); super.dispose(); } @@ -121,6 +138,16 @@ class PropertyEditorController extends DisposableController ); } + Timer _periodicallyCheckConnection(Duration interval) { + return Timer.periodic(interval, (timer) async { + final isClosed = await editorClient.isClientClosed(); + if (isClosed) { + _shouldReconnect.value = true; + timer.cancel(); + } + }); + } + @visibleForTesting void initForTestsOnly({ EditableArgumentsResult? editableArgsResult, diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart index b0c67e9a554..7cb8ff3b5f1 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart @@ -15,6 +15,7 @@ import '../../../shared/editor/editor_client.dart'; import '../../../shared/ui/common_widgets.dart'; import 'property_editor_controller.dart'; import 'property_editor_view.dart'; +import 'reconnecting_overlay.dart'; /// The side panel for the Property Editor. class PropertyEditorPanel extends StatefulWidget { @@ -106,24 +107,36 @@ class _PropertyEditorConnectedPanelState @override Widget build(BuildContext context) { - return Scrollbar( - controller: scrollController, - thumbVisibility: true, - child: SingleChildScrollView( - controller: scrollController, - child: Padding( - padding: const EdgeInsets.fromLTRB( - denseSpacing, - defaultSpacing, - defaultSpacing, // Additional right padding for scroll bar. - defaultSpacing, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [PropertyEditorView(controller: widget.controller)], - ), - ), - ), + return ValueListenableBuilder( + valueListenable: widget.controller.shouldReconnect, + builder: (context, shouldReconnect, _) { + return Stack( + children: [ + Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: scrollController, + child: Padding( + padding: const EdgeInsets.fromLTRB( + denseSpacing, + defaultSpacing, + defaultSpacing, // Additional right padding for scroll bar. + defaultSpacing, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PropertyEditorView(controller: widget.controller), + ], + ), + ), + ), + ), + if (shouldReconnect) const ReconnectingOverlay(), + ], + ); + }, ); } } diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart index 3db138d8e9e..4007a5436f5 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart @@ -37,8 +37,14 @@ class PropertyEditorView extends StatelessWidget { final editableWidgetData = values.third as EditableWidgetData?; if (editableWidgetData == null) { - return const CenteredMessage( - message: 'No Flutter widget found at the current cursor location.', + final introSentence = + controller.waitingForFirstEvent + ? '👋 Welcome to the Flutter Property Editor!' + : 'No Flutter widget found at the current cursor location.'; + const howToUseSentence = + 'Please move your cursor to a Flutter widget constructor invocation to view its properties.'; + return CenteredMessage( + message: '$introSentence\n\n$howToUseSentence', ); } diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/reconnecting_overlay.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/reconnecting_overlay.dart new file mode 100644 index 00000000000..66f9f3394b2 --- /dev/null +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/reconnecting_overlay.dart @@ -0,0 +1,71 @@ +// Copyright 2025 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; + +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; + +import '../../../shared/ui/common_widgets.dart'; +import 'utils/utils.dart'; + +class ReconnectingOverlay extends StatefulWidget { + const ReconnectingOverlay({super.key}); + + @override + State createState() => _ReconnectingOverlayState(); +} + +class _ReconnectingOverlayState extends State { + static const _countdownInterval = Duration(seconds: 1); + late final Timer _countdownTimer; + int _secondsUntilReconnection = 3; + + @override + void initState() { + super.initState(); + _countdownTimer = Timer.periodic(_countdownInterval, _onTick); + } + + @override + void dispose() { + _countdownTimer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return DevToolsOverlay( + fullScreen: true, + content: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: defaultSpacing), + Text( + _secondsUntilReconnection > 0 + ? 'Reconnecting in $_secondsUntilReconnection' + : 'Reconnecting...', + style: theme.textTheme.headlineMedium, + ), + ], + ), + ); + } + + void _onTick(Timer timer) { + setState(() { + _secondsUntilReconnection--; + if (_secondsUntilReconnection == 0) { + timer.cancel(); + _reconnect(); + } + }); + } + + void _reconnect() { + forceReload(); + } +} diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_desktop.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_desktop.dart index c79da0f1bac..ff2333a299b 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_desktop.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_desktop.dart @@ -2,6 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +void reloadIframe() { + // No-op for desktop platforms. +} + void addBlurListener() { // No-op for desktop platforms. } diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_web.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_web.dart index 87b99146ce0..880e275da9c 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_web.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_web.dart @@ -6,6 +6,10 @@ import 'dart:js_interop'; import 'package:web/web.dart'; +void reloadIframe() { + window.location.reload(); +} + void addBlurListener() { window.addEventListener('blur', _onBlur.toJS); } diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/utils.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/utils.dart index f33d9789fa5..fa3f1d7e3a7 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/utils.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/utils.dart @@ -78,6 +78,13 @@ RichText convertDartDocToRichText( return RichText(text: TextSpan(children: children)); } +/// Workaround to force reload the Property Editor when it disconnects. +/// +/// See https://github.com/flutter/devtools/issues/9028 for details. +void forceReload() { + reloadIframe(); +} + /// Workaround to prevent TextFields from holding onto focus when IFRAME-ed. /// /// See https://github.com/flutter/devtools/issues/8929 for details.