Skip to content

Adds a basic dialog for opening a file #3342

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

Merged
merged 16 commits into from
Sep 8, 2021
Merged
51 changes: 49 additions & 2 deletions packages/devtools_app/lib/src/debugger/codeview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -1053,7 +1056,11 @@ class ScriptPopupMenuOption {
}
}

final defaultScriptPopupMenuOptions = [copyScriptNameOption, goToLineOption];
final defaultScriptPopupMenuOptions = [
copyScriptNameOption,
goToLineOption,
if (openFileDialogEnabled) openFileOption,
];

final copyScriptNameOption = ScriptPopupMenuOption(
label: 'Copy filename',
Expand All @@ -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);

Expand Down Expand Up @@ -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(),
],
);
}
}
16 changes: 16 additions & 0 deletions packages/devtools_app/lib/src/debugger/debugger_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,14 @@ class DebuggerScreenBodyState extends State<DebuggerScreenBody>
goToLineNumberKeySet: GoToLineNumberIntent(context, controller),
searchInFileKeySet: SearchInFileIntent(controller),
escapeKeySet: EscapeIntent(context, controller),
openFileKeySet: OpenFileIntent(context, controller),
},
child: Actions(
actions: <Type, Action<Intent>>{
GoToLineNumberIntent: GoToLineNumberAction(),
SearchInFileIntent: SearchInFileAction(),
EscapeIntent: EscapeAction(),
OpenFileIntent: OpenFileAction(),
},
child: Split(
axis: Axis.horizontal,
Expand Down Expand Up @@ -295,6 +297,20 @@ class EscapeAction extends Action<EscapeIntent> {
}
}

class OpenFileIntent extends Intent {
const OpenFileIntent(this._context, this._controller);

final BuildContext _context;
final DebuggerController _controller;
}

class OpenFileAction extends Action<OpenFileIntent> {
@override
void invoke(OpenFileIntent intent) {
showOpenFileDialog(intent._context, intent._controller);
}
}

class DebuggerStatus extends StatefulWidget {
const DebuggerStatus({
Key key,
Expand Down
143 changes: 143 additions & 0 deletions packages/devtools_app/lib/src/debugger/file_search.dart
Original file line number Diff line number Diff line change
@@ -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<FileSearchField>
with SearchFieldMixin, AutoDisposeMixin {
AutoCompleteController _autoCompleteController;

final _scriptsCache = <String, ScriptRef>{};

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());
Copy link
Member

Choose a reason for hiding this comment

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

can you add a comment explaining why this needs to be in a post frame callback?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done, thanks!

}

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<ScriptRef> findMatches(
String query,
List<ScriptRef> scriptRefs,
) {
if (query.isEmpty) {
takeTopMatches(scriptRefs);
Copy link
Member

Choose a reason for hiding this comment

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

should we be returning this value?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, the idea is to imitate Chrome DevTools. So if the query is empty, we show a list of files. If there is a query and it doesn't match, we show no files:

chrome file opener

}

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<ScriptRef> takeTopMatches(List<ScriptRef> allMatches) {
if (allMatches.length <= numOfMatchesToShow) {
return allMatches;
}

return allMatches.sublist(0, numOfMatchesToShow);
}
3 changes: 1 addition & 2 deletions packages/devtools_app/lib/src/debugger/key_sets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
3 changes: 2 additions & 1 deletion packages/devtools_app/lib/src/ui/search.dart
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ mixin SearchFieldMixin<T extends StatefulWidget> on State<T> {
InputDecoration decoration,
bool tracking = false,
bool supportClearField = false,
bool closeOverlayOnEscape = true,
}) {
_onSelection = onSelection;

Expand All @@ -539,7 +540,7 @@ mixin SearchFieldMixin<T extends StatefulWidget> on State<T> {
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
Expand Down