diff --git a/packages/.vscode/launch.json b/packages/.vscode/launch.json index 9548c784464..a41186fca1d 100644 --- a/packages/.vscode/launch.json +++ b/packages/.vscode/launch.json @@ -78,6 +78,13 @@ "type": "dart", "program": "devtools_app/test/test_infra/fixtures/memory_app/lib/main.dart", }, + { + "name": "standalone_ui/vs_code", + "request": "launch", + "type": "dart", + "program": "devtools_app/test/test_infra/scenes/standalone_ui/vs_code.stager_app.g.dart", + "deviceId": "chrome", + }, { "name": "attach", "type": "dart", diff --git a/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message.dart b/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message.dart new file mode 100644 index 00000000000..09463d76298 --- /dev/null +++ b/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message.dart @@ -0,0 +1,15 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be found +// in the LICENSE file. + +export 'post_message_stub.dart' if (dart.library.html) 'post_message_web.dart'; + +class PostMessageEvent { + PostMessageEvent({ + required this.origin, + required this.data, + }); + + final String origin; + final Object? data; +} diff --git a/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message_stub.dart b/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message_stub.dart new file mode 100644 index 00000000000..063bdaa9d53 --- /dev/null +++ b/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message_stub.dart @@ -0,0 +1,11 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be found +// in the LICENSE file. + +import 'post_message.dart'; + +Stream get onPostMessage => + throw UnsupportedError('unsupported platform'); + +void postMessage(Object? _, String __) => + throw UnsupportedError('unsupported platform'); diff --git a/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message_web.dart b/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message_web.dart new file mode 100644 index 00000000000..7630660cd01 --- /dev/null +++ b/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message_web.dart @@ -0,0 +1,19 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be found +// in the LICENSE file. + +import 'dart:html' as html; + +import 'post_message.dart'; + +Stream get onPostMessage { + return html.window.onMessage.map( + (message) => PostMessageEvent( + origin: message.origin, + data: message.data, + ), + ); +} + +void postMessage(Object? message, String targetOrigin) => + html.window.parent?.postMessage(message, targetOrigin); diff --git a/packages/devtools_app/lib/src/standalone_ui/api/dart_tooling_api.dart b/packages/devtools_app/lib/src/standalone_ui/api/dart_tooling_api.dart new file mode 100644 index 00000000000..d5be958a4fc --- /dev/null +++ b/packages/devtools_app/lib/src/standalone_ui/api/dart_tooling_api.dart @@ -0,0 +1,17 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'vs_code_api.dart'; + +/// An API exposed to Dart tooling surfaces. +/// +/// APIs are grouped into child APIs that are exposed as fields. Each field is a +/// `Future` that will return null if the requested API is unavailable (for +/// example the VS Code APIs if not running inside VS Code, or the LSP APIs if +/// no LSP server is available). +abstract interface class DartToolingApi { + /// Access to APIs provided by VS Code and/or the Dart/Flutter VS Code + /// extensions. + Future get vsCode; +} diff --git a/packages/devtools_app/lib/src/standalone_ui/api/impl/dart_tooling_api.dart b/packages/devtools_app/lib/src/standalone_ui/api/impl/dart_tooling_api.dart new file mode 100644 index 00000000000..f1e3490363c --- /dev/null +++ b/packages/devtools_app/lib/src/standalone_ui/api/impl/dart_tooling_api.dart @@ -0,0 +1,112 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc_2; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:stream_channel/stream_channel.dart'; + +import '../../../shared/config_specific/logger/logger_helpers.dart'; +import '../../../shared/config_specific/post_message/post_message.dart'; +import '../../../shared/constants.dart'; +import '../dart_tooling_api.dart'; +import '../vs_code_api.dart'; +import 'vs_code_api.dart'; + +/// Whether to enable verbose logging for postMessage communication. +/// +/// This is useful for debugging when running inside VS Code. +/// +/// TODO(dantup): Make a way for this to be enabled by users at runtime for +/// troubleshooting. This could be via a message from VS Code, or something +/// that passes a query param. +const _enablePostMessageVerboseLogging = false; + +final _log = Logger('tooling_api'); + +/// An API used by Dart tooling surfaces to interact with Dart tools that expose +/// APIs such as Dart-Code and the LSP server. +class DartToolingApiImpl implements DartToolingApi { + DartToolingApiImpl.rpc(this._rpc) { + unawaited(_rpc.listen()); + } + + /// Connects the API using 'postMessage'. This is only available when running + /// on web and hosted inside an iframe (such as inside a VS Code webview). + factory DartToolingApiImpl.postMessage() { + if (_enablePostMessageVerboseLogging) { + setDevToolsLoggingLevel(verboseLoggingLevel); + } + final postMessageController = StreamController(); + postMessageController.stream.listen((message) { + _log.info('==> $message'); + postMessage(message, '*'); + }); + final channel = StreamChannel( + onPostMessage.map((event) { + _log.info('<== ${jsonEncode(event.data)}'); + return event.data; + }), + postMessageController, + ); + return DartToolingApiImpl.rpc(json_rpc_2.Peer.withoutJson(channel)); + } + + final json_rpc_2.Peer _rpc; + + /// An API that provides Access to APIs related to VS Code, such as executing + /// VS Code commands or interacting with the Dart/Flutter extensions. + /// + /// Lazy-initialized and completes with `null` if VS Code is not available. + @override + late final Future vsCode = VsCodeApiImpl.tryConnect(_rpc); + + void dispose() { + unawaited(_rpc.close()); + } +} + +/// Base class for the different APIs that may be available. +abstract base class ToolApiImpl { + ToolApiImpl(this.rpc); + + static Future?> tryGetCapabilities( + json_rpc_2.Peer rpc, + String apiName, + ) async { + try { + final response = await rpc.sendRequest('$apiName.getCapabilities') + as Map; + return response.cast(); + } catch (_) { + // Any error initializing should disable this functionality. + return null; + } + } + + @protected + final json_rpc_2.Peer rpc; + + @protected + String get apiName; + + @protected + Future sendRequest(String method, [Object? parameters]) async { + return (await rpc.sendRequest('$apiName.$method', parameters)) as T; + } + + /// Listens for an event '[apiName].[name]' that has a Map for parameters. + @protected + Stream> events(String name) { + final streamController = StreamController>.broadcast(); + unawaited(rpc.done.then((_) => streamController.close())); + rpc.registerMethod('$apiName.$name', (json_rpc_2.Parameters parameters) { + streamController.add(parameters.asMap.cast()); + }); + return streamController.stream; + } +} diff --git a/packages/devtools_app/lib/src/standalone_ui/api/impl/vs_code_api.dart b/packages/devtools_app/lib/src/standalone_ui/api/impl/vs_code_api.dart new file mode 100644 index 00000000000..fa6603782d0 --- /dev/null +++ b/packages/devtools_app/lib/src/standalone_ui/api/impl/vs_code_api.dart @@ -0,0 +1,157 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc_2; +import 'package:meta/meta.dart'; + +import '../vs_code_api.dart'; +import 'dart_tooling_api.dart'; + +final class VsCodeApiImpl extends ToolApiImpl implements VsCodeApi { + VsCodeApiImpl._(super.rpc, Map capabilities) { + this.capabilities = VsCodeCapabilitiesImpl(capabilities); + devicesChanged = events(VsCodeApi.jsonDevicesChangedEvent) + .map(VsCodeDevicesEventImpl.fromJson); + } + + static Future tryConnect(json_rpc_2.Peer rpc) async { + final capabilities = + await ToolApiImpl.tryGetCapabilities(rpc, VsCodeApi.jsonApiName); + return capabilities != null ? VsCodeApiImpl._(rpc, capabilities) : null; + } + + @override + Future initialize() => sendRequest(VsCodeApi.jsonInitializeMethod); + + @override + @protected + String get apiName => VsCodeApi.jsonApiName; + + @override + late final Stream devicesChanged; + + @override + late final VsCodeCapabilities capabilities; + + @override + Future executeCommand(String command, [List? arguments]) { + return sendRequest( + VsCodeApi.jsonExecuteCommandMethod, + { + VsCodeApi.jsonExecuteCommandCommandParameter: command, + VsCodeApi.jsonExecuteCommandArgumentsParameter: arguments, + }, + ); + } + + @override + Future selectDevice(String id) { + return sendRequest( + VsCodeApi.jsonSelectDeviceMethod, + {VsCodeApi.jsonSelectDeviceIdParameter: id}, + ); + } +} + +class VsCodeDeviceImpl implements VsCodeDevice { + VsCodeDeviceImpl({ + required this.id, + required this.name, + required this.category, + required this.emulator, + required this.emulatorId, + required this.ephemeral, + required this.platform, + required this.platformType, + }); + + VsCodeDeviceImpl.fromJson(Map json) + : this( + id: json[VsCodeDevice.jsonIdField] as String, + name: json[VsCodeDevice.jsonNameField] as String, + category: json[VsCodeDevice.jsonCategoryField] as String?, + emulator: json[VsCodeDevice.jsonEmulatorField] as bool, + emulatorId: json[VsCodeDevice.jsonEmulatorIdField] as String?, + ephemeral: json[VsCodeDevice.jsonEphemeralField] as bool, + platform: json[VsCodeDevice.jsonPlatformField] as String, + platformType: json[VsCodeDevice.jsonPlatformTypeField] as String?, + ); + + @override + final String id; + + @override + final String name; + + @override + final String? category; + + @override + final bool emulator; + + @override + final String? emulatorId; + + @override + final bool ephemeral; + + @override + final String platform; + + @override + final String? platformType; + + Map toJson() => { + VsCodeDevice.jsonIdField: id, + VsCodeDevice.jsonNameField: name, + VsCodeDevice.jsonCategoryField: category, + VsCodeDevice.jsonEmulatorField: emulator, + VsCodeDevice.jsonEmulatorIdField: emulatorId, + VsCodeDevice.jsonEphemeralField: ephemeral, + VsCodeDevice.jsonPlatformField: platform, + VsCodeDevice.jsonPlatformTypeField: platformType, + }; +} + +class VsCodeDevicesEventImpl implements VsCodeDevicesEvent { + VsCodeDevicesEventImpl({ + required this.selectedDeviceId, + required this.devices, + }); + + VsCodeDevicesEventImpl.fromJson(Map json) + : this( + selectedDeviceId: + json[VsCodeDevicesEvent.jsonSelectedDeviceIdField] as String?, + devices: (json[VsCodeDevicesEvent.jsonDevicesField] as List) + .map((item) => Map.from(item)) + .map((map) => VsCodeDeviceImpl.fromJson(map)) + .toList(), + ); + + @override + final String? selectedDeviceId; + + @override + final List devices; + + Map toJson() => { + VsCodeDevicesEvent.jsonSelectedDeviceIdField: selectedDeviceId, + VsCodeDevicesEvent.jsonDevicesField: devices, + }; +} + +class VsCodeCapabilitiesImpl implements VsCodeCapabilities { + VsCodeCapabilitiesImpl(this._raw); + + final Map? _raw; + + @override + bool get executeCommand => + _raw?[VsCodeCapabilities.jsonExecuteCommandField] == true; + + @override + bool get selectDevice => + _raw?[VsCodeCapabilities.jsonSelectDeviceField] == true; +} diff --git a/packages/devtools_app/lib/src/standalone_ui/api/vs_code_api.dart b/packages/devtools_app/lib/src/standalone_ui/api/vs_code_api.dart new file mode 100644 index 00000000000..d3d8d206700 --- /dev/null +++ b/packages/devtools_app/lib/src/standalone_ui/api/vs_code_api.dart @@ -0,0 +1,84 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// This class defines the API exposed by the Dart/Flutter extensions in VS +/// Code (and must match the implementation there). +/// +/// All changes to this file should be backwards-compatible and use +/// [VsCodeCapabilities] to advertise which capabilities are available and +/// handle any changes in behaviour. +abstract interface class VsCodeApi { + VsCodeCapabilities get capabilities; + Future initialize(); + Stream get devicesChanged; + Future executeCommand(String command, [List? arguments]); + Future selectDevice(String id); + + static const jsonApiName = 'vsCode'; + + static const jsonInitializeMethod = 'initialize'; + + static const jsonExecuteCommandMethod = 'executeCommand'; + static const jsonExecuteCommandCommandParameter = 'command'; + static const jsonExecuteCommandArgumentsParameter = 'arguments'; + + static const jsonDevicesChangedEvent = 'devicesChanged'; + + static const jsonSelectDeviceMethod = 'selectDevice'; + static const jsonSelectDeviceIdParameter = 'id'; +} + +/// This class defines a device exposed by the Dart/Flutter extensions in VS +/// Code (and must match the implementation there). +/// +/// All changes to this file should be backwards-compatible and use +/// [VsCodeCapabilities] to advertise which capabilities are available and +/// handle any changes in behaviour. +abstract interface class VsCodeDevice { + String get id; + String get name; + String? get category; + bool get emulator; + String? get emulatorId; + bool get ephemeral; + String get platform; + String? get platformType; + + static const jsonIdField = 'id'; + static const jsonNameField = 'name'; + static const jsonCategoryField = 'category'; + static const jsonEmulatorField = 'emulator'; + static const jsonEmulatorIdField = 'emulatorId'; + static const jsonEphemeralField = 'ephemeral'; + static const jsonPlatformField = 'platform'; + static const jsonPlatformTypeField = 'platformType'; +} + +/// This class defines a device event sent by the Dart/Flutter extensions in VS +/// Code (and must match the implementation there). +/// +/// All changes to this file should be backwards-compatible and use +/// [VsCodeCapabilities] to advertise which capabilities are available and +/// handle any changes in behaviour. +abstract interface class VsCodeDevicesEvent { + String? get selectedDeviceId; + List get devices; + + static const jsonSelectedDeviceIdField = 'selectedDeviceId'; + static const jsonDevicesField = 'devices'; +} + +/// This class defines the capabilities provided by the current version of the +/// Dart/Flutter extensions in VS Code. +/// +/// All changes to this file should be backwards-compatible and use +/// [VsCodeCapabilities] to advertise which capabilities are available and +/// handle any changes in behaviour. +abstract interface class VsCodeCapabilities { + bool get executeCommand; + bool get selectDevice; + + static const jsonExecuteCommandField = 'executeCommand'; + static const jsonSelectDeviceField = 'selectDevice'; +} diff --git a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart index 557be38917d..7bc7d62e737 100644 --- a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart +++ b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; +import 'api/impl/dart_tooling_api.dart'; import 'vs_code/flutter_panel.dart'; /// "Screens" that are intended for standalone use only, likely for embedding @@ -26,7 +27,8 @@ enum StandaloneScreenType { Widget get screen { return switch (this) { - StandaloneScreenType.vsCodeFlutterPanel => const VsCodeFlutterPanel(), + StandaloneScreenType.vsCodeFlutterPanel => + VsCodeFlutterPanel(DartToolingApiImpl.postMessage()), }; } } diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart index 72b3053aa16..741ccd6c5a8 100644 --- a/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart @@ -2,18 +2,114 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + +import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/material.dart'; +import '../../../devtools_app.dart'; import '../../shared/feature_flags.dart'; +import '../api/dart_tooling_api.dart'; +import '../api/vs_code_api.dart'; +/// A general Flutter sidebar panel for embedding inside IDEs. +/// +/// Provides some basic functionality to improve discoverability of features +/// such as creation of new projects, device selection and DevTools features. class VsCodeFlutterPanel extends StatelessWidget { - const VsCodeFlutterPanel({super.key}); + const VsCodeFlutterPanel(this.api, {super.key}); + + final DartToolingApi api; @override Widget build(BuildContext context) { assert(FeatureFlags.vsCodeSidebarTooling); - return const Center( - child: Text('TODO: a panel for flutter actions in VS Code'), + + return Column( + children: [ + FutureBuilder( + future: api.vsCode, + builder: (context, snapshot) => + switch ((snapshot.connectionState, snapshot.data)) { + (ConnectionState.done, final vsCodeApi?) => + _VsCodeConnectedPanel(vsCodeApi), + (ConnectionState.done, null) => + const Text('VS Code is not available'), + _ => const CenteredCircularProgressIndicator(), + }, + ), + ], + ); + } +} + +/// The panel shown once we know VS Code is available (the host has responded to +/// the `vsCode.getCapabilities` request). +class _VsCodeConnectedPanel extends StatefulWidget { + const _VsCodeConnectedPanel(this.api); + + final VsCodeApi api; + + @override + State<_VsCodeConnectedPanel> createState() => _VsCodeConnectedPanelState(); +} + +class _VsCodeConnectedPanelState extends State<_VsCodeConnectedPanel> { + @override + void initState() { + super.initState(); + + unawaited(widget.api.initialize()); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const SizedBox(height: defaultSpacing), + if (widget.api.capabilities.executeCommand) + ElevatedButton( + onPressed: () => + unawaited(widget.api.executeCommand('flutter.createProject')), + child: const Text('New Flutter Project'), + ), + if (widget.api.capabilities.selectDevice) + StreamBuilder( + stream: widget.api.devicesChanged, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } + final deviceEvent = snapshot.data!; + return Table( + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + for (final device in deviceEvent.devices) + TableRow( + children: [ + TextButton( + child: Text(device.name), + onPressed: () => + unawaited(widget.api.selectDevice(device.id)), + ), + Text( + device.id == deviceEvent.selectedDeviceId + ? '(selected)' + : '', + ), + ], + ), + ], + ); + }, + ), + if (widget.api.capabilities.executeCommand) + ElevatedButton( + onPressed: () => + unawaited(widget.api.executeCommand('flutter.doctor')), + child: const Text('Run Flutter Doctor'), + ), + ], ); } } diff --git a/packages/devtools_app/pubspec.yaml b/packages/devtools_app/pubspec.yaml index 4ffb3c86840..eeeee9fab28 100644 --- a/packages/devtools_app/pubspec.yaml +++ b/packages/devtools_app/pubspec.yaml @@ -46,8 +46,10 @@ dependencies: image: ^3.0.2 intl: ">=0.16.1 <0.18.0" js: ^0.6.1+1 + json_rpc_2: ^3.0.2 leak_tracker: 2.0.1 logging: ^1.1.1 + meta: ^1.9.1 mime: ^1.0.0 path: ^1.8.0 perfetto_ui_compiled: @@ -58,6 +60,7 @@ dependencies: shared_preferences: ^2.0.15 sse: ^4.1.2 stack_trace: ^1.10.0 + stream_channel: ^2.1.1 string_scanner: ^1.1.0 url_launcher: ^6.1.0 url_launcher_web: ^2.0.6 diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/vs_code.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/vs_code.dart new file mode 100644 index 00000000000..50ed69336e9 --- /dev/null +++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/vs_code.dart @@ -0,0 +1,62 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be found +// in the LICENSE file. + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/shared/feature_flags.dart'; +import 'package:devtools_app/src/standalone_ui/vs_code/flutter_panel.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; +import 'package:stager/stager.dart'; + +import '../../../test_infra/test_data/dart_tooling_api/mock_api.dart'; +import 'vs_code_mock_editor.dart'; + +final _api = MockDartToolingApi(); + +/// To run, use the "standalone_ui/vs_code" launch configuration with the +/// `devtools/packages/` folder open in VS Code, or run: +/// +/// flutter run -t test/test_infra/scenes/standalone_ui/vs_code.stager_app.g.dart --dart-define=enable_experiments=true -d chrome +class VsCodeScene extends Scene { + late PerformanceController controller; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: themeFor( + isDarkTheme: false, + ideTheme: _ideTheme(const VsCodeTheme.light()), + theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme), + ), + darkTheme: themeFor( + isDarkTheme: true, + ideTheme: _ideTheme(const VsCodeTheme.dark()), + theme: ThemeData(useMaterial3: true, colorScheme: darkColorScheme), + ), + home: Scaffold( + body: VsCodeFlutterPanelMockEditor( + api: _api, + child: VsCodeFlutterPanel(_api), + ), + ), + ); + } + + /// Creates an [IdeTheme] using the colours from the mock editor. + IdeTheme _ideTheme(VsCodeTheme vsCodeTheme) { + return IdeTheme( + backgroundColor: vsCodeTheme.editorBackgroundColor, + foregroundColor: vsCodeTheme.foregroundColor, + embed: true, + ); + } + + @override + String get title => '$VsCodeScene'; + + @override + Future setUp() async { + FeatureFlags.vsCodeSidebarTooling = true; + } +} diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/vs_code_mock_editor.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/vs_code_mock_editor.dart new file mode 100644 index 00000000000..b71fd3924fb --- /dev/null +++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/vs_code_mock_editor.dart @@ -0,0 +1,193 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:collection'; +import 'dart:convert'; + +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; + +import '../../../test_infra/test_data/dart_tooling_api/mock_api.dart'; + +/// A simple UI that acts as a stand-in host IDE to simplify the development +/// workflow when working on embedded tooling. +/// +/// This UI interacts with [MockDartToolingApi] to allow triggering events that +/// would normally be fired by the IDE and also shows a log of recent requests. +class VsCodeFlutterPanelMockEditor extends StatefulWidget { + const VsCodeFlutterPanelMockEditor({ + super.key, + required this.api, + this.child, + }); + + /// The mock API to interact with. + final MockDartToolingApi api; + + final Widget? child; + + @override + State createState() => + _VsCodeFlutterPanelMockEditorState(); +} + +class _VsCodeFlutterPanelMockEditorState + extends State { + MockDartToolingApi get api => widget.api; + + /// The number of communication messages to keep in the log. + static const maxLogEvents = 20; + + /// The last [maxLogEvents] communication messages sent between the panel + /// and the "host IDE". + final logRing = DoubleLinkedQueue(); + + /// A stream that emits each time the log is updated to allow the log widget + /// to be rebuilt. + Stream? logUpdated; + + /// Flutter icon for the sidebar. + final sidebarImageBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAMAAADXqc3KAAABF1BMVEUAAAD///////+/v//MzMzb29vf39/j4+PV1erY2Njb29vS4eHX1+TZ2ebW1uDY2OLW3d3Y2N7Z2d/a2uDV2+DW2+DX3OHZ2eLZ2d7V2t/Y2OHX29/X29/Z2eDW2eDW2uDX2uHW2d/X2uDY2+HW2d/W2+HW2eHX2d/W2+DW2eDX2eHX2uHX29/X2d/Y2uDY2uDW2uDX2uDX2+DX2+DX2eDX2t/Y2+DX29/Y2eDW2eDX2uDX2uDW2d/X2uDX2uDY2uDX2uHX2eDX2uDX2uHY2t/X2+DX2uDY2uDX2uDX2uDX2+DW2uDX2eDX2uDX2uDX2uDX2eDX2uDX2uDX2uDX2uDX2uDX2uDX2uDX2uDX2uDX2uDX2uDX2uANs9umAAAAXHRSTlMAAgMEBQcICQwNDhETFBkaJScoKTEyMzU2Nzs/QElKS0xQU1VYXV5fY2RlbXh5e3yDi4yNjpmboaKjpKepqrO1ub7AwcLEzM/R2Nnc4OPk5efr7O3w8vT3+Pn7/A+G+WEAAAABYktHRAH/Ai3eAAAA0UlEQVQoz2NgQAKythCgwYAKFCLtTIHAO0YbVVw23AREqUTroYlH0FrcGK94FJq4HExcH5c4t5IyGAiCxeUjDUGUWrQOr0cMBJiDJYwiJYCkarQOt5sXP5Al4OvKBZZgsgqRBJsDERf0c+GE2sFsE2IAVy/k78wBt53ZJkYXKi4c4MCO5C4mCR53Tz4gQyTIng3VyVoxSiDK04cVLY6YLEOlQE4PN2NElzEPkwFS0qHWLNhlxIPt2LDLiAY6cmDaoygmJqYe4cSJLmMBDStNIAcAHhssjDYY1ccAAAAASUVORK5CYII=', + ); + + @override + void initState() { + super.initState(); + + // Listen to the log stream to maintain our buffer and trigger rebuilds. + logUpdated = api.log.map((log) { + logRing.add(log); + while (logRing.length > maxLogEvents) { + logRing.removeFirst(); + } + }); + } + + @override + Widget build(BuildContext context) { + final editorTheme = VsCodeTheme.of(context); + return Split( + axis: Axis.horizontal, + initialFractions: const [0.25, 0.75], + minSizes: const [200, 200], + children: [ + Row( + children: [ + SizedBox( + width: 48, + child: Container( + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(top: 60), + constraints: const BoxConstraints.expand(width: 48), + color: editorTheme.activityBarBackgroundColor, + child: Image.memory(sidebarImageBytes), + ), + ), + Expanded( + child: Container( + color: editorTheme.sidebarBackgroundColor, + child: widget.child ?? const Placeholder(), + ), + ), + ], + ), + Split( + axis: Axis.vertical, + initialFractions: const [0.5, 0.5], + minSizes: const [200, 200], + children: [ + Container( + color: editorTheme.editorBackgroundColor, + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Mock Editor', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Text(''), + const Text( + 'Use these buttons to simulate actions that would usually occur in the IDE.', + ), + const Text(''), + Row( + children: [ + ElevatedButton( + onPressed: api.connectDevices, + child: const Text('Connect Devices'), + ), + ElevatedButton( + onPressed: api.disconnectDevices, + child: const Text('Disconnect Devices'), + ), + ], + ), + ], + ), + ), + Container( + color: editorTheme.editorBackgroundColor, + padding: const EdgeInsets.all(10), + child: StreamBuilder( + stream: logUpdated, + builder: (context, snapshot) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final log in logRing) + Text( + log, + style: Theme.of(context).fixedFontStyle, + ), + ], + ); + }, + ), + ), + ], + ), + ], + ); + } +} + +/// A basic theme that matches the default colours of VS Code dart/light themes +/// so the mock environment can be displayed in either. +class VsCodeTheme { + const VsCodeTheme._({ + required this.activityBarBackgroundColor, + required this.editorBackgroundColor, + required this.foregroundColor, + required this.sidebarBackgroundColor, + }); + + const VsCodeTheme.dark() + : this._( + activityBarBackgroundColor: const Color(0xFF333333), + editorBackgroundColor: const Color(0xFF1E1E1E), + foregroundColor: const Color(0xFFD4D4D4), + sidebarBackgroundColor: const Color(0xFF252526), + ); + + const VsCodeTheme.light() + : this._( + activityBarBackgroundColor: const Color(0xFF2C2C2C), + editorBackgroundColor: const Color(0xFFFFFFFF), + foregroundColor: const Color(0xFF000000), + sidebarBackgroundColor: const Color(0xFFF3F3F3), + ); + + static VsCodeTheme of(BuildContext context) { + return Theme.of(context).isDarkTheme + ? const VsCodeTheme.dark() + : const VsCodeTheme.light(); + } + + final Color activityBarBackgroundColor; + final Color editorBackgroundColor; + final Color foregroundColor; + final Color sidebarBackgroundColor; +} diff --git a/packages/devtools_app/test/test_infra/test_data/dart_tooling_api/mock_api.dart b/packages/devtools_app/test/test_infra/test_data/dart_tooling_api/mock_api.dart new file mode 100644 index 00000000000..3acf15b9509 --- /dev/null +++ b/packages/devtools_app/test/test_infra/test_data/dart_tooling_api/mock_api.dart @@ -0,0 +1,166 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:devtools_app/src/standalone_ui/api/impl/dart_tooling_api.dart'; +import 'package:devtools_app/src/standalone_ui/api/impl/vs_code_api.dart'; +import 'package:devtools_app/src/standalone_ui/api/vs_code_api.dart'; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc_2; +import 'package:stream_channel/stream_channel.dart'; + +/// A [DartToolingApi] that acts as a stand-in host IDE to simplify the +/// development workflow when working on embedded tooling. +/// +/// This API will handle requests with canned responses and can generate +/// events in a similar way to the IDE would. It is used by +/// [VsCodeFlutterPanelMock] which provides a UI onto this functionality and a +/// log of recent requests. +class MockDartToolingApi extends DartToolingApiImpl { + factory MockDartToolingApi() { + // Set up channels where we can act as the server in-process without really + // going over postMessage or a WebSocket (since in the mock environment we + // can't do either). + final clientStreams = StreamController(); + final serverStreams = StreamController(); + + // Capture traffic in both directions to aid development/debugging. + final log = StreamController(); + var logLine = 1; + Stream logStream(Stream stream, String prefix) { + return stream.map((item) { + log.add('${logLine++} $prefix $item'); + return item; + }); + } + + final clientChannel = StreamChannel( + logStream(serverStreams.stream, '<=='), + clientStreams.sink, + ); + final serverChannel = StreamChannel( + logStream(clientStreams.stream, '==>'), + serverStreams.sink, + ); + + final clientPeer = json_rpc_2.Peer(clientChannel); + final serverPeer = json_rpc_2.Peer(serverChannel); + unawaited(serverPeer.listen()); + + return MockDartToolingApi._( + client: clientPeer, + server: serverPeer, + log: log.stream, + ); + } + + MockDartToolingApi._({ + required this.client, + required this.server, + required this.log, + }) : super.rpc(client) { + // Register methods as they'll be available in a real host. + server.registerMethod('vsCode.getCapabilities', () async { + return { + 'executeCommand': true, + 'selectDevice': true, + }; + }); + server.registerMethod('vsCode.initialize', initialize); + server.registerMethod('vsCode.executeCommand', executeCommand); + server.registerMethod('vsCode.selectDevice', selectDevice); + } + + final json_rpc_2.Peer client; + final json_rpc_2.Peer server; + + /// A set of mock devices that can be presented for testing. + final _mockDevices = [ + VsCodeDeviceImpl( + id: 'myMac', + name: 'Mac', + category: 'desktop', + emulator: false, + emulatorId: null, + ephemeral: false, + platform: 'darwin-x64', + platformType: 'macos', + ), + VsCodeDeviceImpl( + id: 'myPhone', + name: 'My Android Phone', + category: 'mobile', + emulator: false, + emulatorId: null, + ephemeral: true, + platform: 'android-x64', + platformType: 'android', + ), + ]; + + /// The current set of devices being presented to the embedded panel. + final _devices = []; + + /// The currently selected device presented to the embedded panel. + String? _selectedDeviceId; + + /// A stream of log events for debugging. + final Stream log; + + /// Simulates executing a VS Code command requested by the embedded panel. + Future initialize() async { + connectDevices(); + } + + /// Simulates executing a VS Code command requested by the embedded panel. + Future executeCommand(json_rpc_2.Parameters parameters) async { + final params = parameters.asMap; + final command = params['command']; + switch (command) { + case 'flutter.createProject': + return null; + case 'flutter.doctor': + return null; + default: + throw 'Unknown command $command'; + } + } + + /// Simulates changing the selected device to [id] as requested by the + /// embedded panel. + Future selectDevice(json_rpc_2.Parameters parameters) async { + final params = parameters.asMap; + _selectedDeviceId = params['id'] as String?; + _sendDevicesChanged(); + return true; + } + + /// Simulates devices being connected in the IDE by notifying the embedded + /// panel about a set of test devices. + void connectDevices() { + _devices + ..clear() + ..addAll(_mockDevices); + _selectedDeviceId = _devices.lastOrNull?.id; + _sendDevicesChanged(); + } + + /// Simulates devices being disconnected in the IDE by notifying the embedded + /// panel about a now-empty set of devices. + void disconnectDevices() { + _devices.clear(); + _selectedDeviceId = null; + _sendDevicesChanged(); + } + + void _sendDevicesChanged() { + server.sendNotification( + 'vsCode.devicesChanged', + VsCodeDevicesEventImpl( + devices: _devices, + selectedDeviceId: _selectedDeviceId, + ).toJson(), + ); + } +}