UNPKG

chrome-devtools-frontend

Version:
611 lines (546 loc) 25.5 kB
// Copyright 2016 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable rulesdir/no-imperative-dom-api */ 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 AiCodeCompletion from '../../models/ai_code_completion/ai_code_completion.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 IconButton from '../../ui/components/icon_button/icon_button.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 * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import * as PanelCommon from '../common/common.js'; import {ConsolePanel} from './ConsolePanel.js'; import consolePromptStyles from './consolePrompt.css.js'; const {Direction} = TextEditor.TextEditorHistory; const UIStrings = { /** * @description Text in Console Prompt of the Console panel */ consolePrompt: 'Console prompt', /** * @description Warning shown to users when pasting text into the DevTools console. * @example {allow pasting} PH1 */ selfXssWarning: 'Warning: Don’t paste code into the DevTools Console that you don’t understand or haven’t reviewed yourself. This could allow attackers to steal your identity or take control of your computer. Please type ‘{PH1}’ below and press Enter to allow pasting.', /** * @description Text a user needs to type in order to confirm that they are aware of the danger of pasting code into the DevTools console. */ allowPasting: 'allow pasting', } as const; const str_ = i18n.i18n.registerUIStrings('panels/console/ConsolePrompt.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const AI_CODE_COMPLETION_CHARACTER_LIMIT = 20_000; export class ConsolePrompt extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.Widget>( UI.Widget.Widget) { private addCompletionsFromHistory: boolean; #history: TextEditor.AutocompleteHistory.AutocompleteHistory; private initialText: string; private editor: TextEditor.TextEditor.TextEditor; private readonly eagerPreviewElement: HTMLDivElement; private textChangeThrottler: Common.Throttler.Throttler; private requestPreviewBound: () => Promise<void>; private requestPreviewCurrent = 0; private readonly innerPreviewElement: HTMLElement; private readonly promptIcon: IconButton.Icon.Icon; private readonly iconThrottler: Common.Throttler.Throttler; private readonly eagerEvalSetting: Common.Settings.Setting<boolean>; protected 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>; #editorHistory: TextEditor.TextEditorHistory.TextEditorHistory; #selfXssWarningShown = false; #javaScriptCompletionCompartment: CodeMirror.Compartment = new CodeMirror.Compartment(); private aidaClient?: Host.AidaClient.AidaClient; private aidaAvailability?: Host.AidaClient.AidaAccessPreconditions; private boundOnAidaAvailabilityChange?: () => Promise<void>; private aiCodeCompletion?: AiCodeCompletion.AiCodeCompletion.AiCodeCompletion; private teaser?: PanelCommon.AiCodeCompletionTeaser; private placeholderCompartment: CodeMirror.Compartment = new CodeMirror.Compartment(); private aiCodeCompletionSetting = Common.Settings.Settings.instance().createSetting('ai-code-completion-enabled', false); private aiCodeCompletionCitations?: Host.AidaClient.Citation[] = []; #getJavaScriptCompletionExtensions(): CodeMirror.Extension { if (this.#selfXssWarningShown) { // No (JavaScript) completions at all while showing the self-XSS warning. return []; } if (Root.Runtime.Runtime.queryParam('noJavaScriptCompletion') !== 'true') { return [ CodeMirror.javascript.javascript(), TextEditor.JavaScript.completion(), ]; } return [CodeMirror.javascript.javascriptLanguage]; } #updateJavaScriptCompletionCompartment(): void { const extensions = this.#getJavaScriptCompletionExtensions(); const effects = this.#javaScriptCompletionCompartment.reconfigure(extensions); this.editor.dispatch({effects}); } constructor() { super({ jslog: `${VisualLogging.textField('console-prompt').track({ change: true, keydown: 'Enter|ArrowUp|ArrowDown|PageUp', })}`, }); this.registerRequiredCSS(consolePromptStyles); this.addCompletionsFromHistory = true; this.#history = new TextEditor.AutocompleteHistory.AutocompleteHistory( Common.Settings.Settings.instance().createLocalSetting('console-history', [])); this.initialText = ''; this.eagerPreviewElement = document.createElement('div'); this.eagerPreviewElement.classList.add('console-eager-preview'); this.textChangeThrottler = new Common.Throttler.Throttler(150); this.requestPreviewBound = this.requestPreview.bind(this); this.innerPreviewElement = this.eagerPreviewElement.createChild('div', 'console-eager-inner-preview'); const previewIcon = new IconButton.Icon.Icon(); previewIcon.name = 'chevron-left-dot'; previewIcon.classList.add('preview-result-icon', 'medium'); this.eagerPreviewElement.appendChild(previewIcon); const editorContainerElement = this.element.createChild('div', 'console-prompt-editor-container'); this.element.appendChild(this.eagerPreviewElement); this.promptIcon = new IconButton.Icon.Icon(); this.promptIcon.name = 'chevron-right'; this.promptIcon.style.color = 'var(--icon-action)'; this.promptIcon.classList.add('console-prompt-icon', 'medium'); this.element.appendChild(this.promptIcon); this.iconThrottler = new Common.Throttler.Throttler(0); this.eagerEvalSetting = Common.Settings.Settings.instance().moduleSetting('console-eager-eval'); 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 autocompleteOnEnter = TextEditor.Config.DynamicSetting.bool( 'console-autocomplete-on-enter', [], TextEditor.Config.conservativeCompletion); const extensions = [ CodeMirror.keymap.of(this.editorKeymap()), CodeMirror.EditorView.updateListener.of(update => this.editorUpdate(update)), argumentHints, autocompleteOnEnter.instance(), TextEditor.Config.showCompletionHint, TextEditor.Config.baseConfiguration(this.initialText), TextEditor.Config.autocompletion.instance(), CodeMirror.javascript.javascriptLanguage.data.of({ autocomplete: (context: CodeMirror.CompletionContext) => this.addCompletionsFromHistory ? this.#editorHistory.historyCompletions(context) : null, }), CodeMirror.EditorView.contentAttributes.of({'aria-label': i18nString(UIStrings.consolePrompt)}), CodeMirror.EditorView.lineWrapping, CodeMirror.autocompletion({aboveCursor: true}), this.#javaScriptCompletionCompartment.of(this.#getJavaScriptCompletionExtensions()), ]; if (this.isAiCodeCompletionEnabled()) { const aiCodeCompletionTeaserDismissedSetting = Common.Settings.Settings.instance().createSetting('ai-code-completion-teaser-dismissed', false); if (!this.aiCodeCompletionSetting.get() && !aiCodeCompletionTeaserDismissedSetting.get()) { this.teaser = new PanelCommon.AiCodeCompletionTeaser({onDetach: this.detachAiCodeCompletionTeaser.bind(this)}); extensions.push(this.placeholderCompartment.of( TextEditor.AiCodeCompletionTeaserPlaceholder.aiCodeCompletionTeaserPlaceholder(this.teaser))); } extensions.push(TextEditor.Config.aiAutoCompleteSuggestion); } const doc = this.initialText; const editorState = CodeMirror.EditorState.create({doc, extensions}); this.editor = new TextEditor.TextEditor.TextEditor(editorState); this.editor.addEventListener('keydown', event => { if (event.defaultPrevented) { event.stopPropagation(); } }); editorContainerElement.appendChild(this.editor); this.#editorHistory = new TextEditor.TextEditorHistory.TextEditorHistory(this.editor, this.#history); 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'); if (this.isAiCodeCompletionEnabled()) { this.aiCodeCompletionSetting.addChangeListener(this.onAiCodeCompletionSettingChanged.bind(this)); this.onAiCodeCompletionSettingChanged(); this.boundOnAidaAvailabilityChange = this.onAidaAvailabilityChange.bind(this); Host.AidaClient.HostConfigTracker.instance().addEventListener( Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.boundOnAidaAvailabilityChange); void this.onAidaAvailabilityChange(); } } 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(docContentChanged?: boolean): 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 ? Common.Throttler.Scheduling.AS_SOON_AS_POSSIBLE : Common.Throttler.Scheduling.DEFAULT); } if (docContentChanged && this.aiCodeCompletion && this.isAiCodeCompletionEnabled()) { // Only trigger when doc content changes. // This ensures that it is not triggered when user is going through the options in existing completion menu. this.triggerAiCodeCompletion(); } this.updatePromptIcon(); this.dispatchEventToListeners(Events.TEXT_CHANGED); } triggerAiCodeCompletion(): void { const {doc, selection} = this.editor.state; const query = doc.toString(); const cursor = selection.main.head; const currentExecutionContext = UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext); let prefix = query.substring(0, cursor); if (prefix.trim().length === 0) { return; } if (currentExecutionContext) { const consoleModel = currentExecutionContext.target().model(SDK.ConsoleModel.ConsoleModel); if (consoleModel) { let lastMessage = ''; let consoleMessages = ''; for (const message of consoleModel.messages()) { if (message.type !== SDK.ConsoleModel.FrontendMessageType.Command || message.messageText === lastMessage) { continue; } lastMessage = message.messageText; consoleMessages = consoleMessages + message.messageText + '\n\n'; } prefix = consoleMessages + prefix; } } let suffix = query.substring(cursor); if (prefix.length > AI_CODE_COMPLETION_CHARACTER_LIMIT) { prefix = prefix.substring(prefix.length - AI_CODE_COMPLETION_CHARACTER_LIMIT); } if (suffix.length > AI_CODE_COMPLETION_CHARACTER_LIMIT) { suffix = suffix.substring(0, AI_CODE_COMPLETION_CHARACTER_LIMIT); } this.aiCodeCompletion?.onTextChanged(prefix, suffix, cursor); } 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); } } override willHide(): void { super.willHide(); if (this.highlightingNode) { this.highlightingNode = false; SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); } if (this.boundOnAidaAvailabilityChange) { Host.AidaClient.HostConfigTracker.instance().removeEventListener( Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.boundOnAidaAvailabilityChange); } } history(): TextEditor.AutocompleteHistory.AutocompleteHistory { return this.#history; } clearAutocomplete(): void { CodeMirror.closeCompletion(this.editor.editor); } 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[] { const keymap = [ {key: 'ArrowUp', run: () => this.#editorHistory.moveHistory(Direction.BACKWARD)}, {key: 'ArrowDown', run: () => this.#editorHistory.moveHistory(Direction.FORWARD)}, {mac: 'Ctrl-p', run: () => this.#editorHistory.moveHistory(Direction.BACKWARD, true)}, {mac: 'Ctrl-n', run: () => this.#editorHistory.moveHistory(Direction.FORWARD, true)}, { key: 'Escape', run: () => this.runOnEscape(), }, { key: 'Ctrl-Enter', run: () => { void this.handleEnter(/* forceEvaluate */ true); return true; }, }, { key: 'Enter', run: () => { void this.handleEnter(); return true; }, shift: CodeMirror.insertNewlineAndIndent, }, ]; if (this.isAiCodeCompletionEnabled()) { keymap.push({ key: 'Tab', run: (): boolean => { const {accepted, suggestion} = TextEditor.Config.acceptAiAutoCompleteSuggestion(this.editor.editor); if (accepted) { this.dispatchEventToListeners( Events.AI_CODE_COMPLETION_SUGGESTION_ACCEPTED, {citations: this.aiCodeCompletionCitations}); if (suggestion?.rpcGlobalId && suggestion?.sampleId) { this.aiCodeCompletion?.registerUserAcceptance(suggestion.rpcGlobalId, suggestion.sampleId); } } return accepted; }, }); } return keymap; } private runOnEscape(): boolean { if (TextEditor.JavaScript.closeArgumentsHintsTooltip(this.editor.editor, this.#argumentHintsState)) { return true; } if (this.aiCodeCompletion && TextEditor.Config.hasActiveAiSuggestion(this.editor.state)) { this.editor.dispatch({ effects: TextEditor.Config.setAiAutoCompleteSuggestion.of(null), }); return true; } return false; } private async enterWillEvaluate(forceEvaluate?: boolean): Promise<boolean> { const {doc, selection} = this.editor.state; if (!doc.length) { return false; } if (forceEvaluate || selection.main.head < doc.length) { return true; } const currentExecutionContext = UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext); const isExpressionComplete = await TextEditor.JavaScript.isExpressionComplete(doc.toString()); if (currentExecutionContext !== UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext)) { // We should not evaluate if the current context has changed since user action return false; } return isExpressionComplete; } showSelfXssWarning(): void { Common.Console.Console.instance().warn( i18nString(UIStrings.selfXssWarning, {PH1: i18nString(UIStrings.allowPasting)}), Common.Console.FrontendMessageSource.SELF_XSS); this.#selfXssWarningShown = true; Host.userMetrics.actionTaken(Host.UserMetrics.Action.SelfXssWarningConsoleMessageShown); this.#updateJavaScriptCompletionCompartment(); } private async handleEnter(forceEvaluate?: boolean): Promise<void> { if (this.#selfXssWarningShown && this.text() === i18nString(UIStrings.allowPasting)) { Common.Console.Console.instance().log(this.text()); this.editor.dispatch({ changes: {from: 0, to: this.editor.state.doc.length}, scrollIntoView: true, }); Common.Settings.Settings.instance() .createSetting('disable-self-xss-warning', false, Common.Settings.SettingStorageType.SYNCED) .set(true); this.#selfXssWarningShown = false; Host.userMetrics.actionTaken(Host.UserMetrics.Action.SelfXssAllowPastingInConsole); this.#updateJavaScriptCompletionCompartment(); return; } if (await this.enterWillEvaluate(forceEvaluate)) { 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, }); if (this.teaser) { this.detachAiCodeCompletionTeaser(); this.teaser = undefined; } } 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 consoleModel = executionContext.target().model(SDK.ConsoleModel.ConsoleModel); if (consoleModel) { const message = consoleModel.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> { const callFrame = executionContext.debuggerModel.selectedCallFrame(); if (callFrame?.script.isJavaScript()) { const nameMap = await SourceMapScopes.NamesResolver.allVariablesInCallFrame(callFrame); expression = await this.substituteNames(expression, nameMap); } await executionContext.target() .model(SDK.ConsoleModel.ConsoleModel) ?.evaluateCommandInConsole(executionContext, message, expression, useCommandLineAPI); } private async substituteNames(expression: string, mapping: Map<string, string|null>): 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)) { const docContentChanged = update.state.doc !== update.startState.doc; this.onTextChanged(docContentChanged); } else if (update.selectionSet) { this.updatePromptIcon(); } } override focus(): void { this.editor.focus(); } // TODO(b/435654172): Refactor and move aiCodeCompletion model one level up to avoid // defining additional listeners and events. private setAiCodeCompletion(): void { if (this.aiCodeCompletion) { return; } if (!this.aidaClient) { this.aidaClient = new Host.AidaClient.AidaClient(); } if (this.teaser) { this.detachAiCodeCompletionTeaser(); this.teaser = undefined; } this.aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion( {aidaClient: this.aidaClient}, this.editor, AiCodeCompletion.AiCodeCompletion.Panel.CONSOLE, ['\n\n']); this.aiCodeCompletion.addEventListener(AiCodeCompletion.AiCodeCompletion.Events.RESPONSE_RECEIVED, event => { this.aiCodeCompletionCitations = event.data.citations; this.dispatchEventToListeners(Events.AI_CODE_COMPLETION_RESPONSE_RECEIVED, event.data); }); this.aiCodeCompletion.addEventListener(AiCodeCompletion.AiCodeCompletion.Events.REQUEST_TRIGGERED, event => { this.dispatchEventToListeners(Events.AI_CODE_COMPLETION_REQUEST_TRIGGERED, event.data); }); } private onAiCodeCompletionSettingChanged(): void { if (this.aiCodeCompletionSetting.get() && this.isAiCodeCompletionEnabled()) { this.setAiCodeCompletion(); } else if (this.aiCodeCompletion) { this.aiCodeCompletion.remove(); this.aiCodeCompletion = undefined; } } private async onAidaAvailabilityChange(): Promise<void> { const currentAidaAvailability = await Host.AidaClient.AidaClient.checkAccessPreconditions(); if (currentAidaAvailability !== this.aidaAvailability) { this.aidaAvailability = currentAidaAvailability; if (this.aidaAvailability === Host.AidaClient.AidaAccessPreconditions.AVAILABLE) { this.onAiCodeCompletionSettingChanged(); } else if (this.aiCodeCompletion) { this.aiCodeCompletion.remove(); this.aiCodeCompletion = undefined; } } } async onAiCodeCompletionTeaserActionKeyDown(event: Event): Promise<void> { if (this.teaser?.isShowing()) { await this.teaser?.onAction(event); void VisualLogging.logKeyDown(event.currentTarget, event, 'ai-code-completion-teaser.fre'); } } onAiCodeCompletionTeaserDismissKeyDown(event: Event): void { if (this.teaser?.isShowing()) { this.teaser?.onDismiss(event); void VisualLogging.logKeyDown(event.currentTarget, event, 'ai-code-completion-teaser.dismiss'); } } private detachAiCodeCompletionTeaser(): void { this.editor.dispatch({ effects: this.placeholderCompartment.reconfigure([]), }); } private isAiCodeCompletionEnabled(): boolean { return Boolean( Root.Runtime.hostConfig.aidaAvailability?.enabled && Root.Runtime.hostConfig.devToolsAiCodeCompletion?.enabled); } private editorSetForTest(): void { } setAidaClientForTest(aidaClient: Host.AidaClient.AidaClient): void { this.aidaClient = aidaClient; } } export const enum Events { TEXT_CHANGED = 'TextChanged', AI_CODE_COMPLETION_SUGGESTION_ACCEPTED = 'AiCodeCompletionSuggestionAccepted', AI_CODE_COMPLETION_RESPONSE_RECEIVED = 'AiCodeCompletionResponseReceived', AI_CODE_COMPLETION_REQUEST_TRIGGERED = 'AiCodeCompletionRequestTriggered' } export interface EventTypes { [Events.TEXT_CHANGED]: void; [Events.AI_CODE_COMPLETION_SUGGESTION_ACCEPTED]: AiCodeCompletion.AiCodeCompletion.ResponseReceivedEvent; [Events.AI_CODE_COMPLETION_RESPONSE_RECEIVED]: AiCodeCompletion.AiCodeCompletion.ResponseReceivedEvent; // eslint-disable-next-line @typescript-eslint/no-empty-object-type [Events.AI_CODE_COMPLETION_REQUEST_TRIGGERED]: {}; }