From e66766449d38854d75e93441113a9fbc8c1bbce4 Mon Sep 17 00:00:00 2001 From: Danny Tuppeny Date: Wed, 26 Jul 2023 18:32:22 +0100 Subject: [PATCH] Add some simple side bar functionality with a mock editor environment --- .../post_message/post_message.dart | 15 ++ .../post_message/post_message_stub.dart | 11 ++ .../post_message/post_message_web.dart | 19 +++ .../src/standalone_ui/standalone_screen.dart | 10 +- .../lib/src/standalone_ui/vs_code/api.dart | 132 +++++++++++++++++ .../standalone_ui/vs_code/flutter_panel.dart | 92 +++++++++++- .../vs_code/flutter_panel_mock.dart | 140 ++++++++++++++++++ .../src/standalone_ui/vs_code/mock_api.dart | 133 +++++++++++++++++ packages/devtools_app/pubspec.yaml | 2 + 9 files changed, 548 insertions(+), 6 deletions(-) create mode 100644 packages/devtools_app/lib/src/shared/config_specific/post_message/post_message.dart create mode 100644 packages/devtools_app/lib/src/shared/config_specific/post_message/post_message_stub.dart create mode 100644 packages/devtools_app/lib/src/shared/config_specific/post_message/post_message_web.dart create mode 100644 packages/devtools_app/lib/src/standalone_ui/vs_code/api.dart create mode 100644 packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel_mock.dart create mode 100644 packages/devtools_app/lib/src/standalone_ui/vs_code/mock_api.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 000000000000..09463d762984 --- /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 000000000000..063bdaa9d53e --- /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 000000000000..7630660cd014 --- /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/standalone_screen.dart b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart index 557be38917d9..476ab676408c 100644 --- a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart +++ b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; +import 'vs_code/api.dart'; import 'vs_code/flutter_panel.dart'; +import 'vs_code/flutter_panel_mock.dart'; /// "Screens" that are intended for standalone use only, likely for embedding /// directly in an IDE. @@ -13,7 +15,8 @@ import 'vs_code/flutter_panel.dart'; /// meaning that this screen will not be part of DevTools' normal navigation. /// The only way to access a standalone screen is directly from the url. enum StandaloneScreenType { - vsCodeFlutterPanel; + vsCodeFlutterPanel, + vsCodeFlutterPanelMock; static StandaloneScreenType? parse(String? id) { if (id == null) return null; @@ -26,7 +29,10 @@ enum StandaloneScreenType { Widget get screen { return switch (this) { - StandaloneScreenType.vsCodeFlutterPanel => const VsCodeFlutterPanel(), + StandaloneScreenType.vsCodeFlutterPanel => + VsCodeFlutterPanel(DartApi.postMessage()), + StandaloneScreenType.vsCodeFlutterPanelMock => + const VsCodeFlutterPanelMock(), }; } } diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/api.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/api.dart new file mode 100644 index 000000000000..7c86050838e5 --- /dev/null +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/api.dart @@ -0,0 +1,132 @@ +// 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:json_rpc_2/json_rpc_2.dart' as json_rpc_2; +import 'package:stream_channel/stream_channel.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../../shared/config_specific/post_message/post_message.dart'; + +/// An API for interacting with Dart tooling. +class DartApi { + DartApi.rpc(this._rpc) : vsCode = VsCodeApi(_rpc) { + unawaited(_rpc.listen()); + } + + /// Connects the API using 'postMessage'. This is only available when running + /// on web and embedded inside VS Code. + factory DartApi.postMessage() { + final postMessageController = StreamController(); + postMessageController.stream.listen((message) => postMessage(message, '*')); + final channel = StreamChannel( + onPostMessage.map((event) => event.data), + postMessageController, + ); + return DartApi.rpc(json_rpc_2.Peer.withoutJson(channel)); + } + + /// Connects the API over the provided WebSocket. + factory DartApi.webSocket(WebSocketChannel socket) { + return DartApi.rpc(json_rpc_2.Peer(socket.cast())); + } + + final json_rpc_2.Peer _rpc; + + /// Access to APIs related to VS Code, such as executing VS Code commands or + /// interacting with the Dart/Flutter extensions. + final VsCodeApi vsCode; + + void dispose() { + unawaited(_rpc.close()); + } +} + +/// Base class for the different APIs that may be available. +abstract base class ToolApi { + ToolApi(this.rpc); + + final json_rpc_2.Peer rpc; + + String get apiName; + + /// Checks whether this API is available. + /// + /// Calls to any other API should only be made if and when this [Future] + /// completes with `true`. + late final Future isAvailable = + _sendRequest('checkAvailable').catchError((_) => false); + + 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. + Stream> events(String name) { + final streamController = StreamController>.broadcast(); + rpc.registerMethod('$apiName.$name', (json_rpc_2.Parameters parameters) { + streamController.add(parameters.asMap.cast()); + }); + return streamController.stream; + } +} + +final class VsCodeApi extends ToolApi { + // TODO(dantup): Consider code-generation because Dart-Code and DevTools will + // both have implementations of this API (although in Dart + TypeScript). + VsCodeApi(super.rpc); + + @override + final apiName = 'vsCode'; + + late final Stream devicesChanged = + events('devicesChanged').map(VsCodeDevicesEvent.fromJson); + + Future executeCommand(String command, [List? arguments]) { + return _sendRequest( + 'executeCommand', + {'command': command, 'arguments': arguments}, + ); + } + + Future selectDevice(String id) { + return _sendRequest( + 'selectDevice', + {'id': id}, + ); + } +} + +class VsCodeDevice { + VsCodeDevice({required this.id}); + + VsCodeDevice.fromJson(Map json) + : this(id: json['id'] as String); + + final String id; + + Map toJson() => {'id': id}; +} + +class VsCodeDevicesEvent { + VsCodeDevicesEvent({required this.selectedDeviceId, required this.devices}); + + VsCodeDevicesEvent.fromJson(Map json) + : this( + selectedDeviceId: json['selectedDeviceId'] as String?, + devices: (json['devices'] as List) + .cast>() + .map(VsCodeDevice.fromJson) + .toList(), + ); + + final String? selectedDeviceId; + final List devices; + + Map toJson() => { + 'selectedDeviceId': selectedDeviceId, + 'devices': devices.map((device) => device.toJson()).toList(), + }; +} 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 72b3053aa161..0b002df2c0c2 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,102 @@ // 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:flutter/material.dart'; +import '../../../devtools_app.dart'; import '../../shared/feature_flags.dart'; +import '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 StatefulWidget { + const VsCodeFlutterPanel(this.api, {super.key}); + + final DartApi api; -class VsCodeFlutterPanel extends StatelessWidget { - const VsCodeFlutterPanel({super.key}); + @override + State createState() => _VsCodeFlutterPanelState(); +} +class _VsCodeFlutterPanelState extends State { @override Widget build(BuildContext context) { assert(FeatureFlags.vsCodeSidebarTooling); - return const Center( - child: Text('TODO: a panel for flutter actions in VS Code'), + + final api = widget.api; + + return Expanded( + child: Column( + children: [ + const Text(''), + FutureBuilder( + future: api.vsCode.isAvailable, + builder: (context, snapshot) => switch (snapshot.data) { + true => _VsCodeConnectedPanel(api.vsCode), + false => const Text('Unable to connect to VS Code'), + null => const CenteredCircularProgressIndicator(), + }, + ), + ], + ), + ); + } +} + +/// The panel shown once we know VS Code is available (the host has responded to +/// the `vsCode.isAvailable` request). +class _VsCodeConnectedPanel extends StatefulWidget { + const _VsCodeConnectedPanel(this.api, {super.key}); + + final VsCodeApi api; + + @override + State<_VsCodeConnectedPanel> createState() => _VsCodeConnectedPanelState(); +} + +class _VsCodeConnectedPanelState extends State<_VsCodeConnectedPanel> { + @override + Widget build(BuildContext context) { + return Column( + children: [ + ElevatedButton( + onPressed: () => + unawaited(widget.api.executeCommand('flutter.createProject')), + child: const Text('New Project'), + ), + StreamBuilder( + stream: widget.api.devicesChanged, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Text(''); + } + final deviceEvent = snapshot.data!; + return Table( + children: [ + for (final device in deviceEvent.devices) + TableRow( + children: [ + TextButton( + child: Text(device.id), + onPressed: () => + unawaited(widget.api.selectDevice(device.id)), + ), + Text( + device.id == deviceEvent.selectedDeviceId + ? '(selected)' + : '', + ), + ], + ), + ], + ); + }, + ), + ], ); } } diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel_mock.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel_mock.dart new file mode 100644 index 000000000000..7ed68273170f --- /dev/null +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel_mock.dart @@ -0,0 +1,140 @@ +// 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:flutter/material.dart'; + +import '../../../devtools_app.dart'; +import 'flutter_panel.dart'; +import '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 [MockDartApi] to allow triggering events that would +/// normally be fired by the IDE and also shows a log of recent requests. +class VsCodeFlutterPanelMock extends StatefulWidget { + const VsCodeFlutterPanelMock({super.key}); + + @override + State createState() => _VsCodeFlutterPanelMockState(); +} + +class _VsCodeFlutterPanelMockState extends State { + /// The mock API to interact with. + final api = MockDartApi(); + + /// 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) { + return Split( + axis: Axis.horizontal, + initialFractions: const [0.2, 0.8], + 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: const Color(0xFF333333), + child: Image.memory(sidebarImageBytes), + ), + ), + VsCodeFlutterPanel(api), + ], + ), + Split( + axis: Axis.vertical, + initialFractions: const [0.5, 0.5], + minSizes: const [200, 200], + children: [ + Container( + color: const Color(0xFF282828), + 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: const Color(0xFF222222), + 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, + ), + ], + ); + }, + ), + ), + ], + ), + ], + ); + } +} diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/mock_api.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/mock_api.dart new file mode 100644 index 000000000000..d46031fddda0 --- /dev/null +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/mock_api.dart @@ -0,0 +1,133 @@ +// 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:json_rpc_2/json_rpc_2.dart' as json_rpc_2; +import 'package:stream_channel/stream_channel.dart'; + +import 'api.dart'; + +/// A [DartApi] 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 MockDartApi extends DartApi { + factory MockDartApi() { + // 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 MockDartApi._( + client: clientPeer, + server: serverPeer, + log: log.stream, + ); + } + + MockDartApi._({ + 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.checkAvailable', () async => true); + 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 = [ + VsCodeDevice(id: 'device 1'), + VsCodeDevice(id: 'device 2'), + ]; + + /// The current set of devices being presented to the embedded panel. + final List _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 executeCommand(json_rpc_2.Parameters parameters) async { + final params = parameters.asMap; + final command = params['command']; + switch (command) { + case 'flutter.createProject': + return null; + default: + throw 'Unknown command $command'; + } + } + + /// Simulates changing the selected device to [id] as requested by the + /// embedded panel. + void selectDevice(json_rpc_2.Parameters parameters) { + final params = parameters.asMap; + _selectedDeviceId = params['id'] as String?; + _sendDevicesChanged(); + } + + /// 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', + VsCodeDevicesEvent( + devices: _devices, + selectedDeviceId: _selectedDeviceId, + ).toJson(), + ); + } +} diff --git a/packages/devtools_app/pubspec.yaml b/packages/devtools_app/pubspec.yaml index 8ef1cc9cb08b..cbfb5c54a31c 100644 --- a/packages/devtools_app/pubspec.yaml +++ b/packages/devtools_app/pubspec.yaml @@ -44,6 +44,7 @@ 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 mime: ^1.0.0 @@ -56,6 +57,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