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

Eval Console Autocomplete #3013

Merged
merged 6 commits into from
May 13, 2021
Merged
Show file tree
Hide file tree
Changes from 2 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
221 changes: 198 additions & 23 deletions packages/devtools_app/lib/src/debugger/evaluate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:vm_service/vm_service.dart';

import '../auto_dispose.dart';
import '../auto_dispose_mixin.dart';
import '../notifications.dart';
import '../theme.dart';
import '../ui/search.dart';
import 'debugger_controller.dart';

// TODO(devoncarew): We'll want some kind of code completion w/ eval.
// TODO(devoncarew): We should insert eval result objects into the console as
// expandable objects.

Expand All @@ -24,17 +29,142 @@ class ExpressionEvalField extends StatefulWidget {
_ExpressionEvalFieldState createState() => _ExpressionEvalFieldState();
}

class _ExpressionEvalFieldState extends State<ExpressionEvalField> {
TextEditingController textController;
FocusNode textFocus;
class _AutoCompleteController extends DisposableController
with SearchControllerMixin, AutoCompleteSearchControllerMixin {}
Copy link
Member

Choose a reason for hiding this comment

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

should this be public and in the search.dart file?


class _ExpressionEvalFieldState extends State<ExpressionEvalField>
with SearchFieldMixin, AutoDisposeMixin {
_AutoCompleteController _autoCompleteController;
int historyPosition = -1;

final evalTextFieldKey = GlobalKey(debugLabel: 'evalTextFieldKey');

@override
void initState() {
super.initState();

textController = TextEditingController();
textFocus = FocusNode();
_autoCompleteController = _AutoCompleteController();
}

@override
void didChangeDependencies() {
super.didChangeDependencies();

addAutoDisposeListener(_autoCompleteController.searchNotifier, () {
_autoCompleteController.handleAutoCompleteOverlay(
context: context,
searchFieldKey: evalTextFieldKey,
onTap: _onSelection,
bottom: false,
maxWidth: false,
);
});
addAutoDisposeListener(
_autoCompleteController.selectTheSearchNotifier, _handleSearch);
addAutoDisposeListener(
_autoCompleteController.searchNotifier, _handleSearch);
}

void _handleSearch() async {
final searchingValue = _autoCompleteController.search;
final isField = searchingValue.endsWith('.');

if (searchingValue.isNotEmpty) {
if (_autoCompleteController.selectTheSearch) {
_autoCompleteController.resetSearch();
return;
}

// No exact match, return the list of possible matches.
_autoCompleteController.clearSearchAutoComplete();

// Find word in TextField to try and match (word breaks).
final textFieldEditingValue = searchTextFieldController.value;
final selection = textFieldEditingValue.selection;

final parts = AutoCompleteSearchControllerMixin.activeEdtingParts(
searchingValue,
selection,
handleFields: isField,
);

// Only show pop-up if there's a real variable name or field.
if (parts.activeWord.isEmpty && !parts.isField) return;

final matches = await _matchesFor(parts);

final normalizedMatches = matches.toSet().toList()..sort();
_autoCompleteController.searchAutoComplete.value =
normalizedMatches.sublist(
0,
min(defaultTopMatchesLimit, normalizedMatches.length),
);
} else {
_autoCompleteController.closeAutoCompleteOverlay();
}
}

Future<List<String>> _matchesFor(EditingParts parts) async {
final result = <String>{};
if (!parts.isField) {
for (var variable in widget.controller.variables.value) {
if (variable.boundVar.name.startsWith(parts.activeWord)) {
result.add(variable.boundVar.name);
}
}
} else {
try {
var left = parts.leftSide.split(' ').last;
// Removing trailing `.`.
left = left.substring(0, left.length - 1);

// Response is either a ErrorRef, InstanceRef, or Sentinel.
final response = await widget.controller.evalAtCurrentFrame(left);

// Display the response to the user.
if (response is InstanceRef) {
final Instance instance = await widget.controller.getObject(response);
result.addAll(await _autoCompleteProperties(instance.classRef));
// TODO(grouma) - This shouldn't be necessary but package:dwds does
// not properly provide superclass information.
result.addAll(instance.fields.map((field) => field.decl.name));
result.removeWhere((prop) => !prop.startsWith(parts.activeWord));
}
} catch (_) {}
}
return result.toList();
}

Future<List<String>> _autoCompleteProperties(ClassRef classRef) async {
final result = <String>[];
if (classRef != null) {
final Class clazz = await widget.controller.getObject(classRef);
result.addAll(clazz.fields.map((field) => field.name));
result.addAll(clazz.functions
.where((funcRef) => _validFunction(funcRef, clazz))
// The VM shows setters as `<member>=`.
.map((funcRef) => funcRef.name.replaceAll('=', '')));
result.addAll(await _autoCompleteProperties(clazz.superClass));
result.removeWhere((member) => !_isAccessible(member, clazz));
}
return result;
}

bool _validFunction(FuncRef funcRef, Class clazz) {
return !funcRef.isStatic &&
!_isContructor(funcRef, clazz) &&
funcRef.name != '==';
}

bool _isContructor(FuncRef funcRef, Class clazz) =>
funcRef.name == clazz.name || funcRef.name.startsWith('${clazz.name}.');

bool _isAccessible(String member, Class clazz) {
final frame = widget.controller.selectedStackFrame.value?.frame ??
Copy link
Contributor

Choose a reason for hiding this comment

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

why is the ?? needed. Can we just always have the first frame be selected? Seems like that is the UI state in the debugger anyway.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is a bit of copied code from the evaluation logic. I'm not exactly sure why it's required but the added defensiveness seemed important.

Copy link
Contributor

Choose a reason for hiding this comment

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

I would add it to the controller. Maybe "currentStackFrame".

widget.controller.stackFramesWithLocation.value.first.frame;
final currentScript = frame.location.script;
return !(member.startsWith('_') &&
currentScript.id != clazz.location?.script?.id);
}

@override
Expand All @@ -61,15 +191,28 @@ class _ExpressionEvalFieldState extends State<ExpressionEvalField> {
} else if (event.isKeyPressed(LogicalKeyboardKey.arrowDown)) {
_historyNavDown();
return KeyEventResult.handled;
} else if (event.isKeyPressed(LogicalKeyboardKey.enter)) {
_handleExpressionEval();
return KeyEventResult.handled;
}

return KeyEventResult.ignored;
},
child: TextField(
onSubmitted: (value) => _handleExpressionEval(context, value),
focusNode: textFocus,
decoration: null,
controller: textController,
child: buildAutoCompleteSearchField(
controller: _autoCompleteController,
searchFieldKey: evalTextFieldKey,
searchFieldEnabled: true,
shouldRequestFocus: false,
supportClearField: true,
onSelection: _onSelection,
tracking: true,
decoration: const InputDecoration(
contentPadding: EdgeInsets.all(denseSpacing),
border: OutlineInputBorder(),
focusedBorder: OutlineInputBorder(borderSide: evalBorder),
enabledBorder: OutlineInputBorder(borderSide: evalBorder),
labelText: 'Eval',
),
),
),
),
Expand All @@ -78,11 +221,47 @@ class _ExpressionEvalFieldState extends State<ExpressionEvalField> {
);
}

void _handleExpressionEval(
BuildContext context,
String expressionText,
) async {
textFocus.requestFocus();
void _onSelection(String word) {
setState(() {
_replaceActiveWord(word);
_autoCompleteController.selectTheSearch = false;
_autoCompleteController.closeAutoCompleteOverlay();
});
}

/// Replace the current activeWord (partial name) with the selected item from
/// the auto-complete list.
void _replaceActiveWord(String word) {
final textFieldEditingValue = searchTextFieldController.value;
final editingValue = textFieldEditingValue.text;
final selection = textFieldEditingValue.selection;

final parts = AutoCompleteSearchControllerMixin.activeEdtingParts(
editingValue,
selection,
handleFields: _autoCompleteController.isField,
);

// Add the newly selected auto-complete value.
final newValue = '${parts.leftSide}$word${parts.rightSide}';

// Update the value and caret position of the auto-completed word.
searchTextFieldController.value = TextEditingValue(
text: newValue,
selection: TextSelection.fromPosition(
// Update the caret position to just beyond the newly picked
// auto-complete item.
TextPosition(offset: parts.leftSide.length + word.length),
),
);
}

void _handleExpressionEval() async {
final expressionText = searchTextFieldController.value.text.trim();
updateSearchField(_autoCompleteController, '', 0);
clearSearchField(_autoCompleteController, force: true);

if (expressionText.isEmpty) return;

// Don't try to eval if we're not paused.
if (!widget.controller.isPaused.value) {
Expand All @@ -91,14 +270,11 @@ class _ExpressionEvalFieldState extends State<ExpressionEvalField> {
return;
}

expressionText = expressionText.trim();

widget.controller.appendStdio('> $expressionText\n');
setState(() {
historyPosition = -1;
widget.controller.evalHistory.pushEvalHistory(expressionText);
});
textController.clear();

try {
// Response is either a ErrorRef, InstanceRef, or Sentinel.
Expand Down Expand Up @@ -135,8 +311,7 @@ class _ExpressionEvalFieldState extends State<ExpressionEvalField> {

@override
void dispose() {
textFocus.dispose();
textController.dispose();
_autoCompleteController.dispose();
super.dispose();
}

Expand All @@ -150,7 +325,7 @@ class _ExpressionEvalFieldState extends State<ExpressionEvalField> {
evalHistory.navigateUp();

final text = evalHistory.currentText;
textController.value = TextEditingValue(
searchTextFieldController.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
Expand All @@ -167,7 +342,7 @@ class _ExpressionEvalFieldState extends State<ExpressionEvalField> {
evalHistory.navigateDown();

final text = evalHistory.currentText ?? '';
textController.value = TextEditingValue(
searchTextFieldController.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,12 +278,6 @@ class HeapTreeViewState extends State<HeapTree>

@override
void dispose() {
// Clean up the TextFieldController and FocusNode.
searchTextFieldController.dispose();
searchFieldFocusNode.dispose();

rawKeyboardFocusNode.dispose();

_animation.dispose();

super.dispose();
Expand Down
2 changes: 2 additions & 0 deletions packages/devtools_app/lib/src/theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,8 @@ const wideSearchTextWidth = 400.0;
const defaultSearchTextWidth = 200.0;
const defaultTextFieldHeight = 32.0;

const evalBorder = BorderSide(color: Colors.white, width: 2);

/// Default color of cursor and color used by search's TextField.
/// Guarantee that the Search TextField on all platforms renders in the same
/// color for border, label text, and cursor. Primarly, so golden screen
Expand Down
Loading