diff --git a/packages/devtools_app/lib/src/debugger/codeview.dart b/packages/devtools_app/lib/src/debugger/codeview.dart index 7648397116d..0877649a1a8 100644 --- a/packages/devtools_app/lib/src/debugger/codeview.dart +++ b/packages/devtools_app/lib/src/debugger/codeview.dart @@ -28,9 +28,12 @@ import 'breakpoints.dart'; import 'common.dart'; import 'debugger_controller.dart'; import 'debugger_model.dart'; +import 'file_search.dart'; import 'hover.dart'; import 'variables.dart'; +const openFileDialogEnabled = false; + final debuggerCodeViewSearchKey = GlobalKey(debugLabel: 'DebuggerCodeViewSearchKey'); @@ -1053,7 +1056,11 @@ class ScriptPopupMenuOption { } } -final defaultScriptPopupMenuOptions = [copyScriptNameOption, goToLineOption]; +final defaultScriptPopupMenuOptions = [ + copyScriptNameOption, + goToLineOption, + if (openFileDialogEnabled) openFileOption, +]; final copyScriptNameOption = ScriptPopupMenuOption( label: 'Copy filename', @@ -1071,11 +1078,26 @@ void showGoToLineDialog(BuildContext context, DebuggerController controller) { } const goToLineOption = ScriptPopupMenuOption( - label: 'Go to line number', + label: 'Go to line number (⌘ G)', icon: Icons.list, onSelected: showGoToLineDialog, ); +void showOpenFileDialog(BuildContext context, DebuggerController controller) { + if (openFileDialogEnabled) { + showDialog( + context: context, + builder: (context) => OpenFileDialog(controller), + ); + } +} + +const openFileOption = ScriptPopupMenuOption( + label: 'Open file (⌘ P)', + icon: Icons.folder_open, + onSelected: showOpenFileDialog, +); + class GoToLineDialog extends StatelessWidget { const GoToLineDialog(this._debuggerController); @@ -1120,3 +1142,28 @@ class GoToLineDialog extends StatelessWidget { ); } } + +class OpenFileDialog extends StatelessWidget { + const OpenFileDialog(this._debuggerController); + + final DebuggerController _debuggerController; + + @override + Widget build(BuildContext context) { + return DevToolsDialog( + title: dialogTitleText(Theme.of(context), 'Open file'), + includeDivider: false, + content: Container( + alignment: Alignment.topCenter, + height: 325, + width: 500, + child: FileSearchField( + controller: _debuggerController, + ), + ), + actions: const [ + DialogCancelButton(), + ], + ); + } +} diff --git a/packages/devtools_app/lib/src/debugger/debugger_screen.dart b/packages/devtools_app/lib/src/debugger/debugger_screen.dart index bc67121b517..0bcde4584a9 100644 --- a/packages/devtools_app/lib/src/debugger/debugger_screen.dart +++ b/packages/devtools_app/lib/src/debugger/debugger_screen.dart @@ -160,12 +160,14 @@ class DebuggerScreenBodyState extends State goToLineNumberKeySet: GoToLineNumberIntent(context, controller), searchInFileKeySet: SearchInFileIntent(controller), escapeKeySet: EscapeIntent(context, controller), + openFileKeySet: OpenFileIntent(context, controller), }, child: Actions( actions: >{ GoToLineNumberIntent: GoToLineNumberAction(), SearchInFileIntent: SearchInFileAction(), EscapeIntent: EscapeAction(), + OpenFileIntent: OpenFileAction(), }, child: Split( axis: Axis.horizontal, @@ -295,6 +297,20 @@ class EscapeAction extends Action { } } +class OpenFileIntent extends Intent { + const OpenFileIntent(this._context, this._controller); + + final BuildContext _context; + final DebuggerController _controller; +} + +class OpenFileAction extends Action { + @override + void invoke(OpenFileIntent intent) { + showOpenFileDialog(intent._context, intent._controller); + } +} + class DebuggerStatus extends StatefulWidget { const DebuggerStatus({ Key key, diff --git a/packages/devtools_app/lib/src/debugger/file_search.dart b/packages/devtools_app/lib/src/debugger/file_search.dart new file mode 100644 index 00000000000..9193c4bb56d --- /dev/null +++ b/packages/devtools_app/lib/src/debugger/file_search.dart @@ -0,0 +1,143 @@ +// Copyright 2021 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'; +import 'package:flutter/scheduler.dart'; +import 'package:vm_service/vm_service.dart'; + +import '../auto_dispose_mixin.dart'; +import '../common_widgets.dart'; +import '../dialogs.dart'; +import '../theme.dart'; +import '../ui/search.dart'; +import '../utils.dart'; +import 'debugger_controller.dart'; +import 'debugger_model.dart'; + +const int numOfMatchesToShow = 6; + +class FileSearchField extends StatefulWidget { + const FileSearchField({ + @required this.controller, + }); + + final DebuggerController controller; + + @override + _FileSearchFieldState createState() => _FileSearchFieldState(); +} + +class _FileSearchFieldState extends State + with SearchFieldMixin, AutoDisposeMixin { + AutoCompleteController _autoCompleteController; + + final _scriptsCache = {}; + + final fileSearchFieldKey = GlobalKey(debugLabel: 'fileSearchFieldKey'); + + @override + void initState() { + super.initState(); + + _autoCompleteController = AutoCompleteController()..currentDefaultIndex = 0; + + addAutoDisposeListener( + _autoCompleteController.searchNotifier, _handleSearch); + addAutoDisposeListener(_autoCompleteController.searchAutoCompleteNotifier, + _handleAutoCompleteOverlay); + + // Open the autocomplete results immediately before a query is entered: + SchedulerBinding.instance.addPostFrameCallback((_) => _handleSearch()); + } + + void _handleSearch() { + final query = _autoCompleteController.search; + final matches = findMatches(query, widget.controller.sortedScripts.value); + if (matches.isEmpty) { + _autoCompleteController.searchAutoComplete.value = ['No files found.']; + } else { + matches.forEach(_addScriptRefToCache); + _autoCompleteController.searchAutoComplete.value = + matches.map((scriptRef) => scriptRef.uri).toList(); + } + } + + void _handleAutoCompleteOverlay() { + _autoCompleteController.handleAutoCompleteOverlay( + context: context, + searchFieldKey: fileSearchFieldKey, + onTap: _onSelection, + ); + } + + void _addScriptRefToCache(ScriptRef scriptRef) { + _scriptsCache.putIfAbsent(scriptRef.uri, () => scriptRef); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + border: Border( + top: defaultBorderSide(theme), + ), + ), + padding: const EdgeInsets.all(denseSpacing), + child: buildAutoCompleteSearchField( + controller: _autoCompleteController, + searchFieldKey: fileSearchFieldKey, + searchFieldEnabled: true, + shouldRequestFocus: true, + closeOverlayOnEscape: false, + onSelection: _onSelection, + ), + ); + } + + void _onSelection(String scriptUri) { + final scriptRef = _scriptsCache[scriptUri]; + widget.controller.showScriptLocation(ScriptLocation(scriptRef)); + _scriptsCache.clear(); + Navigator.of(context).pop(dialogDefaultContext); + } + + @override + void dispose() { + _autoCompleteController.dispose(); + super.dispose(); + } +} + +List findMatches( + String query, + List scriptRefs, +) { + if (query.isEmpty) { + takeTopMatches(scriptRefs); + } + + final exactMatches = scriptRefs + .where((scriptRef) => scriptRef.uri.caseInsensitiveContains(query)) + .toList(); + + if (exactMatches.length >= numOfMatchesToShow) { + return takeTopMatches(exactMatches); + } + + final fuzzyMatches = scriptRefs + .where((scriptRef) => scriptRef.uri.caseInsensitiveFuzzyMatch(query)) + .toList(); + + return takeTopMatches([...exactMatches, ...fuzzyMatches]); +} + +List takeTopMatches(List allMatches) { + if (allMatches.length <= numOfMatchesToShow) { + return allMatches; + } + + return allMatches.sublist(0, numOfMatchesToShow); +} diff --git a/packages/devtools_app/lib/src/debugger/key_sets.dart b/packages/devtools_app/lib/src/debugger/key_sets.dart index 63c537d07b6..a6c8c2ad199 100644 --- a/packages/devtools_app/lib/src/debugger/key_sets.dart +++ b/packages/devtools_app/lib/src/debugger/key_sets.dart @@ -21,10 +21,9 @@ final LogicalKeySet escapeKeySet = LogicalKeySet( LogicalKeyboardKey.escape, ); -// TODO(elliette): Change to cmd/ctrl + P once focus library filter is removed. final LogicalKeySet openFileKeySet = LogicalKeySet( HostPlatform.instance.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, - LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyP, ); diff --git a/packages/devtools_app/lib/src/ui/search.dart b/packages/devtools_app/lib/src/ui/search.dart index f5a31717a85..a66fce236b0 100644 --- a/packages/devtools_app/lib/src/ui/search.dart +++ b/packages/devtools_app/lib/src/ui/search.dart @@ -528,6 +528,7 @@ mixin SearchFieldMixin on State { InputDecoration decoration, bool tracking = false, bool supportClearField = false, + bool closeOverlayOnEscape = true, }) { _onSelection = onSelection; @@ -539,7 +540,7 @@ mixin SearchFieldMixin on State { if (event is RawKeyDownEvent) { final key = event.data.logicalKey.keyId & LogicalKeyboardKey.valueMask; - if (key == escape) { + if (key == escape && closeOverlayOnEscape) { // TODO(kenz): Enable this once we find a way around the navigation // this causes. This triggers a "back" navigation. // ESCAPE key pressed clear search TextField.c