diff --git a/packages/devtools_app/lib/src/app.dart b/packages/devtools_app/lib/src/app.dart index 2dd1abcd019..cbee5fa9876 100644 --- a/packages/devtools_app/lib/src/app.dart +++ b/packages/devtools_app/lib/src/app.dart @@ -9,6 +9,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'example/conditional_screen.dart'; +import 'extensions/extension_model.dart'; +import 'extensions/ui/extension_screen.dart'; import 'framework/framework_core.dart'; import 'framework/home_screen.dart'; import 'framework/initializer.dart'; @@ -66,13 +68,13 @@ const showVmDeveloperMode = false; @immutable class DevToolsApp extends StatefulWidget { const DevToolsApp( - this.screens, + this.originalScreens, this.analyticsController, { super.key, this.sampleData = const [], }); - final List screens; + final List originalScreens; final AnalyticsController analyticsController; final List sampleData; @@ -87,7 +89,18 @@ class DevToolsApp extends StatefulWidget { // TODO(https://github.com/flutter/devtools/issues/1146): Introduce tests that // navigate the full app. class DevToolsAppState extends State with AutoDisposeMixin { - List get _screens => widget.screens.map((s) => s.screen).toList(); + List get _screens => [ + ..._originalScreens, + if (FeatureFlags.devToolsExtensions) ..._extensionScreens, + ]; + + List get _originalScreens => + widget.originalScreens.map((s) => s.screen).toList(); + + Iterable get _extensionScreens => + extensionService.availableExtensions.value.map( + (e) => DevToolsScreen(ExtensionScreen(e)).screen, + ); bool get isDarkThemeEnabled => _isDarkThemeEnabled; bool _isDarkThemeEnabled = true; @@ -115,6 +128,14 @@ class DevToolsAppState extends State with AutoDisposeMixin { unawaited(ga.setupDimensions()); + if (FeatureFlags.devToolsExtensions) { + addAutoDisposeListener(extensionService.availableExtensions, () { + setState(() { + _clearCachedRoutes(); + }); + }); + } + addAutoDisposeListener(serviceManager.isolateManager.mainIsolate, () { setState(() { _clearCachedRoutes(); @@ -215,10 +236,11 @@ class DevToolsAppState extends State with AutoDisposeMixin { Widget scaffoldBuilder() { // Force regeneration of visible screens when VM developer mode is - // enabled. - return ValueListenableBuilder( - valueListenable: preferences.vmDeveloperModeEnabled, - builder: (_, __, child) { + // enabled and when the list of available extensions change. + return DualValueListenableBuilder>( + firstListenable: preferences.vmDeveloperModeEnabled, + secondListenable: extensionService.availableExtensions, + builder: (_, __, ___, child) { final screens = _visibleScreens() .where((p) => embed && page != null ? p.screenId == page : true) .where((p) => !hide.contains(p.screenId)) @@ -257,8 +279,7 @@ class DevToolsAppState extends State with AutoDisposeMixin { Map get pages { return _routes ??= { homeScreenId: _buildTabbedPage, - for (final screen in widget.screens) - screen.screen.screenId: _buildTabbedPage, + for (final screen in _screens) screen.screenId: _buildTabbedPage, snapshotScreenId: (_, __, args, ___) { final snapshotArgs = OfflineDataArguments.fromArgs(args); final embed = isEmbedded(args); @@ -305,7 +326,9 @@ class DevToolsAppState extends State with AutoDisposeMixin { List _visibleScreens() => _screens.where(shouldShowScreen).toList(); List _providedControllers({bool offline = false}) { - return widget.screens + // We use [widget.originalScreens] here instead of [_screens] because + // extension screens do not provide a controller through this mechanism. + return widget.originalScreens .where( (s) => s.providesController && (offline ? s.supportsOffline : true), ) @@ -360,10 +383,10 @@ class DevToolsAppState extends State with AutoDisposeMixin { } /// DevTools screen wrapper that is responsible for creating and providing the -/// screen's controller, as well as enabling offline support. +/// screen's controller, if one exists, as well as enabling offline support. /// /// [C] corresponds to the type of the screen's controller, which is created by -/// [createController] and provided by [controllerProvider]. +/// [createController] or provided by [controllerProvider]. class DevToolsScreen { const DevToolsScreen( this.screen, { @@ -459,10 +482,7 @@ List defaultScreens({ List sampleData = const [], }) { return devtoolsScreens ??= [ - DevToolsScreen( - HomeScreen(sampleData: sampleData), - createController: (_) {}, - ), + DevToolsScreen(HomeScreen(sampleData: sampleData)), DevToolsScreen( InspectorScreen(), createController: (_) => InspectorController( @@ -503,10 +523,7 @@ List defaultScreens({ LoggingScreen(), createController: (_) => LoggingController(), ), - DevToolsScreen( - ProviderScreen(), - createController: (_) {}, - ), + DevToolsScreen(ProviderScreen()), DevToolsScreen( AppSizeScreen(), createController: (_) => AppSizeController(), diff --git a/packages/devtools_app/lib/src/extensions/extension_model.dart b/packages/devtools_app/lib/src/extensions/extension_model.dart new file mode 100644 index 00000000000..a7c3b36aae2 --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/extension_model.dart @@ -0,0 +1,83 @@ +// 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:flutter/material.dart'; + +// TODO(kenz): share this with devtools_server so that we do not duplicate. + +/// Describes an extension that can be dynamically loaded into a custom screen +/// in DevTools. +class DevToolsExtensionConfig { + DevToolsExtensionConfig._({ + required this.name, + required this.path, + required this.issueTrackerLink, + required this.version, + required this.materialIconCodePoint, + }); + + factory DevToolsExtensionConfig.parse(Map json) { + // Defaults to the code point for [Icons.extensions_outlined] if null. + final codePoint = json[materialIconCodePointKey] as int? ?? 0xf03f; + return DevToolsExtensionConfig._( + name: json[nameKey]! as String, + path: json[pathKey]! as String, + issueTrackerLink: json[issueTrackerKey]! as String, + version: json[versionKey]! as String, + materialIconCodePoint: codePoint, + ); + } + + static const nameKey = 'name'; + static const pathKey = 'path'; + static const issueTrackerKey = 'issueTracker'; + static const versionKey = 'version'; + static const materialIconCodePointKey = 'materialIconCodePoint'; + + final String name; + final String path; + final String issueTrackerLink; + final String version; + final int materialIconCodePoint; + + Map toJson() => { + nameKey: name, + pathKey: path, + issueTrackerKey: issueTrackerLink, + versionKey: version, + materialIconCodePointKey: materialIconCodePoint, + }; +} + +extension ExtensionConfigExtension on DevToolsExtensionConfig { + IconData get icon => IconData( + materialIconCodePoint, + fontFamily: 'MaterialIcons', + ); +} + +// TODO(kenz): remove these once the DevTools extensions feature has shipped. +final List debugPlugins = [ + DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'foo', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '1.0.0', + DevToolsExtensionConfig.pathKey: '/path/to/foo', + }), + DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '2.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, + DevToolsExtensionConfig.pathKey: '/path/to/bar', + }), + DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'provider', + DevToolsExtensionConfig.issueTrackerKey: + 'https://github.com/rrousselGit/provider/issues', + DevToolsExtensionConfig.versionKey: '3.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe50a, + DevToolsExtensionConfig.pathKey: '/path/to/provider', + }), +]; diff --git a/packages/devtools_app/lib/src/extensions/extension_service.dart b/packages/devtools_app/lib/src/extensions/extension_service.dart new file mode 100644 index 00000000000..8bef25ca755 --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/extension_service.dart @@ -0,0 +1,30 @@ +// 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:flutter/foundation.dart'; + +import '../shared/globals.dart'; +import '../shared/primitives/auto_dispose.dart'; +import 'extension_model.dart'; + +class ExtensionService extends DisposableController + with AutoDisposeControllerMixin { + ValueListenable> get availableExtensions => + _availableExtensions; + final _availableExtensions = ValueNotifier>([]); + + void initialize() { + addAutoDisposeListener(serviceManager.connectedState, () { + _refreshAvailablePlugins(); + }); + } + + // TODO(kenz): actually look up the available plugins from the server, based + // on the root path(s) from the available isolate(s). + int _count = 0; + void _refreshAvailablePlugins() { + _availableExtensions.value = + debugPlugins.sublist(0, _count++ % (debugPlugins.length + 1)); + } +} diff --git a/packages/devtools_app/lib/src/extensions/ui/extension_screen.dart b/packages/devtools_app/lib/src/extensions/ui/extension_screen.dart new file mode 100644 index 00000000000..f8d15a74c21 --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/ui/extension_screen.dart @@ -0,0 +1,32 @@ +// 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:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../shared/primitives/listenable.dart'; +import '../../shared/screen.dart'; +import '../extension_model.dart'; + +class ExtensionScreen extends Screen { + ExtensionScreen(this.extensionConfig) + : super.conditional( + // TODO(kenz): we may need to ensure this is a unique id. + id: extensionConfig.name, + title: extensionConfig.name, + icon: extensionConfig.icon, + // TODO(kenz): support static DevTools extensions. + requiresConnection: true, + ); + + final DevToolsExtensionConfig extensionConfig; + + @override + ValueListenable get showIsolateSelector => + const FixedValueListenable(true); + + @override + Widget build(BuildContext context) => + Text('TODO: iFrame for ${extensionConfig.name}'); +} diff --git a/packages/devtools_app/lib/src/framework/framework_core.dart b/packages/devtools_app/lib/src/framework/framework_core.dart index 74ef37e197d..19ba0cf5cf7 100644 --- a/packages/devtools_app/lib/src/framework/framework_core.dart +++ b/packages/devtools_app/lib/src/framework/framework_core.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:logging/logging.dart'; import '../../devtools.dart' as devtools show version; +import '../extensions/extension_service.dart'; import '../screens/debugger/breakpoint_manager.dart'; import '../service/service.dart'; import '../service/service_manager.dart'; @@ -40,6 +41,7 @@ class FrameworkCore { setGlobal(BannerMessagesController, BannerMessagesController()); setGlobal(BreakpointManager, BreakpointManager()); setGlobal(EvalService, EvalService()); + setGlobal(ExtensionService, ExtensionService()); } static void init() { diff --git a/packages/devtools_app/lib/src/framework/scaffold.dart b/packages/devtools_app/lib/src/framework/scaffold.dart index 94e00f3fc17..202563f66c6 100644 --- a/packages/devtools_app/lib/src/framework/scaffold.dart +++ b/packages/devtools_app/lib/src/framework/scaffold.dart @@ -142,8 +142,7 @@ class DevToolsScaffoldState extends State widget.screens.indexOf(oldWidget.screens[_tabController!.index]); } // Create a new tab controller to reflect the changed tabs. - _setupTabController(); - _tabController!.index = newIndex; + _setupTabController(startingIndex: newIndex); } else if (widget.screens[_tabController!.index].screenId != widget.page) { // If the page changed (eg. the route was modified by pressing back in the // browser), animate to the new one. @@ -170,9 +169,13 @@ class DevToolsScaffoldState extends State super.dispose(); } - void _setupTabController() { + void _setupTabController({int startingIndex = 0}) { _tabController?.dispose(); - _tabController = TabController(length: widget.screens.length, vsync: this); + _tabController = TabController( + initialIndex: startingIndex, + length: widget.screens.length, + vsync: this, + ); if (widget.page != null) { final initialIndex = diff --git a/packages/devtools_app/lib/src/service/service_manager.dart b/packages/devtools_app/lib/src/service/service_manager.dart index 54e13fc3b79..5cfaddb2153 100644 --- a/packages/devtools_app/lib/src/service/service_manager.dart +++ b/packages/devtools_app/lib/src/service/service_manager.dart @@ -17,6 +17,7 @@ import '../shared/connected_app.dart'; import '../shared/console/console_service.dart'; import '../shared/diagnostics/inspector_service.dart'; import '../shared/error_badge_manager.dart'; +import '../shared/feature_flags.dart'; import '../shared/globals.dart'; import '../shared/primitives/utils.dart'; import '../shared/title.dart'; @@ -321,6 +322,10 @@ class ServiceConnectionManager { return; } + if (FeatureFlags.devToolsExtensions) { + extensionService.initialize(); + } + _connectedState.value = const ConnectedState(true); } diff --git a/packages/devtools_app/lib/src/shared/feature_flags.dart b/packages/devtools_app/lib/src/shared/feature_flags.dart index 55e946c24c9..6117c85e477 100644 --- a/packages/devtools_app/lib/src/shared/feature_flags.dart +++ b/packages/devtools_app/lib/src/shared/feature_flags.dart @@ -69,6 +69,11 @@ abstract class FeatureFlags { /// https://github.com/flutter/devtools/issues/6013 static bool deepLinkValidation = enableExperiments; + /// Flag to enable DevTools extensions. + /// + /// https://github.com/flutter/devtools/issues/1632 + static bool devToolsExtensions = enableExperiments; + /// Stores a map of all the feature flags for debugging purposes. /// /// When adding a new flag, you are responsible for adding it to this map as diff --git a/packages/devtools_app/lib/src/shared/globals.dart b/packages/devtools_app/lib/src/shared/globals.dart index 9116331374b..d0e5610452d 100644 --- a/packages/devtools_app/lib/src/shared/globals.dart +++ b/packages/devtools_app/lib/src/shared/globals.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import '../extensions/extension_service.dart'; import '../screens/debugger/breakpoint_manager.dart'; import '../service/service_manager.dart'; import '../shared/banner_messages.dart'; @@ -60,6 +61,9 @@ BreakpointManager get breakpointManager => EvalService get evalService => globals[EvalService] as EvalService; +ExtensionService get extensionService => + globals[ExtensionService] as ExtensionService; + void setGlobal(Type clazz, Object instance) { globals[clazz] = instance; }