diff --git a/packages/devtools_app/lib/src/extensions/extension_service.dart b/packages/devtools_app/lib/src/extensions/extension_service.dart index 1ef732369f5..826ff120908 100644 --- a/packages/devtools_app/lib/src/extensions/extension_service.dart +++ b/packages/devtools_app/lib/src/extensions/extension_service.dart @@ -11,6 +11,19 @@ import '../shared/server/server.dart' as server; class ExtensionService extends DisposableController with AutoDisposeControllerMixin { + ExtensionService({this.fixedAppRootPath}); + + /// The fixed (unchanging) root path for the application this + /// [ExtensionService] will manage DevTools extensions for. + /// + /// When null, the root path will instead be calculated from the + /// [serviceManager]'s currently connected app. See [_initRootPath]. + final String? fixedAppRootPath; + + /// The root path for the Dart / Flutter application this [ExtensionService] + /// will manage DevTools extensions for. + String? _rootPath; + /// All the DevTools extensions that are available for the connected /// application, regardless of whether they have been enabled or disabled /// by the user. @@ -45,31 +58,43 @@ class ExtensionService extends DisposableController >{}; Future initialize() async { + await _initRootPath(); await _maybeRefreshExtensions(); - addAutoDisposeListener( - serviceConnection.serviceManager.connectedState, - () async { - if (serviceConnection.serviceManager.connectedState.value.connected) { - await _maybeRefreshExtensions(); - } else { - _reset(); - } - }, - ); - // TODO(https://github.com/flutter/flutter/issues/134470): refresh on - // hot reload and hot restart events instead. - addAutoDisposeListener( - serviceConnection.serviceManager.isolateManager.mainIsolate, - () async { - if (serviceConnection.serviceManager.isolateManager.mainIsolate.value != - null) { - await _maybeRefreshExtensions(); - } else { - _reset(); - } - }, - ); + cancelListeners(); + + // We only need to add VM service manager related listeners when we are + // interacting with the currently connected app (i.e. when + // [fixedAppRootPath] is null). + if (fixedAppRootPath == null) { + addAutoDisposeListener( + serviceConnection.serviceManager.connectedState, + () async { + if (serviceConnection.serviceManager.connectedState.value.connected) { + await _initRootPath(); + await _maybeRefreshExtensions(); + } else { + _reset(); + } + }, + ); + + // TODO(https://github.com/flutter/flutter/issues/134470): refresh on + // hot reload and hot restart events instead. + addAutoDisposeListener( + serviceConnection.serviceManager.isolateManager.mainIsolate, + () async { + if (serviceConnection + .serviceManager.isolateManager.mainIsolate.value != + null) { + await _initRootPath(); + await _maybeRefreshExtensions(); + } else { + _reset(); + } + }, + ); + } addAutoDisposeListener( preferences.devToolsExtensions.showOnlyEnabledExtensions, @@ -83,21 +108,23 @@ class ExtensionService extends DisposableController // .dart_tool/package_config.json file for changes. } + Future _initRootPath() async { + _rootPath = fixedAppRootPath ?? await _connectedAppRootPath(); + } + Future _maybeRefreshExtensions() async { - final appRootPath = await _connectedAppRootPath(); - if (appRootPath == null) return; + if (_rootPath == null) return; _refreshInProgress.value = true; _availableExtensions.value = - await server.refreshAvailableExtensions(appRootPath) + await server.refreshAvailableExtensions(_rootPath!) ..sort(); await _refreshExtensionEnabledStates(); _refreshInProgress.value = false; } Future _refreshExtensionEnabledStates() async { - final appRootPath = await _connectedAppRootPath(); - if (appRootPath == null) return; + if (_rootPath == null) return; final onlyIncludeEnabled = preferences.devToolsExtensions.showOnlyEnabledExtensions.value; @@ -105,7 +132,7 @@ class ExtensionService extends DisposableController final visible = []; for (final extension in _availableExtensions.value) { final stateFromOptionsFile = await server.extensionEnabledState( - rootPath: appRootPath, + rootPath: _rootPath!, extensionName: extension.name, ); final stateNotifier = _extensionEnabledStates.putIfAbsent( @@ -133,18 +160,18 @@ class ExtensionService extends DisposableController DevToolsExtensionConfig extension, { required bool enable, }) async { - final appRootPath = await _connectedAppRootPath(); - if (appRootPath != null) { - await server.extensionEnabledState( - rootPath: appRootPath, - extensionName: extension.name, - enable: enable, - ); - await _refreshExtensionEnabledStates(); - } + if (_rootPath == null) return; + + await server.extensionEnabledState( + rootPath: _rootPath!, + extensionName: extension.name, + enable: enable, + ); + await _refreshExtensionEnabledStates(); } void _reset() { + _rootPath = null; _availableExtensions.value = []; _visibleExtensions.value = []; _extensionEnabledStates.clear(); diff --git a/packages/devtools_app/lib/src/shared/development_helpers.dart b/packages/devtools_app/lib/src/shared/development_helpers.dart index 1d23d5a8d76..c0140bed9de 100644 --- a/packages/devtools_app/lib/src/shared/development_helpers.dart +++ b/packages/devtools_app/lib/src/shared/development_helpers.dart @@ -21,7 +21,8 @@ bool debugAnalytics = false; /// /// This flag should never be checked in with a value of true - this is covered /// by a test. -final debugDevToolsExtensions = _debugDevToolsExtensions || integrationTestMode; +final debugDevToolsExtensions = + _debugDevToolsExtensions || integrationTestMode || stagerMode; const _debugDevToolsExtensions = false; List debugHandleRefreshAvailableExtensions( diff --git a/packages/devtools_app/lib/src/shared/globals.dart b/packages/devtools_app/lib/src/shared/globals.dart index d272f424b48..4983b6433d3 100644 --- a/packages/devtools_app/lib/src/shared/globals.dart +++ b/packages/devtools_app/lib/src/shared/globals.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter/foundation.dart'; import '../extensions/extension_service.dart'; import '../screens/debugger/breakpoint_manager.dart'; @@ -67,3 +68,12 @@ bool _integrationTestMode = false; void setIntegrationTestMode() { _integrationTestMode = true; } + +/// Whether DevTools is being run as a stager app. +bool get stagerMode => _stagerMode; +bool _stagerMode = false; +void setStagerMode() { + if (!kReleaseMode) { + _stagerMode = true; + } +} diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/debug_sessions.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/debug_sessions.dart index 93937d124af..75fde56b67c 100644 --- a/packages/devtools_app/lib/src/standalone_ui/vs_code/debug_sessions.dart +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/debug_sessions.dart @@ -5,14 +5,15 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_shared/devtools_extensions.dart'; import 'package:flutter/material.dart'; import '../../extensions/extension_screen.dart'; +import '../../extensions/extension_service.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; import '../../shared/constants.dart'; import '../../shared/feature_flags.dart'; -import '../../shared/globals.dart'; import '../../shared/screen.dart'; import '../api/vs_code_api.dart'; @@ -115,7 +116,7 @@ class DebugSessions extends StatelessWidget { } } -class _DevToolsMenu extends StatelessWidget { +class _DevToolsMenu extends StatefulWidget { const _DevToolsMenu({ required this.api, required this.session, @@ -134,6 +135,46 @@ class _DevToolsMenu extends StatelessWidget { final bool isRelease; final bool isWeb; + @override + State<_DevToolsMenu> createState() => _DevToolsMenuState(); +} + +class _DevToolsMenuState extends State<_DevToolsMenu> { + ExtensionService? _extensionServiceForSession; + + @override + void initState() { + super.initState(); + unawaited(_initExtensions()); + } + + Future _initExtensions() async { + final sessionRootPath = widget.session.projectRootPath; + if (sessionRootPath != null) { + setState(() { + _extensionServiceForSession = + ExtensionService(fixedAppRootPath: sessionRootPath); + unawaited(_extensionServiceForSession!.initialize()); + }); + } + } + + @override + void didUpdateWidget(_DevToolsMenu oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.session != widget.session) { + _extensionServiceForSession?.dispose(); + unawaited(_initExtensions()); + } + } + + @override + void dispose() { + _extensionServiceForSession?.dispose(); + _extensionServiceForSession = null; + super.dispose(); + } + @override Widget build(BuildContext context) { final normalDirection = Directionality.of(context); @@ -144,13 +185,13 @@ class _DevToolsMenu extends StatelessWidget { Widget devToolsButton(ScreenMetaData screen) { final title = screen.title ?? screen.id; String? disabledReason; - if (isRelease) { + if (widget.isRelease) { disabledReason = 'Not available in release mode'; - } else if (screen.requiresFlutter && !isFlutter) { + } else if (screen.requiresFlutter && !widget.isFlutter) { disabledReason = 'Only available for Flutter applications'; - } else if (screen.requiresDebugBuild && !isDebug) { + } else if (screen.requiresDebugBuild && !widget.isDebug) { disabledReason = 'Only available in debug mode'; - } else if (screen.requiresDartVm && isWeb) { + } else if (screen.requiresDartVm && widget.isWeb) { disabledReason = 'Not available when running on the web'; } @@ -163,7 +204,7 @@ class _DevToolsMenu extends StatelessWidget { gac.VsCodeFlutterSidebar.id, gac.VsCodeFlutterSidebar.openDevToolsScreen(screen.id), ); - unawaited(api.openDevToolsPage(session.id, screen.id)); + unawaited(widget.api.openDevToolsPage(widget.session.id, screen.id)); }, ); } @@ -181,7 +222,15 @@ class _DevToolsMenu extends StatelessWidget { .where(_shouldIncludeScreen) .map(devToolsButton) .toList(), - const ExtensionScreenMenuItem(), + if (_extensionServiceForSession != null) + ValueListenableBuilder( + valueListenable: _extensionServiceForSession!.visibleExtensions, + builder: (context, extensions, _) { + return extensions.isEmpty + ? const SizedBox.shrink() + : ExtensionScreenMenuItem(extensions: extensions); + }, + ), ], builder: (context, controller, child) => IconButton( onPressed: () { @@ -252,34 +301,28 @@ class DevToolsScreenMenuItem extends StatelessWidget { } class ExtensionScreenMenuItem extends StatelessWidget { - const ExtensionScreenMenuItem({super.key}); + const ExtensionScreenMenuItem({super.key, required this.extensions}); + + final List extensions; @override Widget build(BuildContext context) { - // TODO: we are not getting the proper list of visible extensions here. - // More than likely, we will need to as the DevTools server for the list of - // available extensions directly based on the debug session's root path. - return ValueListenableBuilder( - valueListenable: extensionService.visibleExtensions, - builder: (context, extensions, _) { - return SubmenuButton( - menuStyle: const MenuStyle( - alignment: Alignment.centerLeft, - ), - menuChildren: extensions - .map( - (e) => DevToolsScreenMenuItem( - title: e.name, - icon: e.icon, - // TODO: this should open the extension screen in the browser, - // or if possible, in an embedded iFrame in VS code. - onPressed: () {}, - ), - ) - .toList(), - child: const Text('Extensions'), - ); - }, + return SubmenuButton( + menuStyle: const MenuStyle( + alignment: Alignment.centerLeft, + ), + menuChildren: extensions + .map( + (e) => DevToolsScreenMenuItem( + title: e.name, + icon: e.icon, + // TODO: this should open the extension screen in the browser, + // or if possible, in an embedded iFrame in VS code. + onPressed: () {}, + ), + ) + .toList(), + child: const Text('Extensions'), ); } } 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 index 87304cb2e77..4e7c6b8204b 100644 --- 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 @@ -6,12 +6,10 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/standalone_ui/vs_code/flutter_panel.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:devtools_test/devtools_test.dart'; import 'package:flutter/material.dart'; import 'package:stager/stager.dart'; import '../../../test_infra/test_data/dart_tooling_api/mock_api.dart'; -import '../../test_data/extensions.dart'; import 'vs_code_mock_editor.dart'; final _api = MockDartToolingApi(); @@ -59,10 +57,8 @@ class VsCodeScene extends Scene { @override Future setUp() async { + setStagerMode(); setGlobal(IdeTheme, IdeTheme()); - setGlobal( - ExtensionService, - await createMockExtensionServiceWithDefaults(testExtensions), - ); + setGlobal(PreferencesController, PreferencesController()); } } 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 index 21b6fc77226..88f4edbad60 100644 --- 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 @@ -220,7 +220,7 @@ class MockDartToolingApi extends DartToolingApiImpl { flutterMode: mode, flutterDeviceId: deviceId, debuggerType: 'Flutter', - projectRootPath: null, + projectRootPath: '/mock/root/path', ), ); _sendDebugSessionsChanged();