chrome-devtools-frontend
Version:
Chrome DevTools UI
413 lines (360 loc) • 14.2 kB
text/typescript
// 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.
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import * as TextEditor from '../../ui/components/text_editor/text_editor.js';
import * as ObjectUI from '../../ui/legacy/components/object_ui/object_ui.js';
import * as UI from '../../ui/legacy/legacy.js';
import {ConsolePanel} from './ConsolePanel.js';
import consolePromptStyles from './consolePrompt.css.js';
const UIStrings = {
/**
*@description Text in Console Prompt of the Console panel
*/
consolePrompt: 'Console prompt',
};
const str_ = i18n.i18n.registerUIStrings('panels/console/ConsolePrompt.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class ConsolePrompt extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.Widget>(
UI.Widget.Widget) {
private addCompletionsFromHistory: boolean;
private historyInternal: ConsoleHistoryManager;
private initialText: string;
private editor: TextEditor.TextEditor.TextEditor;
private readonly eagerPreviewElement: HTMLDivElement;
private textChangeThrottler: Common.Throttler.Throttler;
private readonly formatter: ObjectUI.RemoteObjectPreviewFormatter.RemoteObjectPreviewFormatter;
private requestPreviewBound: () => Promise<void>;
private requestPreviewCurrent = 0;
private readonly innerPreviewElement: HTMLElement;
private readonly promptIcon: UI.Icon.Icon;
private readonly iconThrottler: Common.Throttler.Throttler;
private readonly eagerEvalSetting: Common.Settings.Setting<boolean>;
private previewRequestForTest: Promise<void>|null;
private highlightingNode: boolean;
constructor() {
super();
this.addCompletionsFromHistory = true;
this.historyInternal = new ConsoleHistoryManager();
this.initialText = '';
this.eagerPreviewElement = document.createElement('div');
this.eagerPreviewElement.classList.add('console-eager-preview');
this.textChangeThrottler = new Common.Throttler.Throttler(150);
this.formatter = new ObjectUI.RemoteObjectPreviewFormatter.RemoteObjectPreviewFormatter();
this.requestPreviewBound = this.requestPreview.bind(this);
this.innerPreviewElement = this.eagerPreviewElement.createChild('div', 'console-eager-inner-preview');
this.eagerPreviewElement.appendChild(UI.Icon.Icon.create('smallicon-command-result', 'preview-result-icon'));
const editorContainerElement = this.element.createChild('div', 'console-prompt-editor-container');
this.element.appendChild(this.eagerPreviewElement);
this.promptIcon = UI.Icon.Icon.create('smallicon-text-prompt', 'console-prompt-icon');
this.element.appendChild(this.promptIcon);
this.iconThrottler = new Common.Throttler.Throttler(0);
this.eagerEvalSetting = Common.Settings.Settings.instance().moduleSetting('consoleEagerEval');
this.eagerEvalSetting.addChangeListener(this.eagerSettingChanged.bind(this));
this.eagerPreviewElement.classList.toggle('hidden', !this.eagerEvalSetting.get());
this.element.tabIndex = 0;
this.previewRequestForTest = null;
this.highlightingNode = false;
const editorState = CodeMirror.EditorState.create({
doc: this.initialText,
extensions: [
CodeMirror.keymap.of(this.editorKeymap()),
CodeMirror.EditorView.updateListener.of(update => this.editorUpdate(update)),
TextEditor.JavaScript.argumentHints(),
TextEditor.JavaScript.completion(),
TextEditor.Config.showCompletionHint,
CodeMirror.javascript.javascript(),
TextEditor.Config.baseConfiguration(this.initialText),
TextEditor.Config.autocompletion,
CodeMirror.javascript.javascriptLanguage.data.of({
autocomplete: (context: CodeMirror.CompletionContext): CodeMirror.CompletionResult | null =>
this.historyCompletions(context),
}),
CodeMirror.EditorView.contentAttributes.of({'aria-label': i18nString(UIStrings.consolePrompt)}),
CodeMirror.EditorView.lineWrapping,
CodeMirror.autocompletion({aboveCursor: true}),
],
});
this.editor = new TextEditor.TextEditor.TextEditor(editorState);
this.editor.addEventListener('keydown', (event): void => {
if (event.defaultPrevented) {
event.stopPropagation();
}
});
editorContainerElement.appendChild(this.editor);
if (this.hasFocus()) {
this.focus();
}
this.element.removeAttribute('tabindex');
this.editorSetForTest();
// Record the console tool load time after the console prompt constructor is complete.
Host.userMetrics.panelLoaded('console', 'DevTools.Launch.Console');
}
private eagerSettingChanged(): void {
const enabled = this.eagerEvalSetting.get();
this.eagerPreviewElement.classList.toggle('hidden', !enabled);
if (enabled) {
this.requestPreview();
}
}
belowEditorElement(): Element {
return this.eagerPreviewElement;
}
private onTextChanged(): void {
// ConsoleView and prompt both use a throttler, so we clear the preview
// ASAP to avoid inconsistency between a fresh viewport and stale preview.
if (this.eagerEvalSetting.get()) {
const asSoonAsPossible = !TextEditor.Config.contentIncludingHint(this.editor.editor);
this.previewRequestForTest = this.textChangeThrottler.schedule(this.requestPreviewBound, asSoonAsPossible);
}
this.updatePromptIcon();
this.dispatchEventToListeners(Events.TextChanged);
}
private async requestPreview(): Promise<void> {
const id = ++this.requestPreviewCurrent;
const text = TextEditor.Config.contentIncludingHint(this.editor.editor).trim();
const executionContext = UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext);
const {preview, result} = await ObjectUI.JavaScriptREPL.JavaScriptREPL.evaluateAndBuildPreview(
text, true /* throwOnSideEffect */, true /* replMode */, 500 /* timeout */);
if (this.requestPreviewCurrent !== id) {
return;
}
this.innerPreviewElement.removeChildren();
if (preview.deepTextContent() !== TextEditor.Config.contentIncludingHint(this.editor.editor).trim()) {
this.innerPreviewElement.appendChild(preview);
}
if (result && 'object' in result && result.object && result.object.subtype === 'node') {
this.highlightingNode = true;
SDK.OverlayModel.OverlayModel.highlightObjectAsDOMNode(result.object);
} else if (this.highlightingNode) {
this.highlightingNode = false;
SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight();
}
if (result && executionContext) {
executionContext.runtimeModel.releaseEvaluationResult(result);
}
}
wasShown(): void {
super.wasShown();
this.registerCSSFiles([consolePromptStyles]);
}
willHide(): void {
if (this.highlightingNode) {
this.highlightingNode = false;
SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight();
}
}
history(): ConsoleHistoryManager {
return this.historyInternal;
}
clearAutocomplete(): void {
CodeMirror.closeCompletion(this.editor.editor);
}
private isCaretAtEndOfPrompt(): boolean {
return this.editor.state.selection.main.head === this.editor.state.doc.length;
}
moveCaretToEndOfPrompt(): void {
this.editor.dispatch({
selection: CodeMirror.EditorSelection.cursor(this.editor.state.doc.length),
});
}
clear(): void {
this.editor.dispatch({
changes: {from: 0, to: this.editor.state.doc.length},
});
}
text(): string {
return this.editor.state.doc.toString();
}
setAddCompletionsFromHistory(value: boolean): void {
this.addCompletionsFromHistory = value;
}
private editorKeymap(): readonly CodeMirror.KeyBinding[] {
return [
{key: 'ArrowUp', run: (): boolean => this.moveHistory(-1)},
{key: 'ArrowDown', run: (): boolean => this.moveHistory(1)},
{mac: 'Ctrl-p', run: (): boolean => this.moveHistory(-1, true)},
{mac: 'Ctrl-n', run: (): boolean => this.moveHistory(1, true)},
{key: 'Enter', run: (): boolean => this.evaluate(), shift: CodeMirror.insertNewlineAndIndent},
];
}
private moveHistory(dir: -1|1, force = false): boolean {
const {editor} = this.editor, {main} = editor.state.selection;
if (!force) {
if (!main.empty) {
return false;
}
const cursorCoords = editor.coordsAtPos(main.head);
const endCoords = editor.coordsAtPos(dir < 0 ? 0 : editor.state.doc.length);
// Check if there are wrapped lines in this direction, and let
// the cursor move normally if there are.
if (cursorCoords && endCoords &&
(dir < 0 ? cursorCoords.top > endCoords.top + 5 : cursorCoords.bottom < endCoords.bottom - 5)) {
return false;
}
}
const history = this.historyInternal;
const newText = dir < 0 ? history.previous(this.text()) : history.next();
if (newText === undefined) {
return false;
}
const cursorPos = dir < 0 ? newText.search(/\n|$/) : newText.length;
this.editor.dispatch({
changes: {from: 0, to: this.editor.state.doc.length, insert: newText},
selection: CodeMirror.EditorSelection.cursor(cursorPos),
scrollIntoView: true,
});
return true;
}
private enterWillEvaluate(): boolean {
const {state} = this.editor;
return state.doc.length > 0 && TextEditor.JavaScript.isExpressionComplete(state);
}
private evaluate(): boolean {
if (this.enterWillEvaluate()) {
this.appendCommand(this.text(), true);
this.editor.dispatch({
changes: {from: 0, to: this.editor.state.doc.length},
scrollIntoView: true,
});
} else if (this.editor.state.doc.length) {
return CodeMirror.insertNewlineAndIndent(this.editor.editor);
} else {
this.editor.dispatch({scrollIntoView: true});
}
this.enterProcessedForTest();
return true;
}
private updatePromptIcon(): void {
this.iconThrottler.schedule(async () => {
this.promptIcon.classList.toggle('console-prompt-incomplete', !this.enterWillEvaluate());
});
}
private appendCommand(text: string, useCommandLineAPI: boolean): void {
const currentExecutionContext = UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext);
if (currentExecutionContext) {
const executionContext = currentExecutionContext;
const message = SDK.ConsoleModel.ConsoleModel.instance().addCommandMessage(executionContext, text);
const expression = ObjectUI.JavaScriptREPL.JavaScriptREPL.preprocessExpression(text);
SDK.ConsoleModel.ConsoleModel.instance().evaluateCommandInConsole(
executionContext, message, expression, useCommandLineAPI);
if (ConsolePanel.instance().isShowing()) {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.CommandEvaluatedInConsolePanel);
}
}
}
private enterProcessedForTest(): void {
}
private editorUpdate(update: CodeMirror.ViewUpdate): void {
if (update.docChanged ||
CodeMirror.selectedCompletion(update.state) !== CodeMirror.selectedCompletion(update.startState)) {
this.onTextChanged();
} else if (update.selectionSet) {
this.updatePromptIcon();
}
}
private historyCompletions(context: CodeMirror.CompletionContext): CodeMirror.CompletionResult|null {
const text = this.text();
if (!this.addCompletionsFromHistory || !this.isCaretAtEndOfPrompt() || (!text.length && !context.explicit)) {
return null;
}
const result = [];
const set = new Set<string>();
const data = this.historyInternal.historyData();
for (let i = data.length - 1; i >= 0 && result.length < 50; --i) {
const item = data[i];
if (!item.startsWith(text)) {
continue;
}
if (set.has(item)) {
continue;
}
set.add(item);
result.push({label: item, type: 'secondary', boost: -1e5});
}
return result.length ? {
from: 0,
to: text.length,
options: result,
} :
null;
}
focus(): void {
this.editor.focus();
}
private editorSetForTest(): void {
}
}
export class ConsoleHistoryManager {
private data: string[];
private historyOffset: number;
private uncommittedIsTop?: boolean;
constructor() {
this.data = [];
/**
* 1-based entry in the history stack.
*/
this.historyOffset = 1;
}
historyData(): string[] {
return this.data;
}
setHistoryData(data: string[]): void {
this.data = data.slice();
this.historyOffset = 1;
}
/**
* Pushes a committed text into the history.
*/
pushHistoryItem(text: string): void {
if (this.uncommittedIsTop) {
this.data.pop();
delete this.uncommittedIsTop;
}
this.historyOffset = 1;
if (text === this.currentHistoryItem()) {
return;
}
this.data.push(text);
}
/**
* Pushes the current (uncommitted) text into the history.
*/
private pushCurrentText(currentText: string): void {
if (this.uncommittedIsTop) {
this.data.pop();
} // Throw away obsolete uncommitted text.
this.uncommittedIsTop = true;
this.data.push(currentText);
}
previous(currentText: string): string|undefined {
if (this.historyOffset > this.data.length) {
return undefined;
}
if (this.historyOffset === 1) {
this.pushCurrentText(currentText);
}
++this.historyOffset;
return this.currentHistoryItem();
}
next(): string|undefined {
if (this.historyOffset === 1) {
return undefined;
}
--this.historyOffset;
return this.currentHistoryItem();
}
private currentHistoryItem(): string|undefined {
return this.data[this.data.length - this.historyOffset];
}
}
export const enum Events {
TextChanged = 'TextChanged',
}
export type EventTypes = {
[Events.TextChanged]: void,
};