Skip to content

Commit 3d6b599

Browse files
authored
[ Widget Preview ] Improve IDE integration support (flutter#176114)
This change adds support for two DTD Editor service RPCs and makes functionality that's dependent on the availability of the Editor service reactive to the service availability changing. The newly added `getActiveLocation` Editor RPC now allows for us to properly determine which source file is selected at startup, removing the need for special casing around initializing the filtered preview set. The `navigateToCode` Editor RPC is now supported, and URIs in stack traces can now be clicked to navigate to the source location in the IDE. Both filtering previews by selected file and stack frame navigation links are only enabled when the Editor service is available. Fixes flutter#176113
1 parent b78356c commit 3d6b599

32 files changed

+869
-159
lines changed

packages/flutter_tools/lib/src/commands/widget_preview.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
254254
shutdownHooks: shutdownHooks,
255255
onHotRestartPreviewerRequest: onHotRestartRequest,
256256
dtdLauncher: DtdLauncher(logger: logger, artifacts: artifacts, processManager: processManager),
257+
project: rootProject.widgetPreviewScaffoldProject,
257258
);
258259

259260
/// The currently running instance of the widget preview scaffold.

packages/flutter_tools/lib/src/widget_preview/dtd_services.dart

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'dart:async';
66

77
import 'package:dtd/dtd.dart';
88
import 'package:json_rpc_2/json_rpc_2.dart';
9+
import 'package:package_config/package_config_types.dart';
910
import 'package:process/process.dart';
1011

1112
import '../artifacts.dart';
@@ -15,6 +16,8 @@ import '../base/logger.dart';
1516
import '../base/platform.dart';
1617
import '../base/process.dart';
1718
import '../convert.dart';
19+
import '../dart/package_map.dart';
20+
import '../project.dart';
1821

1922
typedef DtdService = (String, DTDServiceCallback);
2023

@@ -25,6 +28,7 @@ class WidgetPreviewDtdServices {
2528
required this.shutdownHooks,
2629
required this.dtdLauncher,
2730
required this.onHotRestartPreviewerRequest,
31+
required this.project,
2832
}) {
2933
shutdownHooks.addShutdownHook(() async {
3034
await _dtd?.close();
@@ -40,9 +44,14 @@ class WidgetPreviewDtdServices {
4044
static const kWidgetPreviewService = 'widget-preview';
4145
static const kIsWindows = 'isWindows';
4246
static const kHotRestartPreviewer = 'hotRestartPreviewer';
47+
static const kResolveUri = 'resolveUri';
4348

4449
/// The list of DTD service methods registered by the tool.
45-
late final services = <DtdService>[(kHotRestartPreviewer, _hotRestart), (kIsWindows, _isWindows)];
50+
late final services = <DtdService>[
51+
(kHotRestartPreviewer, _hotRestart),
52+
(kIsWindows, _isWindows),
53+
(kResolveUri, _resolveUri),
54+
];
4655

4756
// END KEEP SYNCED
4857

@@ -54,6 +63,11 @@ class WidgetPreviewDtdServices {
5463
/// scaffold.
5564
final VoidCallback onHotRestartPreviewerRequest;
5665

66+
/// The widget_preview_scaffold project.
67+
final FlutterProject project;
68+
69+
PackageConfig? _packageConfig;
70+
5771
DartToolingDaemon? _dtd;
5872

5973
/// The [Uri] pointing to the currently connected DTD instance.
@@ -95,6 +109,12 @@ class WidgetPreviewDtdServices {
95109
Future<Map<String, Object?>> _isWindows(Parameters _) async {
96110
return BoolResponse(const LocalPlatform().isWindows).toJson();
97111
}
112+
113+
Future<Map<String, Object?>> _resolveUri(Parameters params) async {
114+
_packageConfig ??= await loadPackageConfigWithLogging(project.packageConfig, logger: logger);
115+
final Uri? result = _packageConfig!.resolve(Uri.parse(params.asMap['uri'] as String));
116+
return StringResponse(result.toString()).toJson();
117+
}
98118
}
99119

100120
/// Manages the lifecycle of a Dart Tooling Daemon (DTD) instance.

packages/flutter_tools/lib/src/widget_preview/preview_pubspec_builder.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class PreviewPubspecBuilder {
4040
/// - flutter_lints, which is referenced by the analysis_options.yaml generated by the 'app'
4141
/// template.
4242
/// - google_fonts, which is used for the Roboto Mono font.
43+
/// - json_rpc_2, which is used to handle errors thrown by package:dtd
4344
/// - path, which is used to normalize and compare paths.
4445
/// - stack_trace, which is used to generate terse stack traces for displaying errors thrown
4546
/// by widgets being previewed.
@@ -48,6 +49,7 @@ class PreviewPubspecBuilder {
4849
'dtd',
4950
'flutter_lints',
5051
'google_fonts',
52+
'json_rpc_2',
5153
'path',
5254
'stack_trace',
5355
'url_launcher',

packages/flutter_tools/templates/template_manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@
344344
"templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl",
345345
"templates/widget_preview_scaffold/lib/src/widget_preview_scaffold_controller.dart.tmpl",
346346
"templates/widget_preview_scaffold/lib/src/controls.dart.tmpl",
347+
"templates/widget_preview_scaffold/lib/src/dtd/utils.dart.tmpl",
347348
"templates/widget_preview_scaffold/lib/src/dtd/dtd_services.dart.tmpl",
348349
"templates/widget_preview_scaffold/lib/src/dtd/editor_service.dart.tmpl",
349350
"templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl",

packages/flutter_tools/templates/widget_preview_scaffold/lib/src/controls.dart.tmpl

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -153,25 +153,38 @@ class LayoutTypeSelector extends StatelessWidget {
153153

154154
/// A toggle button that enables / disables filtering previews by the currently
155155
/// selected source file.
156+
///
157+
/// This control is hidden if the DTD Editor service isn't available.
156158
class FilterBySelectedFileToggle extends StatelessWidget {
157159
const FilterBySelectedFileToggle({super.key, required this.controller});
158160

161+
@visibleForTesting
162+
static const kTooltip = 'Filter previews by selected file';
163+
159164
final WidgetPreviewScaffoldController controller;
160165

161166
@override
162167
Widget build(BuildContext context) {
163-
return _ControlDecorator(
164-
child: ValueListenableBuilder(
165-
valueListenable: controller.filterBySelectedFileListenable,
166-
builder: (context, value, child) {
167-
return IconButton(
168-
onPressed: controller.toggleFilterBySelectedFile,
169-
icon: Icon(Icons.file_open),
170-
color: value ? Colors.blue : Colors.black,
171-
tooltip: 'Filter previews by selected file',
172-
);
173-
},
174-
),
168+
return ValueListenableBuilder(
169+
valueListenable: controller.editorServiceAvailable,
170+
builder: (context, editorServiceAvailable, child) {
171+
if (!editorServiceAvailable) {
172+
return Container();
173+
}
174+
return _ControlDecorator(
175+
child: ValueListenableBuilder(
176+
valueListenable: controller.filterBySelectedFileListenable,
177+
builder: (context, value, child) {
178+
return IconButton(
179+
onPressed: controller.toggleFilterBySelectedFile,
180+
icon: Icon(Icons.file_open),
181+
color: value ? Colors.blue : Colors.black,
182+
tooltip: kTooltip,
183+
);
184+
},
185+
),
186+
);
187+
},
175188
);
176189
}
177190
}

packages/flutter_tools/templates/widget_preview_scaffold/lib/src/dtd/dtd_services.dart.tmpl

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import 'dart:async';
66

77
import 'package:dtd/dtd.dart';
8+
import 'package:widget_preview_scaffold/src/dtd/utils.dart';
89
import 'editor_service.dart';
910

1011
/// Provides services, streams, and RPC invocations to interact with Flutter developer tooling.
@@ -17,9 +18,10 @@ class WidgetPreviewScaffoldDtdServices with DtdEditorService {
1718
//
1819
// START KEEP SYNCED
1920

20-
static const kWidgetPreviewService = 'widget-preview';
21+
static const String kWidgetPreviewService = 'widget-preview';
2122
static const kIsWindows = 'isWindows';
22-
static const kHotRestartPreviewer = 'hotRestartPreviewer';
23+
static const String kHotRestartPreviewer = 'hotRestartPreviewer';
24+
static const String kResolveUri = 'resolveUri';
2325

2426
// END KEEP SYNCED
2527

@@ -50,21 +52,35 @@ class WidgetPreviewScaffoldDtdServices with DtdEditorService {
5052
await dtd.close();
5153
}
5254

53-
Future<DTDResponse> _call(
55+
Future<DTDResponse?> _call(
5456
String methodName, {
5557
Map<String, Object?>? params,
56-
}) => dtd.call(kWidgetPreviewService, methodName, params: params);
58+
}) => dtd.safeCall(kWidgetPreviewService, methodName, params: params);
5759

5860
/// Returns `true` if the operating system is Windows.
5961
late final bool isWindows;
6062

6163
Future<void> _determineIfWindows() async {
62-
isWindows = ((await _call(kIsWindows)) as BoolResponse).value!;
64+
isWindows = (BoolResponse.fromDTDResponse(
65+
(await _call(kIsWindows))!,
66+
)).value!;
6367
}
6468

6569
/// Trigger a hot restart of the widget preview scaffold.
6670
Future<void> hotRestartPreviewer() => _call(kHotRestartPreviewer);
6771

72+
/// Resolves a package:// URI to a file:// URI using the package_config.
73+
///
74+
/// Returns null if [uri] can not be resolved.
75+
Future<Uri?> resolveUri(Uri uri) async {
76+
final response = await _call(kResolveUri, params: {'uri': uri.toString()});
77+
if (response == null) {
78+
return null;
79+
}
80+
final result = StringResponse.fromDTDResponse(response).value;
81+
return result == null ? null : Uri.parse(result);
82+
}
83+
6884
@override
6985
late final DartToolingDaemon dtd;
7086
}

packages/flutter_tools/templates/widget_preview_scaffold/lib/src/dtd/editor_service.dart.tmpl

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:async';
6+
57
import 'package:dtd/dtd.dart';
68
import 'package:flutter/foundation.dart';
79
import 'package:widget_preview_scaffold/src/dtd/dtd_services.dart';
10+
import 'package:widget_preview_scaffold/src/dtd/utils.dart';
811

912
/// Provides support for interacting with the Editor DTD service registered by IDE plugins.
1013
mixin DtdEditorService {
@@ -13,6 +16,27 @@ mixin DtdEditorService {
1316
/// The name of the Editor service.
1417
static const String kEditorService = 'Editor';
1518

19+
/// The name of the Editor's getActiveLocation method.
20+
static const String kGetActiveLocation = 'getActiveLocation';
21+
22+
/// The name of the Editor's navigateToCode method.
23+
static const String kNavigateToCode = 'navigateToCode';
24+
25+
/// The name of the DTD Service stream.
26+
static const String kServiceStream = 'Service';
27+
28+
/// The kind of the event sent over the [kServiceStream] stream when a new
29+
/// service method is registered.
30+
static const kServiceRegistered = 'ServiceRegistered';
31+
32+
/// The kind of the event sent over the [kServiceStream] stream when a
33+
/// service method is unregistered.
34+
static const kServiceUnregistered = 'ServiceUnregistered';
35+
36+
/// Whether or not the Editor service is available.
37+
ValueListenable<bool> get editorServiceAvailable => _editorServiceAvailable;
38+
static final _editorServiceAvailable = ValueNotifier<bool>(false);
39+
1640
/// The currently selected source file in the IDE.
1741
ValueListenable<TextDocument?> get selectedSourceFile => _selectedSourceFile;
1842
static final _selectedSourceFile = ValueNotifier<TextDocument?>(null);
@@ -41,14 +65,61 @@ mixin DtdEditorService {
4165
).textDocument;
4266
}
4367
});
44-
await dtd.streamListen(kEditorService);
68+
await dtd.safeStreamListen(kEditorService);
69+
70+
dtd.onEvent(kServiceStream).listen((data) async {
71+
switch (data) {
72+
case DTDEvent(
73+
kind: kServiceRegistered,
74+
data: {
75+
DtdParameters.service: kEditorService,
76+
DtdParameters.method: kGetActiveLocation,
77+
},
78+
):
79+
// Manually retrieve the currently selected source file.
80+
unawaited(_updateSelectedSourceFile());
81+
_editorServiceAvailable.value = true;
82+
case DTDEvent(
83+
kind: kServiceRegistered,
84+
data: {DtdParameters.service: kEditorService},
85+
):
86+
_editorServiceAvailable.value = true;
87+
case DTDEvent(
88+
kind: kServiceUnregistered,
89+
data: {DtdParameters.service: kEditorService},
90+
):
91+
_editorServiceAvailable.value = false;
92+
}
93+
});
94+
await dtd.safeStreamListen(kServiceStream);
4595
}
4696

4797
@mustCallSuper
4898
void dispose() {
4999
_selectedSourceFile.dispose();
100+
_editorServiceAvailable.dispose();
50101
_editorTheme.dispose();
51102
}
103+
104+
Future<void> _updateSelectedSourceFile() async {
105+
final response = await dtd.safeCall(kEditorService, kGetActiveLocation);
106+
if (response != null) {
107+
_selectedSourceFile.value = ActiveLocation.fromJson(
108+
response.result,
109+
).textDocument;
110+
}
111+
}
112+
113+
/// Tells the editor to navigate to a given code [location].
114+
///
115+
/// Only locations with `file://` URIs are valid.
116+
Future<void> navigateToCode(CodeLocation location) async {
117+
await dtd.safeCall(
118+
kEditorService,
119+
kNavigateToCode,
120+
params: location.toJson(),
121+
);
122+
}
52123
}
53124

54125
// TODO(bkonyi): much of the following code is copied from the DevTools codebase. We should publish
@@ -123,13 +194,24 @@ class ThemeChangedEvent extends EditorEvent {
123194
}
124195

125196
/// An event sent by an editor when the current cursor position/s change.
126-
class ActiveLocationChangedEvent extends EditorEvent {
127-
ActiveLocationChangedEvent({
128-
required this.selections,
129-
required this.textDocument,
130-
});
197+
class ActiveLocationChangedEvent extends ActiveLocation implements EditorEvent {
198+
ActiveLocationChangedEvent({required ActiveLocation activeLocation})
199+
: super(
200+
selections: activeLocation.selections,
201+
textDocument: activeLocation.textDocument,
202+
);
131203

132204
ActiveLocationChangedEvent.fromJson(Map<String, Object?> map)
205+
: this(activeLocation: ActiveLocation.fromJson(map));
206+
207+
@override
208+
EditorEventKind get kind => EditorEventKind.activeLocationChanged;
209+
}
210+
211+
class ActiveLocation {
212+
ActiveLocation({required this.selections, required this.textDocument});
213+
214+
ActiveLocation.fromJson(Map<String, Object?> map)
133215
: this(
134216
textDocument: map.containsKey(Field.textDocument)
135217
? TextDocument.fromJson(
@@ -145,9 +227,6 @@ class ActiveLocationChangedEvent extends EditorEvent {
145227
final List<EditorSelection> selections;
146228
final TextDocument? textDocument;
147229

148-
@override
149-
EditorEventKind get kind => EditorEventKind.activeLocationChanged;
150-
151230
Map<String, Object?> toJson() => {
152231
Field.selections: selections,
153232
Field.textDocument: textDocument,
@@ -269,13 +348,39 @@ class CursorPosition {
269348
int get hashCode => Object.hash(character, line);
270349
}
271350

351+
/// Parameters for the `navigateToCode` request.
352+
class CodeLocation {
353+
const CodeLocation({required this.uri, this.line, this.column});
354+
355+
/// The URI of the location to navigate to. Only `file://` URIs are supported
356+
/// unless the service registration's `capabilities` indicate other schemes
357+
/// are supported.
358+
///
359+
/// Editors should return error code 144 if a caller passes a URI with an
360+
/// unsupported scheme.
361+
final String uri;
362+
363+
/// Optional 1-based line number to navigate to.
364+
final int? line;
365+
366+
/// Optional 1-based column number to navigate to.
367+
final int? column;
368+
369+
Map<String, Object?> toJson() => {
370+
Field.uri: uri,
371+
Field.line: ?line,
372+
Field.column: ?column,
373+
};
374+
}
375+
272376
/// Constants for all fields used in JSON maps to avoid literal strings that
273377
/// may have typos sprinkled throughout the API classes.
274378
abstract class Field {
275379
static const active = 'active';
276380
static const anchor = 'anchor';
277381
static const backgroundColor = 'backgroundColor';
278382
static const character = 'character';
383+
static const column = 'column';
279384
static const end = 'end';
280385
static const fontSize = 'fontSize';
281386
static const foregroundColor = 'foregroundColor';

0 commit comments

Comments
 (0)