| // 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 Root from '../../core/root/root.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import * as Formatter from '../../models/formatter/formatter.js'; |
| import * as SourceMapScopes from '../../models/source_map_scopes/source_map_scopes.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; |
| // The CodeMirror state field that controls whether the argument hints are showing. |
| // If they are, the escape key will clear them. However, if they aren't, then the |
| // console drawer should be hidden as a whole. |
| #argumentHintsState: CodeMirror.StateField<CodeMirror.Tooltip|null>; |
| |
| 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 argumentHints = TextEditor.JavaScript.argumentHints(); |
| this.#argumentHintsState = argumentHints[0]; |
| |
| const extensions = [ |
| CodeMirror.keymap.of(this.editorKeymap()), |
| CodeMirror.EditorView.updateListener.of(update => this.editorUpdate(update)), |
| argumentHints, |
| TextEditor.Config.conservativeCompletion, |
| TextEditor.Config.showCompletionHint, |
| CodeMirror.javascript.javascript(), |
| TextEditor.Config.baseConfiguration(this.initialText), |
| TextEditor.Config.autocompletion.instance(), |
| 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}), |
| ]; |
| if (Root.Runtime.Runtime.queryParam('noJavaScriptCompletion') !== 'true') { |
| extensions.push(TextEditor.JavaScript.completion()); |
| } |
| const doc = this.initialText; |
| const editorState = CodeMirror.EditorState.create({doc, extensions}); |
| |
| 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) { |
| void 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: 'Escape', |
| run: (): boolean => { |
| return TextEditor.JavaScript.closeArgumentsHintsTooltip(this.editor.editor, this.#argumentHintsState); |
| }, |
| }, |
| { |
| key: 'Enter', |
| run: (): boolean => { |
| void this.handleEnter(); |
| return true; |
| }, |
| 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; |
| } |
| |
| // Change the prompt input to the history content, and scroll to the end to |
| // bring the full content (potentially multiple lines) into view. |
| const cursorPos = newText.length; |
| this.editor.dispatch({ |
| changes: {from: 0, to: this.editor.state.doc.length, insert: newText}, |
| selection: CodeMirror.EditorSelection.cursor(cursorPos), |
| scrollIntoView: true, |
| }); |
| if (dir < 0) { |
| // If we are going back in history, put the cursor to the end of the first line |
| // so that the user can quickly go further back in history. |
| const firstLineBreak = newText.search(/\n|$/); |
| this.editor.dispatch({ |
| selection: CodeMirror.EditorSelection.cursor(firstLineBreak), |
| }); |
| } |
| return true; |
| } |
| |
| private async enterWillEvaluate(): Promise<boolean> { |
| const {state} = this.editor; |
| return state.doc.length > 0 && await TextEditor.JavaScript.isExpressionComplete(state.doc.toString()); |
| } |
| |
| private async handleEnter(): Promise<void> { |
| if (await this.enterWillEvaluate()) { |
| this.appendCommand(this.text(), true); |
| TextEditor.JavaScript.closeArgumentsHintsTooltip(this.editor.editor, this.#argumentHintsState); |
| this.editor.dispatch({ |
| changes: {from: 0, to: this.editor.state.doc.length}, |
| scrollIntoView: true, |
| }); |
| } else if (this.editor.state.doc.length) { |
| CodeMirror.insertNewlineAndIndent(this.editor.editor); |
| } else { |
| this.editor.dispatch({scrollIntoView: true}); |
| } |
| } |
| |
| private updatePromptIcon(): void { |
| void this.iconThrottler.schedule(async () => { |
| this.promptIcon.classList.toggle('console-prompt-incomplete', !(await 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.wrapObjectLiteral(text); |
| void this.evaluateCommandInConsole(executionContext, message, expression, useCommandLineAPI); |
| if (ConsolePanel.instance().isShowing()) { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.CommandEvaluatedInConsolePanel); |
| } |
| } |
| } |
| |
| private async evaluateCommandInConsole( |
| executionContext: SDK.RuntimeModel.ExecutionContext, message: SDK.ConsoleModel.ConsoleMessage, expression: string, |
| useCommandLineAPI: boolean): Promise<void> { |
| if (Root.Runtime.experiments.isEnabled('evaluateExpressionsWithSourceMaps')) { |
| const callFrame = executionContext.debuggerModel.selectedCallFrame(); |
| if (callFrame) { |
| const nameMap = await SourceMapScopes.NamesResolver.allVariablesInCallFrame(callFrame); |
| expression = await this.substituteNames(expression, nameMap); |
| } |
| } |
| |
| await SDK.ConsoleModel.ConsoleModel.instance().evaluateCommandInConsole( |
| executionContext, message, expression, useCommandLineAPI); |
| } |
| |
| private async substituteNames(expression: string, mapping: Map<string, string>): Promise<string> { |
| try { |
| return await Formatter.FormatterWorkerPool.formatterWorkerPool().javaScriptSubstitute(expression, mapping); |
| } catch { |
| return expression; |
| } |
| } |
| |
| 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, |
| }; |