diff --git a/packages/devtools_app/lib/src/common_widgets.dart b/packages/devtools_app/lib/src/common_widgets.dart index 26d27ef90d3..b81f4996554 100644 --- a/packages/devtools_app/lib/src/common_widgets.dart +++ b/packages/devtools_app/lib/src/common_widgets.dart @@ -42,6 +42,9 @@ class PaddedDivider extends StatelessWidget { const PaddedDivider.thin({Key key}) : padding = const EdgeInsets.only(bottom: 4.0); + PaddedDivider.vertical({Key key, double padding = densePadding}) + : padding = EdgeInsets.symmetric(vertical: padding); + /// The padding to place around the divider. final EdgeInsets padding; @@ -119,6 +122,7 @@ class IconLabelButton extends StatelessWidget { this.elevatedButton = false, this.tooltip, this.tooltipPadding, + this.outlined = true, }) : assert((icon == null) != (imageIcon == null)), super(key: key); @@ -141,6 +145,8 @@ class IconLabelButton extends StatelessWidget { final EdgeInsetsGeometry tooltipPadding; + final bool outlined; + @override Widget build(BuildContext context) { final iconLabel = MaterialIconLabel( @@ -170,12 +176,23 @@ class IconLabelButton extends StatelessWidget { width: !includeText(context, minScreenWidthForTextBeforeScaling) ? buttonMinWidth : null, - child: OutlinedButton( - style: denseAwareOutlinedButtonStyle( - context, minScreenWidthForTextBeforeScaling), - onPressed: onPressed, - child: iconLabel, - ), + child: outlined + ? OutlinedButton( + style: denseAwareOutlinedButtonStyle( + context, + minScreenWidthForTextBeforeScaling, + ), + onPressed: onPressed, + child: iconLabel, + ) + : TextButton( + onPressed: onPressed, + child: iconLabel, + style: denseAwareOutlinedButtonStyle( + context, + minScreenWidthForTextBeforeScaling, + ), + ), ), ); } @@ -1869,3 +1886,20 @@ class _DualValueListenableBuilderState ); } } + +class SmallCircularProgressIndicator extends StatelessWidget { + const SmallCircularProgressIndicator({ + Key key, + @required this.valueColor, + }) : super(key: key); + + final Animation valueColor; + + @override + Widget build(BuildContext context) { + return CircularProgressIndicator( + strokeWidth: 2, + valueColor: valueColor, + ); + } +} diff --git a/packages/devtools_app/lib/src/inspector/diagnostics.dart b/packages/devtools_app/lib/src/inspector/diagnostics.dart index fd3047eee9d..f5333bb3757 100644 --- a/packages/devtools_app/lib/src/inspector/diagnostics.dart +++ b/packages/devtools_app/lib/src/inspector/diagnostics.dart @@ -35,18 +35,22 @@ class DiagnosticsNodeDescription extends StatelessWidget { const DiagnosticsNodeDescription( this.diagnostic, { this.isSelected, + this.searchValue, this.errorText, this.multiline = false, this.style, @required this.debuggerController, + this.nodeDescriptionHighlightStyle, }); final RemoteDiagnosticsNode diagnostic; final bool isSelected; final String errorText; + final String searchValue; final bool multiline; final TextStyle style; final DebuggerController debuggerController; + final TextStyle nodeDescriptionHighlightStyle; Widget _paddedIcon(Widget icon) { return Padding( @@ -90,8 +94,18 @@ class DiagnosticsNodeDescription extends StatelessWidget { if (textPreview is String) { final preview = textPreview.replaceAll('\n', ' '); yield TextSpan( - text: ': "$preview"', - style: textStyle.merge(inspector_text_styles.unimportant(colorScheme)), + children: [ + TextSpan( + text: ': ', + style: textStyle, + ), + _buildHighlightedSearchPreview( + preview, + searchValue, + textStyle, + textStyle.merge(nodeDescriptionHighlightStyle), + ), + ], ); } } @@ -350,4 +364,60 @@ class DiagnosticsNodeDescription extends StatelessWidget { ), ); } + + TextSpan _buildHighlightedSearchPreview( + String textPreview, + String searchValue, + TextStyle textStyle, + TextStyle highlightTextStyle, + ) { + if (searchValue == null || searchValue.isEmpty) { + return TextSpan( + text: '"$textPreview"', + style: textStyle, + ); + } + + if (textPreview.caseInsensitiveEquals(searchValue)) { + return TextSpan( + text: '"$textPreview"', + style: highlightTextStyle, + ); + } + + final matches = searchValue.caseInsensitiveAllMatches(textPreview); + if (matches.isEmpty) { + return TextSpan( + text: '"$textPreview"', + style: textStyle, + ); + } + + final quoteSpan = TextSpan(text: '"', style: textStyle); + final spans = [quoteSpan]; + var previousItemEnd = 0; + for (final match in matches) { + if (match.start > previousItemEnd) { + spans.add(TextSpan( + text: textPreview.substring(previousItemEnd, match.start), + style: textStyle, + )); + } + + spans.add(TextSpan( + text: textPreview.substring(match.start, match.end), + style: highlightTextStyle, + )); + + previousItemEnd = match.end; + } + + spans.add(TextSpan( + text: textPreview.substring(previousItemEnd, textPreview.length), + style: textStyle, + )); + spans.add(quoteSpan); + + return TextSpan(children: spans); + } } diff --git a/packages/devtools_app/lib/src/inspector/inspector_breadcrumbs.dart b/packages/devtools_app/lib/src/inspector/inspector_breadcrumbs.dart new file mode 100644 index 00000000000..cbcf58e02da --- /dev/null +++ b/packages/devtools_app/lib/src/inspector/inspector_breadcrumbs.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; + +import '../theme.dart'; +import '../utils.dart'; +import 'inspector_text_styles.dart'; +import 'inspector_tree.dart'; + +class InspectorBreadcrumbNavigator extends StatelessWidget { + const InspectorBreadcrumbNavigator({ + Key key, + @required this.items, + @required this.onTap, + }) : super(key: key); + + /// Max number of visible breadcrumbs including root item but not 'more' item. + /// E.g. value 5 means root and 4 breadcrumbs can be displayed, other + /// breadcrumbs (if any) will be replaced by '...' item. + static const _maxNumberOfBreadcrumbs = 5; + + final List items; + final Function(InspectorTreeNode) onTap; + + @override + Widget build(BuildContext context) { + if (items.isEmpty) { + return const SizedBox(); + } + + final breadcrumbs = _generateBreadcrumbs(items); + return SizedBox( + height: isDense() ? 24 : 28, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Row( + children: breadcrumbs.map((item) { + if (item.isChevron) { + return Icon( + Icons.chevron_right, + size: defaultIconSize, + ); + } + + return Flexible( + child: _InspectorBreadcrumb( + data: item, + onTap: () => onTap(item.node), + ), + ); + }).toList(), + ), + ), + ); + } + + List<_InspectorBreadcrumbData> _generateBreadcrumbs( + List nodes, + ) { + final lastNode = nodes.safeLast; + final List<_InspectorBreadcrumbData> items = nodes.map((node) { + return _InspectorBreadcrumbData.wrap( + node: node, + isSelected: node == lastNode, + ); + }).toList(); + List<_InspectorBreadcrumbData> breadcrumbs; + if (items.length > _maxNumberOfBreadcrumbs) { + breadcrumbs = [ + items[0], + _InspectorBreadcrumbData.more(), + ...items.sublist( + items.length - _maxNumberOfBreadcrumbs + 1, + items.length, + ), + ]; + } else { + breadcrumbs = items; + } + + return breadcrumbs.joinWith(_InspectorBreadcrumbData.chevron()); + } +} + +class _InspectorBreadcrumb extends StatelessWidget { + const _InspectorBreadcrumb({ + Key key, + @required this.data, + @required this.onTap, + }) : assert(data != null), + super(key: key); + + static const BorderRadius _borderRadius = + BorderRadius.all(Radius.circular(defaultBorderRadius)); + + static const _iconScale = .75; + + final _InspectorBreadcrumbData data; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final text = Text( + data.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: regular.copyWith(fontSize: scaleByFontFactor(11)), + ); + + final icon = data.icon == null + ? null + : Transform.scale( + scale: _iconScale, + child: Padding( + padding: const EdgeInsets.only(right: iconPadding), + child: data.icon, + ), + ); + + return InkWell( + onTap: data.isClickable ? onTap : null, + borderRadius: _borderRadius, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: densePadding, + vertical: borderPadding, + ), + decoration: BoxDecoration( + borderRadius: _borderRadius, + color: data.isSelected + ? Theme.of(context).colorScheme.selectedRowBackgroundColor + : Colors.transparent, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) icon, + Flexible(child: text), + ], + ), + ), + ); + } +} + +class _InspectorBreadcrumbData { + const _InspectorBreadcrumbData._({ + @required this.node, + @required this.isSelected, + @required this.alternativeText, + @required this.alternativeIcon, + }); + + factory _InspectorBreadcrumbData.wrap({ + @required InspectorTreeNode node, + @required bool isSelected, + }) { + return _InspectorBreadcrumbData._( + node: node, + isSelected: isSelected, + alternativeText: null, + alternativeIcon: null, + ); + } + + /// Construct a special item for showing '…' symbol between other items + factory _InspectorBreadcrumbData.more() { + return const _InspectorBreadcrumbData._( + node: null, + isSelected: false, + alternativeText: _ellipsisValue, + alternativeIcon: null, + ); + } + + factory _InspectorBreadcrumbData.chevron() { + return const _InspectorBreadcrumbData._( + node: null, + isSelected: false, + alternativeText: null, + alternativeIcon: _breadcrumbSeparatorIcon, + ); + } + + static const _ellipsisValue = '…'; + static const _breadcrumbSeparatorIcon = Icons.chevron_right; + + final InspectorTreeNode node; + final IconData alternativeIcon; + final String alternativeText; + final bool isSelected; + + String get text => alternativeText ?? node?.diagnostic?.description; + + Widget get icon { + if (alternativeIcon != null) { + return Icon( + _breadcrumbSeparatorIcon, + size: defaultIconSize, + ); + } + + return node?.diagnostic?.icon; + } + + bool get isChevron => + node == null && alternativeIcon == _breadcrumbSeparatorIcon; + + bool get isEllipsis => node == null && alternativeText == _ellipsisValue; + + bool get isClickable => !isSelected && !isEllipsis; +} diff --git a/packages/devtools_app/lib/src/inspector/inspector_screen.dart b/packages/devtools_app/lib/src/inspector/inspector_screen.dart index def4ada7094..ec2850860c1 100644 --- a/packages/devtools_app/lib/src/inspector/inspector_screen.dart +++ b/packages/devtools_app/lib/src/inspector/inspector_screen.dart @@ -21,10 +21,13 @@ import '../service_extensions.dart' as extensions; import '../split.dart'; import '../theme.dart'; import '../ui/icons.dart'; +import '../ui/search.dart'; import '../ui/service_extension_widgets.dart'; +import '../utils.dart'; import 'inspector_controller.dart'; import 'inspector_screen_details_tab.dart'; import 'inspector_service.dart'; +import 'inspector_tree.dart'; import 'inspector_tree_controller.dart'; class InspectorScreen extends Screen { @@ -59,7 +62,10 @@ class InspectorScreenBody extends StatefulWidget { } class InspectorScreenBodyState extends State - with BlockingActionMixin, AutoDisposeMixin { + with + BlockingActionMixin, + AutoDisposeMixin, + SearchFieldMixin { bool _expandCollapseSupported = false; bool _layoutExplorerSupported = false; @@ -75,12 +81,31 @@ class InspectorScreenBodyState extends State bool get enableButtons => actionInProgress == false; + bool searchVisible = false; + + /// Indicates whether search can be closed. The value is set to true when + /// search target type dropdown is displayed + /// TODO(https://github.com/flutter/devtools/issues/3489) use this variable when adding the scope dropdown + bool searchPreventClose = false; + + SearchTargetType searchTarget = SearchTargetType.widget; + static const summaryTreeKey = Key('Summary Tree'); static const detailsTreeKey = Key('Details Tree'); static const minScreenWidthForTextBeforeScaling = 900.0; static const unscaledIncludeRefreshTreeWidth = 1255.0; static const serviceExtensionButtonsIncludeTextWidth = 1160.0; + @override + void dispose() { + inspectorController.inspectorTree.dispose(); + if (inspectorController.isSummaryTree && + inspectorController.details != null) { + inspectorController.details.inspectorTree.dispose(); + } + super.dispose(); + } + @override void initState() { super.initState(); @@ -90,13 +115,36 @@ class InspectorScreenBodyState extends State // The app must not be a Flutter app. return; } + final inspectorTreeController = InspectorTreeController(); + final detailsTree = InspectorTreeController(); inspectorController = InspectorController( - inspectorTree: InspectorTreeController(), - detailsTree: InspectorTreeController(), + inspectorTree: inspectorTreeController, + detailsTree: detailsTree, treeType: FlutterTreeType.widget, onExpandCollapseSupported: _onExpandCollapseSupported, onLayoutExplorerSupported: _onLayoutExplorerSupported, ); + + summaryTreeController.setSearchTarget(searchTarget); + + addAutoDisposeListener(searchFieldFocusNode, () { + // Close the search once focus is lost and following conditions are met: + // 1. Search string is empty. + // 2. [searchPreventClose] == false (this is set true when searchTargetType Dropdown is opened). + if (!searchFieldFocusNode.hasFocus && + summaryTreeController.search.isEmpty && + !searchPreventClose) { + setState(() { + searchVisible = false; + }); + } + + // Reset [searchPreventClose] state to false after the search field gains focus. + // Focus is returned automatically once the Dropdown menu is closed. + if (searchFieldFocusNode.hasFocus) { + searchPreventClose = false; + } + }); } void _onExpandClick() { @@ -121,6 +169,7 @@ class InspectorScreenBodyState extends State key: detailsTreeKey, controller: detailsTreeController, debuggerController: _debuggerController, + inspectorTreeController: summaryTreeController, ); final splitAxis = Split.axisFor(context, 0.85); @@ -158,15 +207,6 @@ class InspectorScreenBodyState extends State ); }, ), - const SizedBox(width: denseSpacing), - IconLabelButton( - onPressed: _refreshInspector, - icon: Icons.refresh, - label: 'Refresh Tree', - color: Theme.of(context).colorScheme.toggleButtonsTitle, - minScreenWidthForTextBeforeScaling: - unscaledIncludeRefreshTreeWidth, - ), const Spacer(), Row(children: getServiceExtensionWidgets()), ], @@ -182,42 +222,79 @@ class InspectorScreenBodyState extends State Widget _buildSummaryTreeColumn( DebuggerController debuggerController, ) { - return OutlineDecoration( - child: ValueListenableBuilder( - valueListenable: serviceManager.errorBadgeManager - .erroredItemsForPage(InspectorScreen.id), - builder: (_, LinkedHashMap errors, __) { - final inspectableErrors = errors.map( - (key, value) => MapEntry(key, value as InspectableWidgetError)); - return Stack( + return LayoutBuilder( + builder: (context, constraints) { + return OutlineDecoration( + child: Column( children: [ - InspectorTree( - key: summaryTreeKey, - controller: summaryTreeController, - isSummaryTree: true, - widgetErrors: inspectableErrors, - debuggerController: debuggerController, - ), - if (errors.isNotEmpty && inspectorController != null) - ValueListenableBuilder( - valueListenable: inspectorController.selectedErrorIndex, - builder: (_, selectedErrorIndex, __) => Positioned( - top: 0, - right: 0, - child: ErrorNavigator( - errors: inspectableErrors, - errorIndex: selectedErrorIndex, - onSelectError: inspectorController.selectErrorByIndex, - ), + InspectorSummaryTreeControls( + isSearchVisible: searchVisible, + constraints: constraints, + onRefreshInspectorPressed: _refreshInspector, + onSearchVisibleToggle: _onSearchVisibleToggle, + searchFieldBuilder: () => buildSearchField( + controller: summaryTreeController, + searchFieldKey: GlobalKey( + debugLabel: 'inspectorScreenSearch', ), - ) + searchFieldEnabled: true, + shouldRequestFocus: searchVisible, + supportsNavigation: true, + onClose: _onSearchVisibleToggle, + ), + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: serviceManager.errorBadgeManager + .erroredItemsForPage(InspectorScreen.id), + builder: + (_, LinkedHashMap errors, __) { + final inspectableErrors = errors.map((key, value) => + MapEntry(key, value as InspectableWidgetError)); + return Stack( + children: [ + InspectorTree( + key: summaryTreeKey, + controller: summaryTreeController, + isSummaryTree: true, + widgetErrors: inspectableErrors, + debuggerController: debuggerController, + ), + if (errors.isNotEmpty && inspectorController != null) + ValueListenableBuilder( + valueListenable: + inspectorController.selectedErrorIndex, + builder: (_, selectedErrorIndex, __) => Positioned( + top: 0, + right: 0, + child: ErrorNavigator( + errors: inspectableErrors, + errorIndex: selectedErrorIndex, + onSelectError: + inspectorController.selectErrorByIndex, + ), + ), + ), + ], + ); + }, + ), + ) ], - ); - }, - ), + ), + ); + }, ); } + void _onSearchVisibleToggle() { + setState(() { + searchVisible = !searchVisible; + }); + summaryTreeController.resetSearch(); + searchTextFieldController.clear(); + } + List getServiceExtensionWidgets() { return [ ServiceExtensionButtonGroup( @@ -239,8 +316,13 @@ class InspectorScreenBodyState extends State Widget _expandCollapseButtons() { if (!_expandCollapseSupported) return null; - return Align( + return Container( alignment: Alignment.centerRight, + decoration: BoxDecoration( + border: Border( + left: defaultBorderSide(Theme.of(context)), + ), + ), child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, @@ -252,6 +334,7 @@ class InspectorScreenBodyState extends State label: 'Expand all', minScreenWidthForTextBeforeScaling: minScreenWidthForTextBeforeScaling, + outlined: false, ), ), const SizedBox(width: denseSpacing), @@ -262,6 +345,7 @@ class InspectorScreenBodyState extends State label: 'Collapse to selected', minScreenWidthForTextBeforeScaling: minScreenWidthForTextBeforeScaling, + outlined: false, ), ) ], @@ -289,6 +373,90 @@ class InspectorScreenBodyState extends State } } +class InspectorSummaryTreeControls extends StatelessWidget { + const InspectorSummaryTreeControls({ + Key key, + @required this.constraints, + @required this.isSearchVisible, + @required this.onRefreshInspectorPressed, + @required this.onSearchVisibleToggle, + @required this.searchFieldBuilder, + }) : super(key: key); + + static const _searchBreakpoint = 375.0; + + final bool isSearchVisible; + final BoxConstraints constraints; + final VoidCallback onRefreshInspectorPressed; + final VoidCallback onSearchVisibleToggle; + final Widget Function() searchFieldBuilder; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _controlsContainer( + context, + Row( + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: denseSpacing), + child: Text('Widget Tree'), + ), + ...!isSearchVisible + ? [ + const Spacer(), + ToolbarAction( + icon: Icons.search, + onPressed: onSearchVisibleToggle, + tooltip: 'Search Tree', + ), + ] + : [ + constraints.maxWidth >= _searchBreakpoint + ? _buildSearchControls() + : const Spacer() + ], + ToolbarAction( + icon: Icons.refresh, + onPressed: onRefreshInspectorPressed, + tooltip: 'Refresh Tree', + ), + ], + ), + ), + if (isSearchVisible && constraints.maxWidth < _searchBreakpoint) + _controlsContainer( + context, + Row(children: [_buildSearchControls()]), + ), + ], + ); + } + + Container _controlsContainer(BuildContext context, Widget child) { + return Container( + height: defaultButtonHeight + + (isDense() ? denseModeDenseSpacing : denseSpacing), + decoration: BoxDecoration( + border: Border( + bottom: defaultBorderSide(Theme.of(context)), + ), + ), + child: child, + ); + } + + Widget _buildSearchControls() { + return Expanded( + child: Container( + height: defaultTextFieldHeight, + child: searchFieldBuilder(), + ), + ); + } +} + class ErrorNavigator extends StatelessWidget { const ErrorNavigator({ Key key, diff --git a/packages/devtools_app/lib/src/inspector/inspector_screen_details_tab.dart b/packages/devtools_app/lib/src/inspector/inspector_screen_details_tab.dart index 8091a77508a..7732b9d122c 100644 --- a/packages/devtools_app/lib/src/inspector/inspector_screen_details_tab.dart +++ b/packages/devtools_app/lib/src/inspector/inspector_screen_details_tab.dart @@ -57,7 +57,7 @@ class _InspectorDetailsTabControllerState Widget build(BuildContext context) { final tabs = [ if (widget.layoutExplorerSupported) _buildTab('Layout Explorer'), - _buildTab('Details Tree'), + _buildTab('Widget Details Tree'), ]; final tabViews = [ if (widget.layoutExplorerSupported) @@ -76,27 +76,24 @@ class _InspectorDetailsTabControllerState return Column( children: [ - SizedBox( - // Add [denseSpacing] to add slight padding around the expand / - // collapse buttons. + Container( height: defaultButtonHeight + (isDense() ? denseModeDenseSpacing : denseSpacing), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).focusColor), + ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Container( - color: focusColor, - child: TabBar( - controller: _tabController, - labelColor: theme.textTheme.bodyText1.color, - tabs: tabs, - isScrollable: true, - ), + TabBar( + controller: _tabController, + labelColor: theme.textTheme.bodyText1.color, + tabs: tabs, + isScrollable: true, ), Expanded( child: Container( alignment: Alignment.centerRight, - decoration: BoxDecoration(border: Border(bottom: borderSide)), child: hasActionButtons ? widget.actionButtons : const SizedBox(), diff --git a/packages/devtools_app/lib/src/inspector/inspector_tree.dart b/packages/devtools_app/lib/src/inspector/inspector_tree.dart index 619a2b6c1d8..f9744c6ba51 100644 --- a/packages/devtools_app/lib/src/inspector/inspector_tree.dart +++ b/packages/devtools_app/lib/src/inspector/inspector_tree.dart @@ -15,6 +15,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../theme.dart'; +import '../ui/search.dart'; import '../utils.dart'; import 'diagnostics_node.dart'; import 'inspector_service.dart'; @@ -266,8 +267,8 @@ class InspectorTreeNode { } /// A row in the tree with all information required to render it. -class InspectorTreeRow { - const InspectorTreeRow({ +class InspectorTreeRow with DataSearchStateMixin { + InspectorTreeRow({ @required this.node, @required this.index, @required this.ticks, @@ -309,3 +310,18 @@ class InspectorTreeConfig { final TreeEventCallback onExpand; final TreeEventCallback onHover; } + +enum SearchTargetType { + widget, + // TODO(https://github.com/flutter/devtools/issues/3489) implement other search scopes: details, all etc +} + +extension SearchTargetTypeExtension on SearchTargetType { + String get name { + switch (this) { + case SearchTargetType.widget: + default: + return 'Widget'; + } + } +} diff --git a/packages/devtools_app/lib/src/inspector/inspector_tree_controller.dart b/packages/devtools_app/lib/src/inspector/inspector_tree_controller.dart index 7d82cfa46d0..0c14137d912 100644 --- a/packages/devtools_app/lib/src/inspector/inspector_tree_controller.dart +++ b/packages/devtools_app/lib/src/inspector/inspector_tree_controller.dart @@ -4,6 +4,7 @@ library inspector_tree; +import 'dart:async'; import 'dart:collection'; import 'dart:math'; @@ -18,11 +19,16 @@ import '../common_widgets.dart'; import '../config_specific/logger/logger.dart'; import '../debugger/debugger_controller.dart'; import '../error_badge_manager.dart'; +import '../globals.dart'; import '../theme.dart'; import '../ui/colors.dart'; +import '../ui/search.dart'; import '../ui/utils.dart'; +import '../utils.dart'; import 'diagnostics.dart'; import 'diagnostics_node.dart'; +import 'inspector_breadcrumbs.dart'; +import 'inspector_text_styles.dart' as inspector_text_styles; import 'inspector_tree.dart'; /// Presents a [TreeNode]. @@ -96,12 +102,15 @@ class _InspectorTreeRowState extends State<_InspectorTreeRowWidget> bool shouldShow() => widget.node.shouldShow; } -class InspectorTreeController extends Object { +class InspectorTreeController extends Object + with SearchControllerMixin { /// Clients the controller notifies to trigger changes to the UI. final Set _clients = {}; InspectorTreeNode createNode() => InspectorTreeNode(); + SearchTargetType _searchTarget = SearchTargetType.widget; + void addClient(InspectorControllerClient value) { final firstClient = _clients.isEmpty; _clients.add(value); @@ -133,9 +142,11 @@ class InspectorTreeController extends Object { InspectorTreeNode get root => _root; InspectorTreeNode _root; + set root(InspectorTreeNode node) { setState(() { _root = node; + _populateSearchableCachedRows(); }); } @@ -172,17 +183,39 @@ class InspectorTreeController extends Object { double lastContentWidth; final List cachedRows = []; + InspectorTreeRow _cachedSelectedRow; + + /// All cached rows of the tree. + /// + /// Similar to [cachedRows] but: + /// * contains every row in the tree (including collapsed rows) + /// * items don't change when nodes are expanded or collapsed + /// * items are populated only when root is changed + final _searchableCachedRows = []; + + void setSearchTarget(SearchTargetType searchTarget) { + _searchTarget = searchTarget; + refreshSearchMatches(); + } // TODO: we should add a listener instead that clears the cache when the // root is marked as dirty. void _maybeClearCache() { if (root != null && root.isDirty) { cachedRows.clear(); + _cachedSelectedRow = null; root.isDirty = false; lastContentWidth = null; } } + void _populateSearchableCachedRows() { + _searchableCachedRows.clear(); + for (int i = 0; i < numRows; i++) { + _searchableCachedRows.add(getCachedRow(i)); + } + } + InspectorTreeRow getCachedRow(int index) { if (index < 0) return null; @@ -191,13 +224,34 @@ class InspectorTreeController extends Object { cachedRows.add(null); } cachedRows[index] ??= root?.getRow(index); - return cachedRows[index]; + + final cachedRow = cachedRows[index]; + cachedRow?.isSearchMatch = + _searchableCachedRows.safeGet(index)?.isSearchMatch ?? false; + + if (cachedRow?.isSelected == true) { + _cachedSelectedRow = cachedRow; + } + return cachedRow; } double getRowOffset(int index) { return (getCachedRow(index)?.depth ?? 0) * columnWidth; } + List getPathFromSelectedRowToRoot() { + final selectedItem = _cachedSelectedRow?.node; + if (selectedItem == null) return []; + + final pathToRoot = [selectedItem]; + InspectorTreeNode nextParentNode = selectedItem.parent; + while (nextParentNode != null) { + pathToRoot.add(nextParentNode); + nextParentNode = nextParentNode.parent; + } + return pathToRoot.reversed.toList(); + } + set hover(InspectorTreeNode node) { if (node == _hover) { return; @@ -350,8 +404,12 @@ class InspectorTreeController extends Object { } void onSelectRow(InspectorTreeRow row) { - selection = row.node; - expandPath(row.node); + onSelectNode(row.node); + } + + void onSelectNode(InspectorTreeNode node) { + selection = node; + expandPath(node); } Rect getBoundingBox(InspectorTreeRow row) { @@ -542,6 +600,85 @@ class InspectorTreeController extends Object { } } } + + /* Search support */ + @override + void onMatchChanged(int index) { + onSelectRow(searchMatches.value[index]); + } + + @override + Duration get debounceDelay => const Duration(milliseconds: 300); + + void dispose() { + disposeSearch(); + } + + @override + List matchesForSearch( + String search, { + bool searchPreviousMatches = false, + }) { + final matches = []; + + if (searchPreviousMatches) { + final previousMatches = searchMatches.value; + for (final previousMatch in previousMatches) { + if (previousMatch.node.diagnostic.searchValue + .caseInsensitiveContains(search)) { + matches.add(previousMatch); + } + } + + if (matches.isNotEmpty) return matches; + } + + int _debugStatsSearchOps = 0; + final _debugStatsWidgets = _searchableCachedRows.length; + + if (search == null || + search.isEmpty || + serviceManager.inspectorService == null || + serviceManager.inspectorService.isDisposed) { + debugPrint('Search completed, no search'); + return matches; + } + + debugPrint('Search started: ' + _searchTarget.toString()); + + for (final row in _searchableCachedRows) { + final diagnostic = row.node.diagnostic; + if (row.node == null || diagnostic == null) continue; + + // Widget search begin + if (_searchTarget == SearchTargetType.widget) { + _debugStatsSearchOps++; + if (diagnostic.searchValue.caseInsensitiveContains(search)) { + matches.add(row); + continue; + } + } + // Widget search end + } + + debugPrint('Search completed with ' + + _debugStatsWidgets.toString() + + ' widgets, ' + + _debugStatsSearchOps.toString() + + ' ops'); + + return matches; + } +} + +extension RemoteDiagnosticsNodeExtension on RemoteDiagnosticsNode { + String get searchValue { + final description = toStringShort(); + final textPreview = json['textPreview']; + return textPreview is String + ? '$description ${textPreview.replaceAll('\n', ' ')}' + : description; + } } abstract class InspectorControllerClient { @@ -557,11 +694,14 @@ class InspectorTree extends StatefulWidget { Key key, @required this.controller, @required this.debuggerController, + this.inspectorTreeController, this.isSummaryTree = false, this.widgetErrors, - }) : super(key: key); + }) : assert(isSummaryTree == (inspectorTreeController == null)), + super(key: key); final InspectorTreeController controller; + final InspectorTreeController inspectorTreeController; final DebuggerController debuggerController; final bool isSummaryTree; final LinkedHashMap widgetErrors; @@ -818,7 +958,7 @@ class _InspectorTreeState extends State return LayoutBuilder( builder: (context, constraints) { final viewportWidth = constraints.maxWidth; - return Scrollbar( + final Widget tree = Scrollbar( isAlwaysShown: true, controller: _scrollControllerX, child: SingleChildScrollView( @@ -851,7 +991,7 @@ class _InspectorTreeState extends State return SizedBox(height: rowHeight); } final InspectorTreeRow row = - controller.root?.getRow(index); + controller.getCachedRow(index); final inspectorRef = row.node.diagnostic?.valueRef?.id; return _InspectorTreeRowWidget( @@ -877,6 +1017,24 @@ class _InspectorTreeState extends State ), ), ); + + final bool shouldShowBreadcrumbs = !widget.isSummaryTree; + if (shouldShowBreadcrumbs) { + final parents = + widget.inspectorTreeController.getPathFromSelectedRowToRoot(); + return Column( + children: [ + InspectorBreadcrumbNavigator( + items: parents, + onTap: (node) => + widget.inspectorTreeController.onSelectNode(node), + ), + Expanded(child: tree), + ], + ); + } + + return tree; }, ); } @@ -993,7 +1151,8 @@ class InspectorRowContent extends StatelessWidget { @override Widget build(BuildContext context) { final double currentX = controller.getDepthIndent(row.depth) - columnWidth; - final colorScheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; if (row == null) { return const SizedBox(); @@ -1010,48 +1169,67 @@ class InspectorRowContent extends StatelessWidget { Widget rowWidget = Padding( padding: EdgeInsets.only(left: currentX), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - node.showExpandCollapse - ? InkWell( - onTap: onToggle, - child: RotationTransition( - turns: expandArrowAnimation, - child: Icon( - Icons.expand_more, - size: defaultIconSize, + child: ValueListenableBuilder( + valueListenable: controller.searchNotifier, + builder: (context, searchValue, _) { + return Opacity( + opacity: searchValue.isEmpty || row.isSearchMatch ? 1 : 0.2, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + node.showExpandCollapse + ? InkWell( + onTap: onToggle, + child: RotationTransition( + turns: expandArrowAnimation, + child: Icon( + Icons.expand_more, + size: defaultIconSize, + ), + ), + ) + : const SizedBox( + width: defaultSpacing, + height: defaultSpacing, + ), + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: + hasError ? Border.all(color: devtoolsError) : null, + ), + child: InkWell( + onTap: () { + controller.onSelectRow(row); + // TODO(gmoothart): It may be possible to capture the tap + // and request focus directly from the InspectorTree. Then + // we wouldn't need this. + controller.requestFocus(); + }, + child: Container( + height: rowHeight, + child: DiagnosticsNodeDescription( + node.diagnostic, + isSelected: row.isSelected, + searchValue: searchValue, + errorText: error?.errorMessage, + debuggerController: debuggerController, + nodeDescriptionHighlightStyle: + searchValue.isEmpty || !row.isSearchMatch + ? inspector_text_styles.regular + : row.isSelected + ? theme.searchMatchHighlightStyleFocused + : theme.searchMatchHighlightStyle, + ), + ), ), - ), - ) - : const SizedBox(width: defaultSpacing, height: defaultSpacing), - Expanded( - child: DecoratedBox( - decoration: BoxDecoration( - color: backgroundColor, - border: hasError ? Border.all(color: devtoolsError) : null, - ), - child: InkWell( - onTap: () { - controller.onSelectRow(row); - // TODO(gmoothart): It may be possible to capture the tap - // and request focus directly from the InspectorTree. Then - // we wouldn't need this. - controller.requestFocus(); - }, - child: Container( - height: rowHeight, - child: DiagnosticsNodeDescription( - node.diagnostic, - isSelected: row.isSelected, - errorText: error?.errorMessage, - debuggerController: debuggerController, ), ), - ), + ], ), - ), - ], + ); + }, ), ); diff --git a/packages/devtools_app/lib/src/landing_screen.dart b/packages/devtools_app/lib/src/landing_screen.dart index e879c49fd45..f902746bff8 100644 --- a/packages/devtools_app/lib/src/landing_screen.dart +++ b/packages/devtools_app/lib/src/landing_screen.dart @@ -77,7 +77,7 @@ class LandingScreenSection extends StatelessWidget { ), const PaddedDivider(), child, - const PaddedDivider(padding: EdgeInsets.symmetric(vertical: 10.0)), + PaddedDivider.vertical(padding: 10.0), ], ); } diff --git a/packages/devtools_app/lib/src/network/network_screen.dart b/packages/devtools_app/lib/src/network/network_screen.dart index 8fdb5c76d4f..f00daa55e73 100644 --- a/packages/devtools_app/lib/src/network/network_screen.dart +++ b/packages/devtools_app/lib/src/network/network_screen.dart @@ -72,8 +72,7 @@ class NetworkScreen extends Screen { width: smallProgressSize, height: smallProgressSize, child: recording - ? CircularProgressIndicator( - strokeWidth: 2, + ? SmallCircularProgressIndicator( valueColor: AlwaysStoppedAnimation(color), ) : const SizedBox(), diff --git a/packages/devtools_app/lib/src/status_line.dart b/packages/devtools_app/lib/src/status_line.dart index b6d2aade9c3..25fcc1e44b3 100644 --- a/packages/devtools_app/lib/src/status_line.dart +++ b/packages/devtools_app/lib/src/status_line.dart @@ -168,8 +168,7 @@ class StatusLine extends StatelessWidget { width: smallProgressSize, height: smallProgressSize, child: isBusy - ? CircularProgressIndicator( - strokeWidth: 2, + ? SmallCircularProgressIndicator( valueColor: AlwaysStoppedAnimation(color), ) : const SizedBox(), diff --git a/packages/devtools_app/lib/src/theme.dart b/packages/devtools_app/lib/src/theme.dart index f9eb05757bf..cfb69c6fdb6 100644 --- a/packages/devtools_app/lib/src/theme.dart +++ b/packages/devtools_app/lib/src/theme.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'common_widgets.dart'; import 'config_specific/ide_theme/ide_theme.dart'; +import 'ui/colors.dart'; import 'utils.dart'; const _contrastForegroundWhite = Color.fromARGB(255, 240, 240, 240); @@ -454,6 +455,16 @@ extension ThemeDataExtension on ThemeData { color: colorScheme.chartSubtleColor, fontSize: chartFontSizeSmall, ); + + TextStyle get searchMatchHighlightStyle => const TextStyle( + color: Colors.black, + backgroundColor: activeSearchMatchColor, + ); + + TextStyle get searchMatchHighlightStyleFocused => const TextStyle( + color: Colors.black, + backgroundColor: searchMatchColor, + ); } const extraWideSearchTextWidth = 600.0; diff --git a/packages/devtools_app/lib/src/ui/search.dart b/packages/devtools_app/lib/src/ui/search.dart index c441a59791a..4681eaf41c6 100644 --- a/packages/devtools_app/lib/src/ui/search.dart +++ b/packages/devtools_app/lib/src/ui/search.dart @@ -2,8 +2,10 @@ // 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:math'; +import 'package:async/async.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -11,6 +13,7 @@ import 'package:flutter/services.dart'; import '../auto_dispose.dart'; import '../auto_dispose_mixin.dart'; import '../common_widgets.dart'; +import '../config_specific/logger/logger.dart'; import '../theme.dart'; import '../trees.dart'; import '../ui/utils.dart'; @@ -20,15 +23,22 @@ import '../utils.dart'; const defaultTopMatchesLimit = 10; int topMatchesLimit = defaultTopMatchesLimit; +const double _searchControlDividerHeight = 24.0; + mixin SearchControllerMixin { final _searchNotifier = ValueNotifier(''); + final _searchInProgress = ValueNotifier(false); /// Notify that the search has changed. ValueListenable get searchNotifier => _searchNotifier; + ValueListenable get searchInProgressNotifier => _searchInProgress; /// Last X position of caret in search field, used for pop-up position. double xPosition = 0.0; + CancelableOperation _searchOperation; + Timer _searchDebounce; + set search(String value) { final previousSearchValue = _searchNotifier.value; final shouldSearchPreviousMatches = previousSearchValue != null && @@ -38,24 +48,75 @@ mixin SearchControllerMixin { refreshSearchMatches(searchPreviousMatches: shouldSearchPreviousMatches); } + set searchInProgress(bool searchInProgress) { + _searchInProgress.value = searchInProgress; + } + String get search => _searchNotifier.value; + bool get isSearchInProgress => _searchInProgress.value; final _searchMatches = ValueNotifier>([]); ValueListenable> get searchMatches => _searchMatches; void refreshSearchMatches({bool searchPreviousMatches = false}) { - final matches = - (_searchNotifier.value != null && _searchNotifier.value.isNotEmpty) - ? matchesForSearch( - _searchNotifier.value, - searchPreviousMatches: searchPreviousMatches, - ) - : []; - updateMatches(matches); + if (_searchNotifier.value != null && _searchNotifier.value.isNotEmpty) { + if (debounceDelay != null) { + _startDebounceTimer( + search, + searchPreviousMatches: searchPreviousMatches, + ); + } else { + final matches = matchesForSearch( + _searchNotifier.value, + searchPreviousMatches: searchPreviousMatches, + ); + _updateMatches(matches); + } + } else { + _updateMatches([]); + } + } + + void _startDebounceTimer( + String search, { + @required bool searchPreviousMatches, + }) { + searchInProgress = true; + + if (_searchDebounce?.isActive ?? false) { + _searchDebounce.cancel(); + } + + _searchDebounce = Timer( + search.isEmpty ? Duration.zero : debounceDelay, + () async { + // Abort any ongoing search operations and start a new one + try { + await _searchOperation?.cancel(); + } catch (e) { + log(e, LogLevel.error); + } + searchInProgress = true; + + // Start new search operation + final future = Future(() { + return matchesForSearch( + _searchNotifier.value, + searchPreviousMatches: searchPreviousMatches, + ); + }).then((matches) { + searchInProgress = false; + _updateMatches(matches); + }); + _searchOperation = CancelableOperation.fromFuture(future); + await _searchOperation.value; + searchInProgress = false; + }, + ); } - void updateMatches(List matches) { + void _updateMatches(List matches) { for (final previousMatch in _searchMatches.value) { previousMatch.isSearchMatch = false; } @@ -99,28 +160,47 @@ mixin SearchControllerMixin { void _updateActiveSearchMatch() { // [matchIndex] is 1-based. Subtract 1 for the 0-based list [searchMatches]. - final activeMatchIndex = matchIndex.value - 1; + int activeMatchIndex = matchIndex.value - 1; if (activeMatchIndex < 0) { _activeSearchMatch.value?.isActiveSearchMatch = false; _activeSearchMatch.value = null; return; } - assert(activeMatchIndex < searchMatches.value.length); + if (searchMatches.value.isNotEmpty && + activeMatchIndex >= searchMatches.value.length) { + activeMatchIndex = 0; + matchIndex.value = 1; // first item because [matchIndex] us 1-based + } _activeSearchMatch.value?.isActiveSearchMatch = false; _activeSearchMatch.value = searchMatches.value[activeMatchIndex] ..isActiveSearchMatch = true; + onMatchChanged(activeMatchIndex); } + /// Delay to reduce the amount of search queries + /// Duration.zero (default) disables debounce + Duration get debounceDelay => null; + List matchesForSearch( String search, { bool searchPreviousMatches = false, }) => []; + /// Called when selected match index changes. Index is 0 based + void onMatchChanged(int index) {} + void resetSearch() { _searchNotifier.value = ''; refreshSearchMatches(); } + + void disposeSearch() { + _searchOperation?.cancel(); + if (_searchDebounce?.isActive ?? false) { + _searchDebounce.cancel(); + } + } } class AutoComplete extends StatefulWidget { @@ -595,6 +675,8 @@ mixin SearchFieldMixin SelectAutoComplete _onSelection; void Function() _closeHandler; + FocusNode get searchFieldFocusNode => _searchFieldFocusNode; + @override void initState() { super.initState(); @@ -696,6 +778,8 @@ mixin SearchFieldMixin @required bool shouldRequestFocus, bool supportsNavigation = false, VoidCallback onClose, + Widget prefix, + Widget suffix, }) { return _SearchField( controller: controller, @@ -706,6 +790,8 @@ mixin SearchFieldMixin searchTextFieldController: searchTextFieldController, supportsNavigation: supportsNavigation, onClose: onClose, + prefix: prefix, + suffix: suffix, ); } @@ -757,6 +843,8 @@ class _SearchField extends StatelessWidget { this.decoration, this.onClose, this.overlayXPositionBuilder, + this.prefix, + this.suffix, }); final SearchControllerMixin controller; @@ -771,6 +859,8 @@ class _SearchField extends StatelessWidget { final InputDecoration decoration; final VoidCallback onClose; final OverlayXPositionBuilder overlayXPositionBuilder; + final Widget prefix; + final Widget suffix; @override Widget build(BuildContext context) { @@ -807,13 +897,32 @@ class _SearchField extends StatelessWidget { labelStyle: TextStyle(color: searchColor), border: const OutlineInputBorder(), labelText: label ?? 'Search', - suffix: (supportsNavigation || onClose != null) - ? _SearchFieldSuffix( - controller: controller, - supportsNavigation: supportsNavigation, - onClose: onClose, + prefix: prefix != null + ? Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + prefix, + SizedBox( + height: _searchControlDividerHeight, + width: defaultIconSize, + child: Transform.rotate( + angle: degToRad(90), + child: PaddedDivider.vertical(), + ), + ), + ], ) : null, + suffix: suffix != null + ? suffix + : (supportsNavigation || onClose != null) + ? _SearchFieldSuffix( + controller: controller, + supportsNavigation: supportsNavigation, + onClose: onClose, + ) + : null, ), cursorColor: searchColor, ); @@ -826,6 +935,43 @@ class _SearchField extends StatelessWidget { } } +class SearchDropdown extends StatelessWidget { + const SearchDropdown({ + Key key, + this.value, + this.onChanged, + this.isDense = false, + this.style, + this.selectedItemBuilder, + this.items, + this.onTap, + }) : super(key: key); + + final T value; + final ValueChanged onChanged; + final bool isDense; + final TextStyle style; + final DropdownButtonBuilder selectedItemBuilder; + final List> items; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return DropdownButtonHideUnderline( + child: DropdownButton( + iconSize: defaultIconSize, + value: value, + onTap: onTap, + onChanged: onChanged, + isDense: true, + style: style, + selectedItemBuilder: selectedItemBuilder, + items: items, + ), + ); + } +} + class _AutoCompleteSearchField extends StatelessWidget { const _AutoCompleteSearchField({ @required this.searchField, @@ -1031,33 +1177,49 @@ class SearchNavigationControls extends StatelessWidget { @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: controller.searchMatches, - builder: (context, matches, _) { - final numMatches = matches.length; - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _matchesStatus(numMatches), - SizedBox( - height: 24.0, - width: defaultIconSize, - child: Transform.rotate( - angle: degToRad(90), - child: const PaddedDivider( - padding: EdgeInsets.symmetric(vertical: densePadding), - ), - ), - ), - inputDecorationSuffixButton(Icons.keyboard_arrow_up, - numMatches > 1 ? controller.previousMatch : null), - inputDecorationSuffixButton(Icons.keyboard_arrow_down, - numMatches > 1 ? controller.nextMatch : null), - if (onClose != null) closeSearchDropdownButton(onClose) - ], - ); - }, - ); + valueListenable: controller.searchMatches, + builder: (context, matches, _) { + final numMatches = matches.length; + return ValueListenableBuilder( + valueListenable: controller.searchInProgressNotifier, + builder: (context, isSearchInProgress, _) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Opacity( + opacity: isSearchInProgress ? 1 : 0, + child: SizedBox( + width: scaleByFontFactor(smallProgressSize), + height: scaleByFontFactor(smallProgressSize), + child: isSearchInProgress + ? SmallCircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Theme.of(context).textTheme.bodyText2.color, + ), + ) + : const SizedBox(), + ), + ), + _matchesStatus(numMatches), + SizedBox( + height: _searchControlDividerHeight, + width: defaultIconSize, + child: Transform.rotate( + angle: degToRad(90), + child: PaddedDivider.vertical(), + ), + ), + inputDecorationSuffixButton(Icons.keyboard_arrow_up, + numMatches > 1 ? controller.previousMatch : null), + inputDecorationSuffixButton(Icons.keyboard_arrow_down, + numMatches > 1 ? controller.nextMatch : null), + if (onClose != null) closeSearchDropdownButton(onClose) + ], + ); + }, + ); + }); } Widget _matchesStatus(int numMatches) { diff --git a/packages/devtools_app/lib/src/utils.dart b/packages/devtools_app/lib/src/utils.dart index bc86d97b29e..8732a71edb8 100644 --- a/packages/devtools_app/lib/src/utils.dart +++ b/packages/devtools_app/lib/src/utils.dart @@ -1073,6 +1073,18 @@ extension StringExtension on String { } return true; } + + /// Whether [other] is a case insensitive match for this String + bool caseInsensitiveEquals(String other) { + return toLowerCase() == other?.toLowerCase(); + } + + /// Find all case insensitive matches of query in this String + /// See [allMatches] for more info + Iterable caseInsensitiveAllMatches(String query) { + if (query == null) return const []; + return toLowerCase().allMatches(query.toLowerCase()); + } } extension ListExtension on List { diff --git a/packages/devtools_app/test/goldens/codeview_scrollbars.png b/packages/devtools_app/test/goldens/codeview_scrollbars.png index e70541be91c..0acf7691bdf 100644 Binary files a/packages/devtools_app/test/goldens/codeview_scrollbars.png and b/packages/devtools_app/test/goldens/codeview_scrollbars.png differ diff --git a/packages/devtools_app/test/goldens/instance_viewer/edit.png b/packages/devtools_app/test/goldens/instance_viewer/edit.png index 17d272e0ca6..ae774bd620b 100644 Binary files a/packages/devtools_app/test/goldens/instance_viewer/edit.png and b/packages/devtools_app/test/goldens/instance_viewer/edit.png differ diff --git a/packages/devtools_app/test/goldens/integration_animated_physical_model_selected.png b/packages/devtools_app/test/goldens/integration_animated_physical_model_selected.png index 6339f0db4d1..f5ab45a9e54 100644 Binary files a/packages/devtools_app/test/goldens/integration_animated_physical_model_selected.png and b/packages/devtools_app/test/goldens/integration_animated_physical_model_selected.png differ diff --git a/packages/devtools_app/test/goldens/integration_inspector_errors_1_initial_load.png b/packages/devtools_app/test/goldens/integration_inspector_errors_1_initial_load.png index d20c7f58d27..f4bdd41b73b 100644 Binary files a/packages/devtools_app/test/goldens/integration_inspector_errors_1_initial_load.png and b/packages/devtools_app/test/goldens/integration_inspector_errors_1_initial_load.png differ diff --git a/packages/devtools_app/test/goldens/integration_inspector_errors_2_error_selected.png b/packages/devtools_app/test/goldens/integration_inspector_errors_2_error_selected.png index 8caef6f3ab7..1a1063d0bb7 100644 Binary files a/packages/devtools_app/test/goldens/integration_inspector_errors_2_error_selected.png and b/packages/devtools_app/test/goldens/integration_inspector_errors_2_error_selected.png differ diff --git a/packages/devtools_app/test/goldens/integration_inspector_initial_load.png b/packages/devtools_app/test/goldens/integration_inspector_initial_load.png index 98396750cd4..4d4456e989f 100644 Binary files a/packages/devtools_app/test/goldens/integration_inspector_initial_load.png and b/packages/devtools_app/test/goldens/integration_inspector_initial_load.png differ diff --git a/packages/devtools_app/test/goldens/integration_inspector_richtext_selected.png b/packages/devtools_app/test/goldens/integration_inspector_richtext_selected.png index 47e02c76a55..6af8ef339ba 100644 Binary files a/packages/devtools_app/test/goldens/integration_inspector_richtext_selected.png and b/packages/devtools_app/test/goldens/integration_inspector_richtext_selected.png differ diff --git a/packages/devtools_app/test/goldens/integration_inspector_scaffold_selected.png b/packages/devtools_app/test/goldens/integration_inspector_scaffold_selected.png index 269b2a435e6..26f8da18bbb 100644 Binary files a/packages/devtools_app/test/goldens/integration_inspector_scaffold_selected.png and b/packages/devtools_app/test/goldens/integration_inspector_scaffold_selected.png differ diff --git a/packages/devtools_app/test/goldens/integration_inspector_select_center.png b/packages/devtools_app/test/goldens/integration_inspector_select_center.png index 03cbed3258b..f0db4886e9b 100644 Binary files a/packages/devtools_app/test/goldens/integration_inspector_select_center.png and b/packages/devtools_app/test/goldens/integration_inspector_select_center.png differ diff --git a/packages/devtools_app/test/goldens/integration_inspector_select_center_details_tree.png b/packages/devtools_app/test/goldens/integration_inspector_select_center_details_tree.png index aae8db6603e..9ae54dc4ff2 100644 Binary files a/packages/devtools_app/test/goldens/integration_inspector_select_center_details_tree.png and b/packages/devtools_app/test/goldens/integration_inspector_select_center_details_tree.png differ diff --git a/packages/devtools_app/test/goldens/provider_screen/list_error_banner.png b/packages/devtools_app/test/goldens/provider_screen/list_error_banner.png index f48178eeaf1..565e9d7d5fe 100644 Binary files a/packages/devtools_app/test/goldens/provider_screen/list_error_banner.png and b/packages/devtools_app/test/goldens/provider_screen/list_error_banner.png differ diff --git a/packages/devtools_app/test/goldens/provider_screen/no_selected_provider.png b/packages/devtools_app/test/goldens/provider_screen/no_selected_provider.png index ce95207519c..a47648fbad2 100644 Binary files a/packages/devtools_app/test/goldens/provider_screen/no_selected_provider.png and b/packages/devtools_app/test/goldens/provider_screen/no_selected_provider.png differ diff --git a/packages/devtools_app/test/goldens/provider_screen/selected_provider.png b/packages/devtools_app/test/goldens/provider_screen/selected_provider.png index a51f89a461d..8426b4a38c1 100644 Binary files a/packages/devtools_app/test/goldens/provider_screen/selected_provider.png and b/packages/devtools_app/test/goldens/provider_screen/selected_provider.png differ diff --git a/packages/devtools_app/test/goldens/provider_screen/selected_provider_error_banner.png b/packages/devtools_app/test/goldens/provider_screen/selected_provider_error_banner.png index 09fc6abb747..faa5682761e 100644 Binary files a/packages/devtools_app/test/goldens/provider_screen/selected_provider_error_banner.png and b/packages/devtools_app/test/goldens/provider_screen/selected_provider_error_banner.png differ diff --git a/packages/devtools_app/test/inspector_integration_test.dart b/packages/devtools_app/test/inspector_integration_test.dart index 13493618bd6..7997953c910 100644 --- a/packages/devtools_app/test/inspector_integration_test.dart +++ b/packages/devtools_app/test/inspector_integration_test.dart @@ -85,7 +85,7 @@ void main() async { ); // Select the details tree. - await tester.tap(find.text('Details Tree')); + await tester.tap(find.text('Widget Details Tree')); await tester.pumpAndSettle(inspectorChangeSettleTime); await expectLater( find.byType(InspectorScreenBody), @@ -111,8 +111,10 @@ void main() async { // icons is "Default value". // Test selecting a widget. + // Two 'Scaffold's: a breadcrumb and an actual tree item + expect(find.richText('Scaffold'), findsNWidgets(2)); // select Scaffold widget in summary tree. - await tester.tap(find.richText('Scaffold')); + await tester.tap(find.richText('Scaffold').last); await tester.pumpAndSettle(inspectorChangeSettleTime); // This tree is huge. If there is a change to package:flutter it may // change. If this happens don't panic and rebaseline the golden. diff --git a/packages/devtools_app/test/inspector_screen_test.dart b/packages/devtools_app/test/inspector_screen_test.dart index b680ef784a1..38b1cc91b38 100644 --- a/packages/devtools_app/test/inspector_screen_test.dart +++ b/packages/devtools_app/test/inspector_screen_test.dart @@ -97,7 +97,7 @@ void main() { await tester.pumpWidget(buildInspectorScreen()); expect(find.byType(InspectorScreenBody), findsOneWidget); - expect(find.text('Refresh Tree'), findsOneWidget); + expect(find.byTooltip('Refresh Tree'), findsOneWidget); expect(find.text(extensions.debugPaint.title), findsOneWidget); // Make sure there is not an overflow if the window is narrow. // TODO(jacobr): determine why there are overflows in the test environment diff --git a/packages/devtools_app/test/inspector_tree_test.dart b/packages/devtools_app/test/inspector_tree_test.dart index 0fd72a96f67..0e39e354215 100644 --- a/packages/devtools_app/test/inspector_tree_test.dart +++ b/packages/devtools_app/test/inspector_tree_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:devtools_app/src/globals.dart'; +import 'package:devtools_app/src/inspector/inspector_breadcrumbs.dart'; import 'package:devtools_app/src/inspector/inspector_service.dart'; import 'package:devtools_app/src/inspector/inspector_tree.dart'; import 'package:devtools_app/src/inspector/inspector_tree_controller.dart'; @@ -41,6 +42,7 @@ void main() { await tester.pumpWidget(wrap(InspectorTree( controller: controller, debuggerController: debuggerController, + inspectorTreeController: InspectorTreeController(), ))); expect(controller.getRow(const Offset(0, -100.0)), isNull); @@ -53,6 +55,7 @@ void main() { await tester.pumpWidget(wrap(InspectorTree( controller: controller, debuggerController: debuggerController, + inspectorTreeController: InspectorTreeController(), ))); expect(controller.getRow(const Offset(0, -20)).index, 0); @@ -77,6 +80,7 @@ void main() { await tester.pumpWidget(wrap(InspectorTree( controller: treeController, debuggerController: TestDebuggerController(), + inspectorTreeController: InspectorTreeController(), ))); expect(find.richText('Text: "Content"'), findsOneWidget); @@ -99,6 +103,7 @@ void main() { await tester.pumpWidget(wrap(InspectorTree( controller: treeController, debuggerController: TestDebuggerController(), + inspectorTreeController: InspectorTreeController(), ))); expect(find.richText('Text: "Rich text"'), findsOneWidget); @@ -117,10 +122,49 @@ void main() { wrap(InspectorTree( controller: treeController, debuggerController: TestDebuggerController(), + inspectorTreeController: InspectorTreeController(), )), ); expect(find.richText('Text: "Multiline text content"'), findsOneWidget); }); + + testWidgets('Shows breadcrumbs in Widget detail tree', (tester) async { + final diagnosticNode = await widgetToInspectorTreeDiagnosticsNode( + widget: const Text('Hello'), + tester: tester, + ); + + final controller = inspectorTreeControllerFromNode(diagnosticNode); + await tester.pumpWidget(wrap( + InspectorTree( + controller: controller, + debuggerController: TestDebuggerController(), + inspectorTreeController: InspectorTreeController(), + // ignore: avoid_redundant_argument_values + isSummaryTree: false, + ), + )); + + expect(find.byType(InspectorBreadcrumbNavigator), findsOneWidget); + }); + + testWidgets('Shows no breadcrumbs widget in summary tree', (tester) async { + final diagnosticNode = await widgetToInspectorTreeDiagnosticsNode( + widget: const Text('Hello'), + tester: tester, + ); + + final controller = inspectorTreeControllerFromNode(diagnosticNode); + await tester.pumpWidget(wrap( + InspectorTree( + controller: controller, + debuggerController: TestDebuggerController(), + isSummaryTree: true, + ), + )); + + expect(find.byType(InspectorBreadcrumbNavigator), findsNothing); + }); }); } diff --git a/packages/devtools_app/test/instance_viewer/instance_viewer_test.dart b/packages/devtools_app/test/instance_viewer/instance_viewer_test.dart index f764751e28b..9e09a2ac1d6 100644 --- a/packages/devtools_app/test/instance_viewer/instance_viewer_test.dart +++ b/packages/devtools_app/test/instance_viewer/instance_viewer_test.dart @@ -3,10 +3,13 @@ // found in the LICENSE file. import 'package:devtools_app/src/eval_on_dart_library.dart'; +import 'package:devtools_app/src/globals.dart'; import 'package:devtools_app/src/provider/instance_viewer/instance_details.dart'; import 'package:devtools_app/src/provider/instance_viewer/instance_providers.dart'; import 'package:devtools_app/src/provider/instance_viewer/instance_viewer.dart'; import 'package:devtools_app/src/provider/instance_viewer/result.dart'; +import 'package:devtools_app/src/service_manager.dart'; +import 'package:devtools_test/mocks.dart'; import 'package:devtools_test/utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -115,6 +118,10 @@ final enumValueInstance = AsyncValue.data( void main() { setUpAll(() => loadFonts()); + setUp(() { + setGlobal(ServiceConnectionManager, FakeServiceManager()); + }); + group('InstanceViewer', () { testWidgets( 'showInternalProperties: false hides private properties from dependencies', diff --git a/packages/devtools_app/test/logging_screen_test.dart b/packages/devtools_app/test/logging_screen_test.dart index 46481b0f5f0..5f9fd220a82 100644 --- a/packages/devtools_app/test/logging_screen_test.dart +++ b/packages/devtools_app/test/logging_screen_test.dart @@ -42,6 +42,8 @@ void main() { when(mockLoggingController.search).thenReturn(''); when(mockLoggingController.searchMatches) .thenReturn(ValueNotifier>([])); + when(mockLoggingController.searchInProgressNotifier) + .thenReturn(ValueNotifier(false)); when(mockLoggingController.matchIndex).thenReturn(ValueNotifier(0)); when(mockLoggingController.filteredData) .thenReturn(ListValueNotifier([])); diff --git a/packages/devtools_app/test/utils_test.dart b/packages/devtools_app/test/utils_test.dart index 32c46a844ee..f86af49f30e 100644 --- a/packages/devtools_app/test/utils_test.dart +++ b/packages/devtools_app/test/utils_test.dart @@ -1249,6 +1249,45 @@ void main() { isFalse, ); }); + + test('caseInsensitiveEquals', () { + const str = 'hello, world!'; + expect(str.caseInsensitiveEquals(str), isTrue); + expect(str.caseInsensitiveEquals('HELLO, WORLD!'), isTrue); + expect(str.caseInsensitiveEquals('hElLo, WoRlD!'), isTrue); + expect(str.caseInsensitiveEquals('hello'), isFalse); + expect(str.caseInsensitiveEquals(''), isFalse); + expect(str.caseInsensitiveEquals(null), isFalse); + expect(''.caseInsensitiveEquals(''), isTrue); + expect(''.caseInsensitiveEquals(null), isFalse); + }); + + test('caseInsensitiveAllMatches', () { + const str = 'This is a TEST. Test string is "test"'; + final matches = 'test'.caseInsensitiveAllMatches(str).toList(); + expect(matches.length, equals(3)); + + // First match: 'TEST' + expect(matches[0].start, equals(10)); + expect(matches[0].end, equals(14)); + + // Second match: 'Test' + expect(matches[1].start, equals(16)); + expect(matches[1].end, equals(20)); + + // Third match: 'test' + expect(matches[2].start, equals(32)); + expect(matches[2].end, equals(36)); + + // Dart's allMatches returns 1 char matches when pattern is an empty string + expect( + ''.caseInsensitiveAllMatches('hello world').length, + equals('hello world'.length + 1), + ); + expect('*'.caseInsensitiveAllMatches('hello world'), isEmpty); + expect('test'.caseInsensitiveAllMatches(''), isEmpty); + expect('test'.caseInsensitiveAllMatches(null), isEmpty); + }); }); group('BoolExtension', () {