DevTools: Give argument hints in the console prompt

https://i.imgur.com/Y3OlPM2.png

Change-Id: Ic1d9ef1d47ff93ce77bfc3ae7173689bccf0cb2c
Reviewed-on: https://chromium-review.googlesource.com/1031096
Reviewed-by: Andrey Lushnikov <lushnikov@chromium.org>
Commit-Queue: Joel Einbinder <einbinder@chromium.org>
Cr-Original-Commit-Position: refs/heads/master@{#557030}
Cr-Mirrored-From: https://chromium.googlesource.com/chromium/src
Cr-Mirrored-Commit: 66bdf19b13c9a1dffbd47121101e0b8aedde3495
This commit is contained in:
Joel Einbinder
2018-05-09 00:43:10 +00:00
committed by Commit Bot
parent 4566e31350
commit 2fc0fb7ea3
9 changed files with 286 additions and 11 deletions
+1
View File
@@ -703,6 +703,7 @@ all_devtools_files = [
"front_end/terminal/xterm.js/build/xterm.js",
"front_end/test_runner/module.json",
"front_end/test_runner/TestRunner.js",
"front_end/text_editor/autocompleteTooltip.css",
"front_end/text_editor/cmdevtools.css",
"front_end/text_editor/CodeMirrorTextEditor.js",
"front_end/text_editor/CodeMirrorUtils.js",
+34 -3
View File
@@ -1,9 +1,7 @@
// Copyright 2016 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.
/**
* @unrestricted
*/
Console.ConsolePrompt = class extends UI.Widget {
constructor() {
super();
@@ -11,6 +9,7 @@ Console.ConsolePrompt = class extends UI.Widget {
this._history = new Console.ConsoleHistoryManager();
this._initialText = '';
/** @type {?UI.TextEditor} */
this._editor = null;
this._isBelowPromptEnabled = Runtime.experiments.isEnabled('consoleBelowPrompt');
this._eagerPreviewElement = createElementWithClass('div', 'console-eager-preview');
@@ -25,6 +24,8 @@ Console.ConsolePrompt = class extends UI.Widget {
this._eagerPreviewElement.classList.toggle('hidden', !this._eagerEvalSetting.get());
this.element.tabIndex = 0;
/** @type {?Promise} */
this._previewRequestForTest = null;
self.runtime.extension(UI.TextEditorFactory).instance().then(gotFactory.bind(this));
@@ -39,6 +40,7 @@ Console.ConsolePrompt = class extends UI.Widget {
this._editor.configureAutocomplete({
substituteRangeCallback: this._substituteRange.bind(this),
suggestionsCallback: this._wordsWithQuery.bind(this),
tooltipCallback: (lineNumber, columnNumber) => this._tooltipCallback(lineNumber, columnNumber)
});
this._editor.widget().element.addEventListener('keydown', this._editorKeyDown.bind(this), true);
this._editor.widget().show(this.element);
@@ -371,6 +373,35 @@ Console.ConsolePrompt = class extends UI.Widget {
.then(words => words.concat(historyWords));
}
/**
* @param {number} lineNumber
* @param {number} columnNumber
* @return {!Promise<?Element>}
*/
async _tooltipCallback(lineNumber, columnNumber) {
const before = this._editor.text(new TextUtils.TextRange(0, 0, lineNumber, columnNumber));
const result = await ObjectUI.javaScriptAutocomplete.argumentsHint(before);
if (!result)
return null;
const argumentsElement = createElement('span');
for (let i = 0; i < result.args.length; i++) {
if (i === result.argumentIndex || (i < result.argumentIndex && result.args[i].startsWith('...'))) {
const boldElement = createElement('b');
boldElement.textContent = result.args[i];
argumentsElement.appendChild(boldElement);
} else {
argumentsElement.createTextChild(result.args[i]);
}
if (i < result.args.length - 1)
argumentsElement.createTextChild(', ');
}
const tooltip = createElementWithClass('span', 'source-code');
tooltip.createTextChild('\u0192(');
tooltip.appendChild(argumentsElement);
tooltip.createTextChild(')');
return tooltip;
}
_editorSetForTest() {
}
};
@@ -239,6 +239,23 @@ Formatter.FormatterWorkerPool = class {
return /** @type {!Promise<?{baseExpression: string, possibleSideEffects:boolean}>} */ (
this._runTask('findLastExpression', {content}));
}
/**
* @param {string} content
* @return {!Promise<?{baseExpression: string, possibleSideEffects:boolean, argumentIndex: number}>}
*/
findLastFunctionCall(content) {
return /** @type {!Promise<?{baseExpression: string, possibleSideEffects:boolean, argumentIndex: number}>} */ (
this._runTask('findLastFunctionCall', {content}));
}
/**
* @param {string} content
* @return {!Promise<!Array<string>>}
*/
argumentsList(content) {
return /** @type {!Promise<!Array<string>>} */ (this._runTask('argumentsList', {content}));
}
};
Formatter.FormatterWorkerPool.MaxWorkers = 2;
+121 -4
View File
@@ -87,6 +87,12 @@ self.onmessage = function(event) {
case 'findLastExpression':
postMessage(FormatterWorker.findLastExpression(params.content));
break;
case 'findLastFunctionCall':
postMessage(FormatterWorker.findLastFunctionCall(params.content));
break;
case 'argumentsList':
postMessage(FormatterWorker.argumentsList(params.content));
break;
default:
console.error('Unsupport method name: ' + method);
}
@@ -320,6 +326,95 @@ FormatterWorker.format = function(mimeType, text, indentString) {
postMessage(result);
};
/**
* @param {string} content
* @return {?{baseExpression: string, possibleSideEffects:boolean, argumentIndex: number}}
*/
FormatterWorker.findLastFunctionCall = function(content) {
if (content.length > 10000)
return null;
try {
const tokenizer = acorn.tokenizer(content, {ecmaVersion: 9});
while (tokenizer.getToken().type !== acorn.tokTypes.eof) {
}
} catch (e) {
return null;
}
const suffix = '000)';
const base = FormatterWorker._lastCompleteExpression(content, suffix, new Set(['CallExpression', 'NewExpression']));
if (!base)
return null;
const callee = base.baseNode['callee'];
const argumentIndex = base.baseNode['arguments'].length - 1;
const baseExpression = base.baseExpression.substring(callee.start, callee.end);
const possibleSideEffects = FormatterWorker._nodeHasPossibleSideEffects(callee);
return {baseExpression, possibleSideEffects, argumentIndex};
};
/**
* @param {string} content
* @return {!Array<string>}
*/
FormatterWorker.argumentsList = function(content) {
if (content.length > 10000)
return [];
let parsed = null;
try {
// Try to parse as a function, anonymous function, or arrow function.
parsed = acorn.parse(`(${content})`, {ecmaVersion: 9});
} catch (e) {
}
if (!parsed) {
try {
// Try to parse as a method.
parsed = acorn.parse(`({${content}})`, {ecmaVersion: 9});
} catch (e) {
}
}
if (!parsed || !parsed.body || !parsed.body[0] || !parsed.body[0].expression)
return [];
const expression = parsed.body[0].expression;
let params = null;
switch (expression.type) {
case 'ClassExpression':
if (!expression.body.body)
break;
const constructor = expression.body.body.find(method => method.kind === 'constructor');
if (constructor)
params = constructor.value.params;
break;
case 'ObjectExpression':
if (!expression.properties[0] || !expression.properties[0].value)
break;
params = expression.properties[0].value.params;
break;
case 'FunctionExpression':
case 'ArrowFunctionExpression':
params = expression.params;
break;
}
if (!params)
return [];
return params.map(paramName);
function paramName(param) {
switch (param.type) {
case 'Identifier':
return param.name;
case 'AssignmentPattern':
return '?' + paramName(param.left);
case 'ObjectPattern':
return 'obj';
case 'ArrayPattern':
return 'arr';
case 'RestElement':
return '...' + paramName(param.argument);
}
return '?';
}
};
/**
* @param {string} content
* @return {?{baseExpression: string, possibleSideEffects:boolean}}
@@ -335,8 +430,6 @@ FormatterWorker.findLastExpression = function(content) {
return null;
}
/** @type {!ESTree.Node} */
let ast;
const suffix = '.DEVTOOLS';
try {
acorn.parse(content + suffix, {ecmaVersion: 9});
@@ -345,6 +438,23 @@ FormatterWorker.findLastExpression = function(content) {
if (parseError.message.startsWith('Unexpected token') && parseError.pos === content.length)
return null;
}
const base = FormatterWorker._lastCompleteExpression(content, suffix, new Set(['MemberExpression', 'Identifier']));
if (!base)
return null;
const {baseExpression, baseNode} = base;
const possibleSideEffects = FormatterWorker._nodeHasPossibleSideEffects(baseNode);
return {baseExpression, possibleSideEffects};
};
/**
* @param {string} content
* @param {string} suffix
* @param {!Set<string>} types
* @return {?{baseNode: !ESTree.Node, baseExpression: string}}
*/
FormatterWorker._lastCompleteExpression = function(content, suffix, types) {
/** @type {!ESTree.Node} */
let ast;
let parsedContent = '';
for (let i = 0; i < content.length; i++) {
try {
@@ -357,7 +467,6 @@ FormatterWorker.findLastExpression = function(content) {
}
if (!ast)
return null;
const types = new Set(['MemberExpression', 'Identifier']);
let baseNode = null;
const walker = new FormatterWorker.ESTreeWalker(node => {
if (baseNode || node.end < ast.end)
@@ -371,6 +480,14 @@ FormatterWorker.findLastExpression = function(content) {
let baseExpression = parsedContent.substring(baseNode.start, parsedContent.length - suffix.length);
if (baseExpression.startsWith('{'))
baseExpression = `(${baseExpression})`;
return {baseNode, baseExpression};
};
/**
* @param {!ESTree.Node} baseNode
* @return {boolean}
*/
FormatterWorker._nodeHasPossibleSideEffects = function(baseNode) {
const sideEffectFreeTypes = new Set([
'MemberExpression', 'Identifier', 'BinaryExpression', 'Literal', 'TemplateLiteral', 'TemplateElement',
'ObjectExpression', 'ArrayExpression', 'Property', 'ThisExpression'
@@ -383,7 +500,7 @@ FormatterWorker.findLastExpression = function(content) {
return FormatterWorker.ESTreeWalker.SkipSubtree;
});
sideEffectwalker.walk(/** @type {!ESTree.Node} */ (baseNode));
return {baseExpression, possibleSideEffects};
return possibleSideEffects;
};
/**
@@ -32,6 +32,43 @@ ObjectUI.JavaScriptAutocomplete = class {
return mapCompletions.concat(expressionCompletions);
}
/**
* @param {string} fullText
* @return {!Promise<?{args: !Array<string>, argumentIndex: number}>}
*/
async argumentsHint(fullText) {
const functionCall = await Formatter.formatterWorkerPool().findLastFunctionCall(fullText);
if (!functionCall)
return null;
const executionContext = UI.context.flavor(SDK.ExecutionContext);
if (!executionContext)
return null;
const result = await executionContext.evaluate(
{
expression: functionCall.baseExpression,
objectGroup: 'argumentsHint',
includeCommandLineAPI: true,
silent: true,
returnByValue: false,
generatePreview: false,
throwOnSideEffect: functionCall.possibleSideEffects,
timeout: functionCall.possibleSideEffects ? 500 : undefined
},
/* userGesture */ false, /* awaitPromise */ false);
if (!result || result.exceptionDetails || !result.object || result.object.type !== 'function')
return null;
executionContext.runtimeModel.releaseObjectGroup('argumentsHint');
const description = result.object.description;
if (description.endsWith('{ [native code] }'))
return null; // TODO(einbinder) support native function argument hints
const args = await Formatter.formatterWorkerPool().argumentsList(description);
if (!args.length)
return null;
return {args, argumentIndex: functionCall.argumentIndex};
}
/**
* @param {string} text
* @param {string} query
@@ -22,7 +22,10 @@ TextEditor.TextEditorAutocompleteController = class {
this._changes = this._changes.bind(this);
this._blur = this._blur.bind(this);
this._beforeChange = this._beforeChange.bind(this);
this._mouseDown = this.clearAutocomplete.bind(this);
this._mouseDown = () => {
this.clearAutocomplete();
this._tooltipGlassPane.hide();
};
this._codeMirror.on('changes', this._changes);
this._lastHintText = '';
/** @type {?UI.SuggestBox} */
@@ -30,6 +33,14 @@ TextEditor.TextEditorAutocompleteController = class {
/** @type {?string} */
this._currentSuggestion = null;
this._hintElement = createElementWithClass('span', 'auto-complete-text');
this._tooltipGlassPane = new UI.GlassPane();
this._tooltipGlassPane.setSizeBehavior(UI.GlassPane.SizeBehavior.MeasureContent);
this._tooltipGlassPane.setOutsideClickCallback(this._tooltipGlassPane.hide.bind(this._tooltipGlassPane));
this._tooltipElement = createElementWithClass('div', 'autocomplete-tooltip');
const shadowRoot =
UI.createShadowRootWithCoreStyles(this._tooltipGlassPane.contentElement, 'text_editor/autocompleteTooltip.css');
shadowRoot.appendChild(this._tooltipElement);
}
_initializeIfNeeded() {
@@ -220,14 +231,14 @@ TextEditor.TextEditorAutocompleteController = class {
autocomplete(force) {
this._initializeIfNeeded();
if (this._codeMirror.somethingSelected()) {
this.clearAutocomplete();
this._hideSuggestBox();
return;
}
const cursor = this._codeMirror.getCursor('head');
const substituteRange = this._substituteRange(cursor.line, cursor.ch);
if (!substituteRange || !this._validateSelectionsContexts(substituteRange)) {
this.clearAutocomplete();
this._hideSuggestBox();
return;
}
@@ -246,7 +257,7 @@ TextEditor.TextEditorAutocompleteController = class {
function wordsAcquired(wordsWithQuery) {
if (!wordsWithQuery.length || (wordsWithQuery.length === 1 && query === wordsWithQuery[0].text) ||
(!this._suggestBox && hadSuggestBox)) {
this.clearAutocomplete();
this._hideSuggestBox();
this._onSuggestionsShownForTest([]);
return;
}
@@ -259,6 +270,8 @@ TextEditor.TextEditorAutocompleteController = class {
queryRange.startColumn !== oldQueryRange.startColumn)
this._updateAnchorBox();
this._suggestBox.updateSuggestions(this._anchorBox, wordsWithQuery, true, !this._isCursorAtEndOfLine(), query);
if (this._suggestBox.visible)
this._tooltipGlassPane.hide();
this._onSuggestionsShownForTest(wordsWithQuery);
}
}
@@ -311,6 +324,11 @@ TextEditor.TextEditorAutocompleteController = class {
}
clearAutocomplete() {
this._tooltipGlassPane.hide();
this._hideSuggestBox();
}
_hideSuggestBox() {
if (!this._suggestBox)
return;
this._suggestBox.hide();
@@ -328,6 +346,10 @@ TextEditor.TextEditorAutocompleteController = class {
* @return {boolean}
*/
keyDown(event) {
if (this._tooltipGlassPane.isShowing() && event.keyCode === UI.KeyboardShortcut.Keys.Esc.code) {
this._tooltipGlassPane.hide();
return true;
}
if (!this._suggestBox)
return false;
switch (event.keyCode) {
@@ -417,6 +439,7 @@ TextEditor.TextEditorAutocompleteController = class {
}
_onScroll() {
this._tooltipGlassPane.hide();
if (!this._suggestBox)
return;
const cursor = this._codeMirror.getCursor();
@@ -431,7 +454,34 @@ TextEditor.TextEditorAutocompleteController = class {
}
}
async _updateTooltip() {
const cursor = this._codeMirror.getCursor();
const tooltip = this._config.tooltipCallback ? await this._config.tooltipCallback(cursor.line, cursor.ch) : null;
const newCursor = this._codeMirror.getCursor();
if (newCursor.line !== cursor.line && newCursor.ch !== cursor.ch)
return;
if (this._suggestBox && this._suggestBox.visible)
return;
if (!tooltip) {
this._tooltipGlassPane.hide();
return;
}
const metrics = this._textEditor.cursorPositionToCoordinates(cursor.line, cursor.ch);
if (!metrics) {
this._tooltipGlassPane.hide();
return;
}
this._tooltipGlassPane.setContentAnchorBox(new AnchorBox(metrics.x, metrics.y, 0, metrics.height));
this._tooltipElement.removeChildren();
this._tooltipElement.appendChild(tooltip);
this._tooltipGlassPane.show(/** @type {!Document} */ (this._textEditor.element.ownerDocument));
}
_onCursorActivity() {
this._updateTooltip();
if (!this._suggestBox)
return;
const cursor = this._codeMirror.getCursor();
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2018 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.
*/
.autocomplete-tooltip {
background-color: #FFFFFF;
pointer-events: none;
margin-left: -3px;
box-shadow: var(--drop-shadow);
overflow-x: hidden;
height: 20px;
}
.autocomplete-tooltip > * {
padding: 0px 4px;
white-space: nowrap;
vertical-align: middle;
line-height: 20px;
}
+1
View File
@@ -26,6 +26,7 @@
"CodeMirrorTextEditor.js"
],
"resources": [
"autocompleteTooltip.css",
"cmdevtools.css"
],
"skip_compilation": [
+1
View File
@@ -116,6 +116,7 @@ UI.TextEditor.Options;
/**
* @typedef {{
* substituteRangeCallback: ((function(number, number):?TextUtils.TextRange)|undefined),
* tooltipCallback: ((function(number, number):?Element)|undefined),
* suggestionsCallback: ((function(!TextUtils.TextRange, !TextUtils.TextRange, boolean=):?Promise.<!UI.SuggestBox.Suggestions>)|undefined),
* isWordChar: ((function(string):boolean)|undefined)
* }}