Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,40 +64,21 @@ class _TimelineEventsTabViewState extends State<TimelineEventsTabView>
}

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,
),
);
},
Expand Down
21 changes: 21 additions & 0 deletions packages/devtools_app/lib/src/shared/editor/editor_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,27 @@ class EditorClient extends DisposableController
}
}

Future<bool> 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<DTDResponse> _call(
EditorMethod method, {
Map<String, Object?>? params,
Expand Down
55 changes: 55 additions & 0 deletions packages/devtools_app/lib/src/shared/ui/common_widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2283,3 +2283,58 @@ class _PositiveIntegerSettingState extends State<PositiveIntegerSetting>
);
}
}

/// 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -37,17 +39,31 @@ class PropertyEditorController extends DisposableController
_editableWidgetData;
final _editableWidgetData = ValueNotifier<EditableWidgetData?>(null);

ValueListenable<bool> get shouldReconnect => _shouldReconnect;
final _shouldReconnect = ValueNotifier<bool>(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.
Expand Down Expand Up @@ -78,6 +94,7 @@ class PropertyEditorController extends DisposableController
@override
void dispose() {
_editableArgsDebouncer.dispose();
_checkConnectionTimer.cancel();
super.dispose();
}

Expand Down Expand Up @@ -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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this value ever get set back to false?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but because the IFRAME is then re-loaded the whole Property Editor is re-initialized again (with the default value false). I could reset to false though I don't know if it would ever actually be called.

timer.cancel();
}
});
}

@visibleForTesting
void initForTestsOnly({
EditableArgumentsResult? editableArgsResult,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<bool>(
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(),
],
);
},
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ReconnectingOverlay> createState() => _ReconnectingOverlayState();
}

class _ReconnectingOverlayState extends State<ReconnectingOverlay> {
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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down