Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the extension settings dialog #6138

Merged
merged 6 commits into from
Aug 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/devtools_app/lib/devtools_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// found in the LICENSE file.

export 'src/app.dart';
export 'src/extensions/extension_service.dart';
export 'src/framework/app_bar.dart';
export 'src/framework/home_screen.dart';
export 'src/framework/notifications_view.dart';
Expand Down
3 changes: 3 additions & 0 deletions packages/devtools_app/lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ class DevToolsAppState extends State<DevToolsApp> with AutoDisposeMixin {
List<Screen> get _originalScreens =>
widget.originalScreens.map((s) => s.screen).toList();

/// TODO(kenz): use [extensionService.visibleExtensions] instead of
/// [extensionService.availableExtensions] and verify tabs are added / removed
/// propertly based on the enabled state of extensions.
Iterable<Screen> get _extensionScreens =>
extensionService.availableExtensions.value.map(
(e) => DevToolsScreen<void>(ExtensionScreen(e)).screen,
Expand Down
83 changes: 77 additions & 6 deletions packages/devtools_app/lib/src/extensions/extension_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,104 @@ import '../shared/primitives/auto_dispose.dart';

class ExtensionService extends DisposableController
with AutoDisposeControllerMixin {
/// All the DevTools extensions that are available for the connected
/// application, regardless of whether they have been enabled or disabled
/// by the user.
ValueListenable<List<DevToolsExtensionConfig>> get availableExtensions =>
_availableExtensions;
final _availableExtensions = ValueNotifier<List<DevToolsExtensionConfig>>([]);

/// DevTools extensions that are visible in their own DevTools screen (i.e.
/// extensions that have not been manually disabled by the user).
ValueListenable<List<DevToolsExtensionConfig>> get visibleExtensions =>
_visibleExtensions;
final _visibleExtensions = ValueNotifier<List<DevToolsExtensionConfig>>([]);
bkonyi marked this conversation as resolved.
Show resolved Hide resolved

/// Returns the [ValueListenable] that stores the [ExtensionEnabledState] for
/// the DevTools Extension with [extensionName].
ValueListenable<ExtensionEnabledState> enabledStateListenable(
String extensionName,
) {
return _extensionEnabledStates.putIfAbsent(
extensionName.toLowerCase(),
() => ValueNotifier<ExtensionEnabledState>(
ExtensionEnabledState.none,
),
);
}

final _extensionEnabledStates =
<String, ValueNotifier<ExtensionEnabledState>>{};

Future<void> initialize() async {
await maybeRefreshExtensions();
await _maybeRefreshExtensions();
addAutoDisposeListener(
serviceManager.connectedState,
maybeRefreshExtensions,
_maybeRefreshExtensions,
);

// TODO(kenz): we should also refresh the available extensions on some event
// from the analysis server that is watching the
// .dart_tool/package_config.json file for changes.
}

Future<void> maybeRefreshExtensions() async {
Future<void> _maybeRefreshExtensions() async {
final appRootPath = await _connectedAppRootPath();
if (appRootPath == null) return;

_availableExtensions.value =
await server.refreshAvailableExtensions(appRootPath)
..sort();
await _refreshExtensionEnabledStates();
}

Future<void> _refreshExtensionEnabledStates() async {
final appRootPath = await _connectedAppRootPath();
if (appRootPath == null) return;

final visible = <DevToolsExtensionConfig>[];
for (final extension in _availableExtensions.value) {
final stateFromOptionsFile = await server.extensionEnabledState(
rootPath: appRootPath,
extensionName: extension.name,
);
final stateNotifier = _extensionEnabledStates.putIfAbsent(
extension.name,
() => ValueNotifier<ExtensionEnabledState>(stateFromOptionsFile),
);
stateNotifier.value = stateFromOptionsFile;
if (stateFromOptionsFile != ExtensionEnabledState.disabled) {
visible.add(extension);
}
}
// [_visibleExtensions] should be set last so that all extension states in
// [_extensionEnabledStates] are updated by the time we notify listeners of
// [visibleExtensions]. It is not necessary to sort [visible] because
// [_availableExtensions] is already sorted.
_visibleExtensions.value = visible;
}

/// Sets the enabled state for [extension].
Future<void> setExtensionEnabledState(
DevToolsExtensionConfig extension, {
required bool enable,
}) async {
final appRootPath = await _connectedAppRootPath();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this value be cached? Maybe for the duration of the VM service connection?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a TODO to consider doing this in a follow up.

if (appRootPath != null) {
kenzieschmoll marked this conversation as resolved.
Show resolved Hide resolved
_availableExtensions.value =
await server.refreshAvailableExtensions(appRootPath);
await server.extensionEnabledState(
rootPath: appRootPath,
extensionName: extension.name,
enable: enable,
);
await _refreshExtensionEnabledStates();
}
}
}

// TODO(kenz): consider caching this for the duration of the VM service
// connection.
Future<String?> _connectedAppRootPath() async {
var fileUri = await serviceManager.rootLibraryForSelectedIsolate();
var fileUri = await serviceManager.rootLibraryForMainIsolate();
if (fileUri == null) return null;

// TODO(kenz): for robustness, consider sending the root library uri to the
Expand Down
205 changes: 205 additions & 0 deletions packages/devtools_app/lib/src/extensions/extension_settings.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// 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_shared/devtools_extensions.dart';
import 'package:flutter/material.dart';

import '../shared/analytics/analytics.dart' as ga;
import '../shared/analytics/constants.dart' as gac;
import '../shared/common_widgets.dart';
import '../shared/dialogs.dart';
import '../shared/globals.dart';
import '../shared/theme.dart';
import '../shared/utils.dart';

/// A [ScaffoldAction] that, when clicked, will open a dialog menu for
/// managing DevTools extension states.
class ExtensionSettingsAction extends ScaffoldAction {
ExtensionSettingsAction({super.key, Color? color})
: super(
icon: Icons.extension_outlined,
tooltip: 'DevTools Extensions',
color: color,
onPressed: (context) {
unawaited(
showDialog(
context: context,
builder: (context) => const ExtensionSettingsDialog(),
),
);
},
);
}

class ExtensionSettingsDialog extends StatelessWidget {
const ExtensionSettingsDialog({super.key});

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final availableExtensions = extensionService.availableExtensions.value;
// This dialog needs a fixed height because it contains a scrollable list.
final dialogHeight = scaleByFontFactor(300.0);
kenzieschmoll marked this conversation as resolved.
Show resolved Hide resolved
return DevToolsDialog(
title: const DialogTitleText('DevTools Extensions'),
content: SizedBox(
width: defaultDialogWidth,
height: dialogHeight,
child: Column(
children: [
const Text(
'Extensions are provided by the pub packages used in your '
'application. When activated, the tools provided by these '
'extensions will be available in a separate DevTools tab.',
),
const SizedBox(height: defaultSpacing),
Expanded(
child: availableExtensions.isEmpty
? Center(
child: Text(
'No extensions available.',
style: theme.textTheme.bodyLarge!.copyWith(
color: theme.colorScheme.subtleTextColor,
),
),
)
: _ExtensionsList(extensions: availableExtensions),
),
],
),
),
actions: const [
DialogCloseButton(),
],
);
}
}

class _ExtensionsList extends StatefulWidget {
const _ExtensionsList({required this.extensions});

final List<DevToolsExtensionConfig> extensions;

@override
State<_ExtensionsList> createState() => __ExtensionsListState();
}

class __ExtensionsListState extends State<_ExtensionsList> {
late ScrollController scrollController;

@override
void initState() {
super.initState();
scrollController = ScrollController();
}

@override
void dispose() {
scrollController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scrollbar(
controller: scrollController,
thumbVisibility: true,
child: ListView.builder(
controller: scrollController,
itemCount: widget.extensions.length,
itemBuilder: (context, index) => ExtensionSetting(
extension: widget.extensions[index],
),
),
);
}
}

@visibleForTesting
class ExtensionSetting extends StatelessWidget {
const ExtensionSetting({super.key, required this.extension});

final DevToolsExtensionConfig extension;

@override
Widget build(BuildContext context) {
final buttonStates = [
(
title: 'Enabled',
isSelected: (ExtensionEnabledState state) =>
state == ExtensionEnabledState.enabled,
onPressed: () {
ga.select(
gac.extensionSettingsId,
gac.extensionEnable(extension.name.toLowerCase()),
);
unawaited(
extensionService.setExtensionEnabledState(
extension,
enable: true,
),
);
},
),
(
title: 'Disabled',
isSelected: (ExtensionEnabledState state) =>
state == ExtensionEnabledState.disabled,
onPressed: () {
ga.select(
gac.extensionSettingsId,
gac.extensionDisable(extension.name.toLowerCase()),
);
unawaited(
extensionService.setExtensionEnabledState(
extension,
enable: false,
),
);
},
),
];
final theme = Theme.of(context);
final extensionName = extension.name.toLowerCase();
return ValueListenableBuilder(
valueListenable: extensionService.enabledStateListenable(extensionName),
builder: (context, enabledState, _) {
return Padding(
padding: const EdgeInsets.only(bottom: denseSpacing),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'package:$extensionName',
kenzieschmoll marked this conversation as resolved.
Show resolved Hide resolved
overflow: TextOverflow.ellipsis,
style: theme.fixedFontStyle,
),
DevToolsToggleButtonGroup(
fillColor: theme.colorScheme.primary,
selectedColor: theme.colorScheme.onPrimary,
onPressed: (index) => buttonStates[index].onPressed(),
selectedStates: buttonStates
.map((option) => option.isSelected(enabledState))
.toList(),
children: buttonStates
.map(
(option) => Padding(
padding: const EdgeInsets.symmetric(
vertical: densePadding,
horizontal: denseSpacing,
),
child: Text(option.title),
),
)
.toList(),
),
],
),
);
},
);
}
}
4 changes: 4 additions & 0 deletions packages/devtools_app/lib/src/framework/scaffold.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../app.dart';
import '../extensions/extension_settings.dart';
import '../screens/debugger/debugger_screen.dart';
import '../shared/analytics/prompt.dart';
import '../shared/banner_messages.dart';
import '../shared/common_widgets.dart';
import '../shared/config_specific/drag_and_drop/drag_and_drop.dart';
import '../shared/config_specific/import_export/import_export.dart';
import '../shared/console/widgets/console_pane.dart';
import '../shared/feature_flags.dart';
import '../shared/framework_controller.dart';
import '../shared/globals.dart';
import '../shared/primitives/auto_dispose.dart';
Expand Down Expand Up @@ -62,6 +64,8 @@ class DevToolsScaffold extends StatefulWidget {
}) =>
[
OpenSettingsAction(color: color),
if (FeatureFlags.devToolsExtensions)
ExtensionSettingsAction(color: color),
ReportFeedbackButton(color: color),
if (!isEmbedded) ImportToolbarAction(color: color),
OpenAboutAction(color: color),
Expand Down
10 changes: 5 additions & 5 deletions packages/devtools_app/lib/src/service/service_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -540,18 +540,18 @@ class ServiceConnectionManager {
return libraryUriAvailableNow(uri);
}

Future<String?> rootLibraryForSelectedIsolate() async {
Future<String?> rootLibraryForMainIsolate() async {
if (!connectedState.value.connected) return null;

final selectedIsolateRef = isolateManager.mainIsolate.value;
if (selectedIsolateRef == null) return null;
final mainIsolateRef = isolateManager.mainIsolate.value;
if (mainIsolateRef == null) return null;

final isolateState = isolateManager.isolateState(selectedIsolateRef);
final isolateState = isolateManager.isolateState(mainIsolateRef);
await isolateState.waitForIsolateLoad();
final rootLib = isolateState.rootInfo!.library;
if (rootLib == null) return null;

final selectedIsolateRefId = selectedIsolateRef.id!;
final selectedIsolateRefId = mainIsolateRef.id!;
await resolvedUriManager.fetchFileUris(selectedIsolateRefId, [rootLib]);
return resolvedUriManager.lookupFileUri(
selectedIsolateRefId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ const discordLink = 'discord';

// Extension screens UX actions.
const extensionScreenId = 'devtoolsExtension';
const extensionSettingsId = 'devtoolsExtensionSettings';
String extensionFeedback(String name) => 'extensionFeedback-$name';
String extensionEnable(String name) => 'extensionEnable-$name';
String extensionDisable(String name) => 'extensionDisable-$name';

// Inspector UX actions:
const refresh = 'refresh';
Expand Down
Loading