chrome-devtools-frontend
Version:
Chrome DevTools UI
1,273 lines (1,132 loc) • 47.2 kB
text/typescript
/*
* Copyright (C) 2011 Google Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/* 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 Platform from '../../../../core/platform/platform.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 TextUtils from '../../../../models/text_utils/text_utils.js';
import * as PanelCommon from '../../../../panels/common/common.js';
import * as CodeMirror from '../../../../third_party/codemirror.next/codemirror.next.js';
import * as CodeHighlighter from '../../../components/code_highlighter/code_highlighter.js';
import * as TextEditor from '../../../components/text_editor/text_editor.js';
import * as VisualLogging from '../../../visual_logging/visual_logging.js';
import * as UI from '../../legacy.js';
const UIStrings = {
/**
*@description Text for the source of something
*/
source: 'Source',
/**
*@description Text to pretty print a file
*/
prettyPrint: 'Pretty print',
/**
*@description Text when something is loading
*/
loading: 'Loading…',
/**
* @description Shown at the bottom of the Sources panel when the user has made multiple
* simultaneous text selections in the text editor.
* @example {2} PH1
*/
dSelectionRegions: '{PH1} selection regions',
/**
* @description Position indicator in Source Frame of the Sources panel. The placeholder is a
* hexadecimal number value, which is why it is prefixed with '0x'.
* @example {abc} PH1
*/
bytecodePositionXs: 'Bytecode position `0x`{PH1}',
/**
*@description Text in Source Frame of the Sources panel
*@example {2} PH1
*@example {2} PH2
*/
lineSColumnS: 'Line {PH1}, Column {PH2}',
/**
*@description Text in Source Frame of the Sources panel
*@example {2} PH1
*/
dCharactersSelected: '{PH1} characters selected',
/**
*@description Text in Source Frame of the Sources panel
*@example {2} PH1
*@example {2} PH2
*/
dLinesDCharactersSelected: '{PH1} lines, {PH2} characters selected',
/**
*@description Headline of warning shown to users when pasting text/code into DevTools.
*/
doYouTrustThisCode: 'Do you trust this code?',
/**
*@description Warning shown to users when pasting text/code into DevTools.
*@example {allow pasting} PH1
*/
doNotPaste:
'Don\'t paste code you do not understand or have not reviewed yourself into DevTools. This could allow attackers to steal your identity or take control of your computer. Please type \'\'{PH1}\'\' below 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',
/**
*@description Input box placeholder which instructs the user to type 'allow pasting' into the input box.
*@example {allow pasting} PH1
*/
typeAllowPasting: 'Type \'\'{PH1}\'\'',
/**
* @description Error message shown when the user tries to open a file that contains non-readable data. "Editor" refers to
* a text editor.
*/
binaryContentError:
'Editor can\'t show binary data. Use the "Response" tab in the "Network" panel to inspect this resource.',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/source_frame/SourceFrame.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export interface SourceFrameOptions {
// Whether to show line numbers. Defaults to true.
lineNumbers?: boolean;
// Whether to wrap lines. Defaults to false.
lineWrapping?: boolean;
}
export const enum Events {
EDITOR_UPDATE = 'EditorUpdate',
EDITOR_SCROLL = 'EditorScroll',
}
export interface EventTypes {
[Events.EDITOR_UPDATE]: CodeMirror.ViewUpdate;
[Events.EDITOR_SCROLL]: void;
}
type FormatFn = (lineNo: number, state: CodeMirror.EditorState) => string;
export const LINE_NUMBER_FORMATTER = CodeMirror.Facet.define<FormatFn, FormatFn>({
combine(value): FormatFn {
if (value.length === 0) {
return (lineNo: number) => lineNo.toString();
}
return value[0];
},
});
export class SourceFrameImpl extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.View.SimpleView>(
UI.View.SimpleView) implements UI.SearchableView.Searchable, UI.SearchableView.Replaceable, Transformer {
private readonly lazyContent: () => Promise<TextUtils.ContentData.ContentDataOrError>;
private prettyInternal: boolean;
private rawContent: string|CodeMirror.Text|null;
private formattedMap: Formatter.ScriptFormatter.FormatterSourceMapping|null;
private readonly prettyToggle: UI.Toolbar.ToolbarToggle;
private shouldAutoPrettyPrint: boolean;
private readonly progressToolbarItem: UI.Toolbar.ToolbarItem;
private textEditorInternal: TextEditor.TextEditor.TextEditor;
// The 'clean' document, before editing
private baseDoc: CodeMirror.Text;
private prettyBaseDoc: CodeMirror.Text|null = null;
private displayedSelection: CodeMirror.EditorSelection|null = null;
private searchConfig: UI.SearchableView.SearchConfig|null;
private delayedFindSearchMatches: (() => void)|null;
private currentSearchResultIndex: number;
private searchResults: SearchMatch[];
private searchRegex: UI.SearchableView.SearchRegexResult|null;
private loadError: boolean;
private readonly sourcePosition: UI.Toolbar.ToolbarText;
private searchableView: UI.SearchableView.SearchableView|null;
private editable: boolean;
private positionToReveal: {
to: {lineNumber: number, columnNumber: number},
from?: {lineNumber: number, columnNumber: number},
shouldHighlight?: boolean,
}|null;
private lineToScrollTo: number|null;
private selectionToSet: TextUtils.TextRange.TextRange|null;
private loadedInternal: boolean;
private contentRequested: boolean;
private wasmDisassemblyInternal: TextUtils.WasmDisassembly.WasmDisassembly|null;
contentSet: boolean;
private selfXssWarningDisabledSetting: Common.Settings.Setting<boolean>;
constructor(
lazyContent: () => Promise<TextUtils.ContentData.ContentDataOrError>,
private readonly options: SourceFrameOptions = {}) {
super(i18nString(UIStrings.source));
this.lazyContent = lazyContent;
this.prettyInternal = false;
this.rawContent = null;
this.formattedMap = null;
this.prettyToggle =
new UI.Toolbar.ToolbarToggle(i18nString(UIStrings.prettyPrint), 'brackets', undefined, 'pretty-print');
this.prettyToggle.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
void this.setPretty(this.prettyToggle.isToggled());
});
this.shouldAutoPrettyPrint = false;
this.prettyToggle.setVisible(false);
this.progressToolbarItem = new UI.Toolbar.ToolbarItem(document.createElement('div'));
this.textEditorInternal = new TextEditor.TextEditor.TextEditor(this.placeholderEditorState(''));
this.textEditorInternal.style.flexGrow = '1';
this.element.appendChild(this.textEditorInternal);
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.defaultPrevented) {
event.stopPropagation();
}
});
this.baseDoc = this.textEditorInternal.state.doc;
this.searchConfig = null;
this.delayedFindSearchMatches = null;
this.currentSearchResultIndex = -1;
this.searchResults = [];
this.searchRegex = null;
this.loadError = false;
this.sourcePosition = new UI.Toolbar.ToolbarText();
this.searchableView = null;
this.editable = false;
this.positionToReveal = null;
this.lineToScrollTo = null;
this.selectionToSet = null;
this.loadedInternal = false;
this.contentRequested = false;
this.wasmDisassemblyInternal = null;
this.contentSet = false;
this.selfXssWarningDisabledSetting = Common.Settings.Settings.instance().createSetting(
'disable-self-xss-warning', false, Common.Settings.SettingStorageType.SYNCED);
Common.Settings.Settings.instance()
.moduleSetting('text-editor-indent')
.addChangeListener(this.#textEditorIndentChanged, this);
}
override disposeView(): void {
Common.Settings.Settings.instance()
.moduleSetting('text-editor-indent')
.removeChangeListener(this.#textEditorIndentChanged, this);
}
async #textEditorIndentChanged(): Promise<void> {
if (this.prettyInternal) {
// Indentation settings changed, which are used for pretty printing as well,
// so if the editor is currently pretty printed, just toggle the state here
// to apply the new indentation settings.
await this.setPretty(false);
await this.setPretty(true);
}
}
private placeholderEditorState(content: string): CodeMirror.EditorState {
return CodeMirror.EditorState.create({
doc: content,
extensions: [
CodeMirror.EditorState.readOnly.of(true),
this.options.lineNumbers !== false ? CodeMirror.lineNumbers() : [],
TextEditor.Config.theme(),
],
});
}
protected editorConfiguration(doc: string|CodeMirror.Text): CodeMirror.Extension {
return [
CodeMirror.EditorView.updateListener.of(update => this.dispatchEventToListeners(Events.EDITOR_UPDATE, update)),
TextEditor.Config.baseConfiguration(doc),
TextEditor.Config.closeBrackets.instance(),
TextEditor.Config.autocompletion.instance(),
TextEditor.Config.showWhitespace.instance(),
TextEditor.Config.allowScrollPastEof.instance(),
CodeMirror.Prec.lowest(TextEditor.Config.codeFolding.instance()),
TextEditor.Config.autoDetectIndent.instance(),
sourceFrameTheme,
CodeMirror.EditorView.domEventHandlers({
focus: () => this.onFocus(),
blur: () => this.onBlur(),
paste: () => this.onPaste(),
scroll: () => this.dispatchEventToListeners(Events.EDITOR_SCROLL),
contextmenu: event => this.onContextMenu(event),
}),
CodeMirror.lineNumbers({
domEventHandlers:
{contextmenu: (_view, block, event) => this.onLineGutterContextMenu(block.from, event as MouseEvent)},
}),
CodeMirror.EditorView.updateListener.of(
(update):
void => {
if (update.selectionSet || update.docChanged) {
this.updateSourcePosition();
}
if (update.docChanged) {
this.onTextChanged();
}
}),
activeSearchState,
CodeMirror.Prec.lowest(searchHighlighter),
config.language.of([]),
this.wasmDisassemblyInternal ? markNonBreakableLines(this.wasmDisassemblyInternal) : nonBreakableLines,
this.options.lineWrapping ? CodeMirror.EditorView.lineWrapping : [],
this.options.lineNumbers !== false ? CodeMirror.lineNumbers() : [],
CodeMirror.indentationMarkers({
colors: {
light: 'var(--sys-color-divider)',
activeLight: 'var(--sys-color-divider-prominent)',
dark: 'var(--sys-color-divider)',
activeDark: 'var(--sys-color-divider-prominent)',
},
}),
infobarState,
];
}
protected onBlur(): void {
}
protected onFocus(): void {
}
protected onPaste(): boolean {
if (Root.Runtime.Runtime.queryParam('isChromeForTesting') ||
Root.Runtime.Runtime.queryParam('disableSelfXssWarnings') || this.selfXssWarningDisabledSetting.get()) {
return false;
}
void this.showSelfXssWarning();
return true;
}
async showSelfXssWarning(): Promise<void> {
// Hack to circumvent Chrome issue which would show a tooltip for the newly opened
// dialog if pasting via keyboard.
await new Promise(resolve => setTimeout(resolve, 0));
const allowPasting = await PanelCommon.TypeToAllowDialog.show({
jslogContext: {
dialog: 'self-xss-warning',
input: 'allow-pasting',
},
header: i18nString(UIStrings.doYouTrustThisCode),
message: i18nString(UIStrings.doNotPaste, {PH1: i18nString(UIStrings.allowPasting)}),
typePhrase: i18nString(UIStrings.allowPasting),
inputPlaceholder: i18nString(UIStrings.typeAllowPasting, {PH1: i18nString(UIStrings.allowPasting)})
});
if (allowPasting) {
this.selfXssWarningDisabledSetting.set(true);
Host.userMetrics.actionTaken(Host.UserMetrics.Action.SelfXssAllowPastingInDialog);
}
}
get wasmDisassembly(): TextUtils.WasmDisassembly.WasmDisassembly|null {
return this.wasmDisassemblyInternal;
}
editorLocationToUILocation(lineNumber: number, columnNumber: number): {
lineNumber: number,
columnNumber: number,
};
editorLocationToUILocation(lineNumber: number): {
lineNumber: number,
columnNumber: number|undefined,
};
editorLocationToUILocation(lineNumber: number, columnNumber?: number): {
lineNumber: number,
columnNumber?: number|undefined,
} {
if (this.wasmDisassemblyInternal) {
columnNumber = this.wasmDisassemblyInternal.lineNumberToBytecodeOffset(lineNumber);
lineNumber = 0;
} else if (this.prettyInternal) {
[lineNumber, columnNumber] = this.prettyToRawLocation(lineNumber, columnNumber);
}
return {lineNumber, columnNumber};
}
uiLocationToEditorLocation(lineNumber: number, columnNumber: number|undefined = 0): {
lineNumber: number,
columnNumber: number,
} {
if (this.wasmDisassemblyInternal) {
lineNumber = this.wasmDisassemblyInternal.bytecodeOffsetToLineNumber(columnNumber);
columnNumber = 0;
} else if (this.prettyInternal) {
[lineNumber, columnNumber] = this.rawToPrettyLocation(lineNumber, columnNumber);
}
return {lineNumber, columnNumber};
}
setCanPrettyPrint(canPrettyPrint: boolean, autoPrettyPrint?: boolean): void {
this.shouldAutoPrettyPrint = autoPrettyPrint === true &&
Common.Settings.Settings.instance().moduleSetting('auto-pretty-print-minified').get();
this.prettyToggle.setVisible(canPrettyPrint);
}
setEditable(editable: boolean): void {
this.editable = editable;
if (this.loaded && editable !== !this.textEditor.state.readOnly) {
this.textEditor.dispatch({effects: config.editable.reconfigure(CodeMirror.EditorState.readOnly.of(!editable))});
}
}
private async setPretty(value: boolean): Promise<void> {
this.prettyInternal = value;
this.prettyToggle.setEnabled(false);
const wasLoaded = this.loaded;
const {textEditor} = this;
const selection = textEditor.state.selection.main;
const startPos = textEditor.toLineColumn(selection.from), endPos = textEditor.toLineColumn(selection.to);
let newSelection;
if (this.prettyInternal) {
const content =
this.rawContent instanceof CodeMirror.Text ? this.rawContent.sliceString(0) : this.rawContent || '';
const formatInfo = await Formatter.ScriptFormatter.formatScriptContent(this.contentType, content);
this.formattedMap = formatInfo.formattedMapping;
await this.setContent(formatInfo.formattedContent);
this.prettyBaseDoc = textEditor.state.doc;
const start = this.rawToPrettyLocation(startPos.lineNumber, startPos.columnNumber);
const end = this.rawToPrettyLocation(endPos.lineNumber, endPos.columnNumber);
newSelection = textEditor.createSelection(
{lineNumber: start[0], columnNumber: start[1]}, {lineNumber: end[0], columnNumber: end[1]});
} else {
await this.setContent(this.rawContent || '');
this.baseDoc = textEditor.state.doc;
const start = this.prettyToRawLocation(startPos.lineNumber, startPos.columnNumber);
const end = this.prettyToRawLocation(endPos.lineNumber, endPos.columnNumber);
newSelection = textEditor.createSelection(
{lineNumber: start[0], columnNumber: start[1]}, {lineNumber: end[0], columnNumber: end[1]});
}
if (wasLoaded) {
textEditor.revealPosition(newSelection, false);
}
this.prettyToggle.setEnabled(true);
this.updatePrettyPrintState();
}
// If this is a disassembled WASM file or a pretty-printed file,
// wire in a line number formatter that shows binary offsets or line
// numbers in the original source.
private getLineNumberFormatter(): CodeMirror.Extension {
if (this.options.lineNumbers === false) {
return [];
}
let formatNumber = undefined;
if (this.wasmDisassemblyInternal) {
const disassembly = this.wasmDisassemblyInternal;
const lastBytecodeOffset = disassembly.lineNumberToBytecodeOffset(disassembly.lineNumbers - 1);
const bytecodeOffsetDigits = lastBytecodeOffset.toString(16).length + 1;
formatNumber = (lineNumber: number) => {
const bytecodeOffset =
disassembly.lineNumberToBytecodeOffset(Math.min(disassembly.lineNumbers, lineNumber) - 1);
return `0x${bytecodeOffset.toString(16).padStart(bytecodeOffsetDigits, '0')}`;
};
} else if (this.prettyInternal) {
formatNumber = (lineNumber: number, state: CodeMirror.EditorState) => {
// @codemirror/view passes a high number here to estimate the
// maximum width to allocate for the line number gutter.
if (lineNumber < 2 || lineNumber > state.doc.lines) {
return String(lineNumber);
}
const [currLine] = this.prettyToRawLocation(lineNumber - 1);
const [prevLine] = this.prettyToRawLocation(lineNumber - 2);
if (currLine !== prevLine) {
return String(currLine + 1);
}
return '-';
};
}
return formatNumber ? [CodeMirror.lineNumbers({formatNumber}), LINE_NUMBER_FORMATTER.of(formatNumber)] : [];
}
private updateLineNumberFormatter(): void {
this.textEditor.dispatch({effects: config.lineNumbers.reconfigure(this.getLineNumberFormatter())});
this.textEditor.shadowRoot?.querySelector('.cm-lineNumbers')
?.setAttribute('jslog', `${VisualLogging.gutter('line-numbers').track({click: true})}`);
}
private updatePrettyPrintState(): void {
this.prettyToggle.setToggled(this.prettyInternal);
this.textEditorInternal.classList.toggle('pretty-printed', this.prettyInternal);
this.updateLineNumberFormatter();
}
private prettyToRawLocation(line: number, column: number|undefined = 0): number[] {
if (!this.formattedMap) {
return [line, column];
}
return this.formattedMap.formattedToOriginal(line, column);
}
private rawToPrettyLocation(line: number, column: number): number[] {
if (!this.formattedMap) {
return [line, column];
}
return this.formattedMap.originalToFormatted(line, column);
}
hasLoadError(): boolean {
return this.loadError;
}
override wasShown(): void {
void this.ensureContentLoaded();
this.wasShownOrLoaded();
}
override willHide(): void {
super.willHide();
this.clearPositionToReveal();
}
override async toolbarItems(): Promise<UI.Toolbar.ToolbarItem[]> {
return [this.prettyToggle, this.sourcePosition, this.progressToolbarItem];
}
get loaded(): boolean {
return this.loadedInternal;
}
get textEditor(): TextEditor.TextEditor.TextEditor {
return this.textEditorInternal;
}
get pretty(): boolean {
return this.prettyInternal;
}
get contentType(): string {
return this.loadError ? '' : this.getContentType();
}
protected getContentType(): string {
return '';
}
private async ensureContentLoaded(): Promise<void> {
if (!this.contentRequested) {
this.contentRequested = true;
await this.setContentDataOrError(this.lazyContent());
this.contentSet = true;
}
}
protected async setContentDataOrError(contentDataPromise: Promise<TextUtils.ContentData.ContentDataOrError>):
Promise<void> {
const progressIndicator = new UI.ProgressIndicator.ProgressIndicator();
progressIndicator.setTitle(i18nString(UIStrings.loading));
progressIndicator.setTotalWork(100);
this.progressToolbarItem.element.appendChild(progressIndicator.element);
progressIndicator.setWorked(1);
const contentData = await contentDataPromise;
let error: string|undefined;
let content: CodeMirror.Text|string|null;
let isMinified = false;
if (TextUtils.ContentData.ContentData.isError(contentData)) {
error = contentData.error;
content = contentData.error;
} else if (contentData instanceof TextUtils.WasmDisassembly.WasmDisassembly) {
content = CodeMirror.Text.of(contentData.lines);
this.wasmDisassemblyInternal = contentData;
} else if (contentData.isTextContent) {
content = contentData.text;
isMinified = TextUtils.TextUtils.isMinified(contentData.text);
this.wasmDisassemblyInternal = null;
} else if (contentData.mimeType === 'application/wasm') {
// The network panel produces ContentData with raw WASM inside. We have to manually disassemble that
// as V8 might not know about it.
this.wasmDisassemblyInternal = await SDK.Script.disassembleWasm(contentData.base64);
content = CodeMirror.Text.of(this.wasmDisassemblyInternal.lines);
} else {
error = i18nString(UIStrings.binaryContentError);
content = null;
this.wasmDisassemblyInternal = null;
}
progressIndicator.setWorked(100);
progressIndicator.done();
if (this.rawContent === content && error === undefined) {
return;
}
this.rawContent = content;
this.formattedMap = null;
this.prettyToggle.setEnabled(true);
if (error) {
this.loadError = true;
this.textEditor.state = this.placeholderEditorState(error);
this.prettyToggle.setEnabled(false);
} else if (this.shouldAutoPrettyPrint && isMinified) {
await this.setPretty(true);
} else {
await this.setContent(this.rawContent || '');
}
}
revealPosition(position: RevealPosition, shouldHighlight?: boolean): void {
this.lineToScrollTo = null;
this.selectionToSet = null;
if (typeof position === 'number') {
let line = 0, column = 0;
const {doc} = this.textEditor.state;
if (position > doc.length) {
line = doc.lines - 1;
} else if (position >= 0) {
const lineObj = doc.lineAt(position);
line = lineObj.number - 1;
column = position - lineObj.from;
}
this.positionToReveal = {to: {lineNumber: line, columnNumber: column}, shouldHighlight};
} else if ('lineNumber' in position) {
const {lineNumber, columnNumber} = position;
this.positionToReveal = {to: {lineNumber, columnNumber: columnNumber ?? 0}, shouldHighlight};
} else {
this.positionToReveal = {...position, shouldHighlight};
}
this.innerRevealPositionIfNeeded();
}
private innerRevealPositionIfNeeded(): void {
if (!this.positionToReveal) {
return;
}
if (!this.loaded || !this.isShowing()) {
return;
}
const {from, to, shouldHighlight} = this.positionToReveal;
const toLocation = this.uiLocationToEditorLocation(to.lineNumber, to.columnNumber);
const fromLocation = from ? this.uiLocationToEditorLocation(from.lineNumber, from.columnNumber) : undefined;
const {textEditor} = this;
textEditor.revealPosition(textEditor.createSelection(toLocation, fromLocation), shouldHighlight);
this.positionToReveal = null;
}
private clearPositionToReveal(): void {
this.positionToReveal = null;
}
scrollToLine(line: number): void {
this.clearPositionToReveal();
this.lineToScrollTo = line;
this.innerScrollToLineIfNeeded();
}
private innerScrollToLineIfNeeded(): void {
if (this.lineToScrollTo !== null) {
if (this.loaded && this.isShowing()) {
const {textEditor} = this;
const position = textEditor.toOffset({lineNumber: this.lineToScrollTo, columnNumber: 0});
textEditor.dispatch({effects: CodeMirror.EditorView.scrollIntoView(position, {y: 'start', yMargin: 0})});
this.lineToScrollTo = null;
}
}
}
setSelection(textRange: TextUtils.TextRange.TextRange): void {
this.selectionToSet = textRange;
this.innerSetSelectionIfNeeded();
}
private innerSetSelectionIfNeeded(): void {
const sel = this.selectionToSet;
if (sel && this.loaded && this.isShowing()) {
const {textEditor} = this;
textEditor.dispatch({
selection: textEditor.createSelection(
{lineNumber: sel.startLine, columnNumber: sel.startColumn},
{lineNumber: sel.endLine, columnNumber: sel.endColumn}),
});
this.selectionToSet = null;
}
}
private wasShownOrLoaded(): void {
this.innerRevealPositionIfNeeded();
this.innerSetSelectionIfNeeded();
this.innerScrollToLineIfNeeded();
this.textEditor.shadowRoot?.querySelector('.cm-lineNumbers')
?.setAttribute('jslog', `${VisualLogging.gutter('line-numbers').track({click: true})}`);
this.textEditor.shadowRoot?.querySelector('.cm-foldGutter')
?.setAttribute('jslog', `${VisualLogging.gutter('fold')}`);
this.textEditor.setAttribute('jslog', `${VisualLogging.textField().track({change: true})}`);
}
onTextChanged(): void {
const wasPretty = this.pretty;
this.prettyInternal = Boolean(this.prettyBaseDoc && this.textEditor.state.doc.eq(this.prettyBaseDoc));
if (this.prettyInternal !== wasPretty) {
this.updatePrettyPrintState();
}
this.prettyToggle.setEnabled(this.isClean());
if (this.searchConfig && this.searchableView) {
this.performSearch(this.searchConfig, false, false);
}
}
isClean(): boolean {
return this.textEditor.state.doc.eq(this.baseDoc) ||
(this.prettyBaseDoc !== null && this.textEditor.state.doc.eq(this.prettyBaseDoc));
}
contentCommitted(): void {
this.baseDoc = this.textEditorInternal.state.doc;
this.prettyBaseDoc = null;
this.rawContent = this.textEditor.state.doc.toString();
this.formattedMap = null;
if (this.prettyInternal) {
this.prettyInternal = false;
this.updatePrettyPrintState();
}
this.prettyToggle.setEnabled(true);
}
protected async getLanguageSupport(content: string|CodeMirror.Text): Promise<CodeMirror.Extension> {
// This is a pretty horrible work-around for webpack-based Vue2 setups. See
// https://crbug.com/1416562 for the full story behind this.
let {contentType} = this;
if (contentType === 'text/x.vue') {
content = typeof content === 'string' ? content : content.sliceString(0);
if (!content.trimStart().startsWith('<')) {
contentType = 'text/javascript';
}
}
const languageDesc = await CodeHighlighter.CodeHighlighter.languageFromMIME(contentType);
if (!languageDesc) {
return [];
}
return [
languageDesc,
CodeMirror.javascript.javascriptLanguage.data.of({autocomplete: CodeMirror.completeAnyWord}),
];
}
async updateLanguageMode(content: string): Promise<void> {
const langExtension = await this.getLanguageSupport(content);
this.textEditor.dispatch({effects: config.language.reconfigure(langExtension)});
}
async setContent(content: string|CodeMirror.Text): Promise<void> {
const {textEditor} = this;
const wasLoaded = this.loadedInternal;
const scrollTop = textEditor.editor.scrollDOM.scrollTop;
this.loadedInternal = true;
const languageSupport = await this.getLanguageSupport(content);
const editorState = CodeMirror.EditorState.create({
doc: content,
extensions: [
this.editorConfiguration(content),
languageSupport,
config.lineNumbers.of(this.getLineNumberFormatter()),
config.editable.of(this.editable ? [] : CodeMirror.EditorState.readOnly.of(true)),
],
});
this.baseDoc = editorState.doc;
textEditor.state = editorState;
if (wasLoaded) {
textEditor.editor.scrollDOM.scrollTop = scrollTop;
}
this.wasShownOrLoaded();
if (this.delayedFindSearchMatches) {
this.delayedFindSearchMatches();
this.delayedFindSearchMatches = null;
}
}
setSearchableView(view: UI.SearchableView.SearchableView|null): void {
this.searchableView = view;
}
private doFindSearchMatches(
searchConfig: UI.SearchableView.SearchConfig, shouldJump: boolean, jumpBackwards: boolean): void {
this.currentSearchResultIndex = -1;
this.searchRegex = searchConfig.toSearchRegex(true);
this.searchResults = this.collectRegexMatches(this.searchRegex);
if (this.searchableView) {
this.searchableView.updateSearchMatchesCount(this.searchResults.length);
}
const editor = this.textEditor;
if (!this.searchResults.length) {
if (editor.state.field(activeSearchState)) {
editor.dispatch({effects: setActiveSearch.of(null)});
}
} else if (shouldJump && jumpBackwards) {
this.jumpToPreviousSearchResult();
} else if (shouldJump) {
this.jumpToNextSearchResult();
} else {
editor.dispatch({effects: setActiveSearch.of(new ActiveSearch(this.searchRegex, null))});
}
}
performSearch(searchConfig: UI.SearchableView.SearchConfig, shouldJump: boolean, jumpBackwards?: boolean): void {
if (this.searchableView) {
this.searchableView.updateSearchMatchesCount(0);
}
this.resetSearch();
this.searchConfig = searchConfig;
if (this.loaded) {
this.doFindSearchMatches(searchConfig, shouldJump, Boolean(jumpBackwards));
} else {
this.delayedFindSearchMatches =
this.doFindSearchMatches.bind(this, searchConfig, shouldJump, Boolean(jumpBackwards));
}
void this.ensureContentLoaded();
}
private resetCurrentSearchResultIndex(): void {
if (!this.searchResults.length) {
return;
}
this.currentSearchResultIndex = -1;
if (this.searchableView) {
this.searchableView.updateCurrentMatchIndex(this.currentSearchResultIndex);
}
const editor = this.textEditor;
const currentActiveSearch = editor.state.field(activeSearchState);
if (currentActiveSearch?.currentRange) {
editor.dispatch({effects: setActiveSearch.of(new ActiveSearch(currentActiveSearch.regexp, null))});
}
}
private resetSearch(): void {
this.searchConfig = null;
this.delayedFindSearchMatches = null;
this.currentSearchResultIndex = -1;
this.searchResults = [];
this.searchRegex = null;
}
onSearchCanceled(): void {
const range = this.currentSearchResultIndex !== -1 ? this.searchResults[this.currentSearchResultIndex] : null;
this.resetSearch();
if (!this.loaded) {
return;
}
const editor = this.textEditor;
editor.dispatch({
effects: setActiveSearch.of(null),
selection: range ? {anchor: range.from, head: range.to} : undefined,
scrollIntoView: true,
userEvent: 'select.search.cancel',
});
}
jumpToLastSearchResult(): void {
this.jumpToSearchResult(this.searchResults.length - 1);
}
private searchResultIndexForCurrentSelection(): number {
return Platform.ArrayUtilities.lowerBound(
this.searchResults, this.textEditor.state.selection.main, (a, b) => a.to - b.to);
}
jumpToNextSearchResult(): void {
const currentIndex = this.searchResultIndexForCurrentSelection();
const nextIndex = this.currentSearchResultIndex === -1 ? currentIndex : currentIndex + 1;
this.jumpToSearchResult(nextIndex);
}
jumpToPreviousSearchResult(): void {
const currentIndex = this.searchResultIndexForCurrentSelection();
this.jumpToSearchResult(currentIndex - 1);
}
supportsCaseSensitiveSearch(): boolean {
return true;
}
supportsRegexSearch(): boolean {
return true;
}
jumpToSearchResult(index: number): void {
if (!this.loaded || !this.searchResults.length || !this.searchRegex) {
return;
}
this.currentSearchResultIndex = (index + this.searchResults.length) % this.searchResults.length;
if (this.searchableView) {
this.searchableView.updateCurrentMatchIndex(this.currentSearchResultIndex);
}
const editor = this.textEditor;
const range = this.searchResults[this.currentSearchResultIndex];
editor.dispatch({
effects: setActiveSearch.of(new ActiveSearch(this.searchRegex, range)),
selection: {anchor: range.from, head: range.to},
scrollIntoView: true,
userEvent: 'select.search',
});
}
replaceSelectionWith(_searchConfig: UI.SearchableView.SearchConfig, replacement: string): void {
const range = this.searchResults[this.currentSearchResultIndex];
if (!range) {
return;
}
const insert = this.searchRegex?.fromQuery ? range.insertPlaceholders(replacement) : replacement;
const editor = this.textEditor;
const changes = editor.state.changes({from: range.from, to: range.to, insert});
editor.dispatch(
{changes, selection: {anchor: changes.mapPos(editor.state.selection.main.to, 1)}, userEvent: 'input.replace'});
}
replaceAllWith(searchConfig: UI.SearchableView.SearchConfig, replacement: string): void {
this.resetCurrentSearchResultIndex();
const regex = searchConfig.toSearchRegex(true);
const ranges = this.collectRegexMatches(regex);
if (!ranges.length) {
return;
}
const isRegExp = regex.fromQuery;
const changes = ranges.map(
match =>
({from: match.from, to: match.to, insert: isRegExp ? match.insertPlaceholders(replacement) : replacement}));
this.textEditor.dispatch({changes, scrollIntoView: true, userEvent: 'input.replace.all'});
}
private collectRegexMatches({regex}: UI.SearchableView.SearchRegexResult): SearchMatch[] {
const ranges = [];
let pos = 0;
for (const line of this.textEditor.state.doc.iterLines()) {
regex.lastIndex = 0;
for (;;) {
const match = regex.exec(line);
if (!match) {
break;
}
if (match[0].length) {
const from = pos + match.index;
ranges.push(new SearchMatch(from, from + match[0].length, match));
} else {
regex.lastIndex = match.index + 1;
}
}
pos += line.length + 1;
}
return ranges;
}
canEditSource(): boolean {
return this.editable;
}
private updateSourcePosition(): void {
const {textEditor} = this, {state} = textEditor, {selection} = state;
if (this.displayedSelection?.eq(selection)) {
return;
}
this.displayedSelection = selection;
if (selection.ranges.length > 1) {
this.sourcePosition.setText(i18nString(UIStrings.dSelectionRegions, {PH1: selection.ranges.length}));
return;
}
const {main} = state.selection;
if (main.empty) {
const {lineNumber, columnNumber} = textEditor.toLineColumn(main.head);
const location = this.prettyToRawLocation(lineNumber, columnNumber);
if (this.wasmDisassemblyInternal) {
const disassembly = this.wasmDisassemblyInternal;
const lastBytecodeOffset = disassembly.lineNumberToBytecodeOffset(disassembly.lineNumbers - 1);
const bytecodeOffsetDigits = lastBytecodeOffset.toString(16).length;
const bytecodeOffset = disassembly.lineNumberToBytecodeOffset(location[0]);
this.sourcePosition.setText(i18nString(
UIStrings.bytecodePositionXs, {PH1: bytecodeOffset.toString(16).padStart(bytecodeOffsetDigits, '0')}));
} else {
this.sourcePosition.setText(i18nString(UIStrings.lineSColumnS, {PH1: location[0] + 1, PH2: location[1] + 1}));
}
} else {
const startLine = state.doc.lineAt(main.from), endLine = state.doc.lineAt(main.to);
if (startLine.number === endLine.number) {
this.sourcePosition.setText(i18nString(UIStrings.dCharactersSelected, {PH1: main.to - main.from}));
} else {
this.sourcePosition.setText(i18nString(
UIStrings.dLinesDCharactersSelected,
{PH1: endLine.number - startLine.number + 1, PH2: main.to - main.from}));
}
}
}
onContextMenu(event: MouseEvent): boolean {
event.consume(true); // Consume event now to prevent document from handling the async menu
const contextMenu = new UI.ContextMenu.ContextMenu(event);
const {state} = this.textEditor;
const pos = state.selection.main.from, line = state.doc.lineAt(pos);
this.populateTextAreaContextMenu(contextMenu, line.number - 1, pos - line.from);
contextMenu.appendApplicableItems(this);
void contextMenu.show();
return true;
}
protected populateTextAreaContextMenu(_menu: UI.ContextMenu.ContextMenu, _lineNumber: number, _columnNumber: number):
void {
}
onLineGutterContextMenu(position: number, event: MouseEvent): boolean {
event.consume(true); // Consume event now to prevent document from handling the async menu
const contextMenu = new UI.ContextMenu.ContextMenu(event);
const lineNumber = this.textEditor.state.doc.lineAt(position).number - 1;
this.populateLineGutterContextMenu(contextMenu, lineNumber);
contextMenu.appendApplicableItems(this);
void contextMenu.show();
return true;
}
protected populateLineGutterContextMenu(_menu: UI.ContextMenu.ContextMenu, _lineNumber: number): void {
}
override focus(): void {
this.textEditor.focus();
}
}
class SearchMatch {
constructor(readonly from: number, readonly to: number, readonly match: RegExpMatchArray) {
}
insertPlaceholders(replacement: string): string {
return replacement.replace(/\$(\$|&|\d+|<[^>]+>)/g, (_, selector) => {
if (selector === '$') {
return '$';
}
if (selector === '&') {
return this.match[0];
}
if (selector[0] === '<') {
return (this.match.groups?.[selector.slice(1, selector.length - 1)]) || '';
}
return this.match[Number.parseInt(selector, 10)] || '';
});
}
}
export interface Transformer {
editorLocationToUILocation(lineNumber: number, columnNumber: number): {
lineNumber: number,
columnNumber: number,
};
editorLocationToUILocation(lineNumber: number): {
lineNumber: number,
columnNumber: number|undefined,
};
uiLocationToEditorLocation(lineNumber: number, columnNumber?: number): {
lineNumber: number,
columnNumber: number,
};
}
export const enum DecoratorType {
PERFORMANCE = 'performance',
MEMORY = 'memory',
COVERAGE = 'coverage',
}
const config = {
editable: new CodeMirror.Compartment(),
language: new CodeMirror.Compartment(),
lineNumbers: new CodeMirror.Compartment(),
};
class ActiveSearch {
constructor(
readonly regexp: UI.SearchableView.SearchRegexResult, readonly currentRange: {from: number, to: number}|null) {
}
map(change: CodeMirror.ChangeDesc): ActiveSearch {
return change.empty || !this.currentRange ?
this :
new ActiveSearch(
this.regexp, {from: change.mapPos(this.currentRange.from), to: change.mapPos(this.currentRange.to)});
}
static eq(a: ActiveSearch|null, b: ActiveSearch|null): boolean {
return Boolean(
a === b ||
a && b && a.currentRange?.from === b.currentRange?.from && a.currentRange?.to === b.currentRange?.to &&
a.regexp.regex.source === b.regexp.regex.source && a.regexp.regex.flags === b.regexp.regex.flags);
}
}
const setActiveSearch =
CodeMirror.StateEffect.define<ActiveSearch|null>({map: (value, mapping) => value?.map(mapping)});
const activeSearchState = CodeMirror.StateField.define<ActiveSearch|null>({
create(): null {
return null;
},
update(state, tr): ActiveSearch |
null {
return tr.effects.reduce(
(state, effect) => effect.is(setActiveSearch) ? effect.value : state, state?.map(tr.changes) ?? null);
},
});
const searchMatchDeco = CodeMirror.Decoration.mark({class: 'cm-searchMatch'});
const currentSearchMatchDeco = CodeMirror.Decoration.mark({class: 'cm-searchMatch cm-searchMatch-selected'});
const searchHighlighter = CodeMirror.ViewPlugin.fromClass(class {
decorations: CodeMirror.DecorationSet;
constructor(view: CodeMirror.EditorView) {
this.decorations = this.computeDecorations(view);
}
update(update: CodeMirror.ViewUpdate): void {
const active = update.state.field(activeSearchState);
if (!ActiveSearch.eq(active, update.startState.field(activeSearchState)) ||
(active && (update.viewportChanged || update.docChanged))) {
this.decorations = this.computeDecorations(update.view);
}
}
private computeDecorations(view: CodeMirror.EditorView): CodeMirror.DecorationSet {
const active = view.state.field(activeSearchState);
if (!active) {
return CodeMirror.Decoration.none;
}
const builder = new CodeMirror.RangeSetBuilder<CodeMirror.Decoration>();
const {doc} = view.state;
for (const {from, to} of view.visibleRanges) {
let pos = from;
for (const part of doc.iterRange(from, to)) {
if (part !== '\n') {
active.regexp.regex.lastIndex = 0;
for (;;) {
const match = active.regexp.regex.exec(part);
if (!match) {
break;
}
if (match[0].length) {
const start = pos + match.index, end = start + match[0].length;
const current =
active.currentRange && active.currentRange.from === start && active.currentRange.to === end;
builder.add(start, end, current ? currentSearchMatchDeco : searchMatchDeco);
} else {
active.regexp.regex.lastIndex = match.index + 1;
}
}
}
pos += part.length;
}
}
return builder.finish();
}
}, {decorations: value => value.decorations});
const nonBreakableLineMark = new (class extends CodeMirror.GutterMarker {
override elementClass = 'cm-nonBreakableLine';
})();
// Effect to add lines (by position) to the set of non-breakable lines.
export const addNonBreakableLines = CodeMirror.StateEffect.define<readonly number[]>();
const nonBreakableLines = CodeMirror.StateField.define<CodeMirror.RangeSet<CodeMirror.GutterMarker>>({
create(): CodeMirror.RangeSet<CodeMirror.GutterMarker> {
return CodeMirror.RangeSet.empty;
},
update(deco, tr): CodeMirror.RangeSet<CodeMirror.GutterMarker> {
return tr.effects.reduce((deco, effect) => {
return !effect.is(addNonBreakableLines) ?
deco :
deco.update({add: effect.value.map(pos => nonBreakableLineMark.range(pos))});
}, deco.map(tr.changes));
},
provide: field => CodeMirror.lineNumberMarkers.from(field),
});
export function isBreakableLine(state: CodeMirror.EditorState, line: CodeMirror.Line): boolean {
const nonBreakable = state.field(nonBreakableLines);
if (!nonBreakable.size) {
return true;
}
let found = false;
nonBreakable.between(line.from, line.from, () => {
found = true;
});
return !found;
}
function markNonBreakableLines(disassembly: TextUtils.WasmDisassembly.WasmDisassembly): CodeMirror.Extension {
// Mark non-breakable lines in the Wasm disassembly after setting
// up the content for the text editor (which creates the gutter).
return nonBreakableLines.init(state => {
const marks = [];
for (const lineNumber of disassembly.nonBreakableLineNumbers()) {
if (lineNumber < state.doc.lines) {
marks.push(nonBreakableLineMark.range(state.doc.line(lineNumber + 1).from));
}
}
return CodeMirror.RangeSet.of(marks);
});
}
const sourceFrameTheme = CodeMirror.EditorView.theme({
'&.cm-editor': {height: '100%'},
'.cm-scroller': {overflow: 'auto'},
'.cm-lineNumbers .cm-gutterElement.cm-nonBreakableLine': {color: 'var(--sys-color-state-disabled) !important'},
'.cm-searchMatch': {
border: '1px solid var(--sys-color-outline)',
borderRadius: '3px',
margin: '0 -1px',
'&.cm-searchMatch-selected': {
borderRadius: '1px',
backgroundColor: 'var(--sys-color-yellow-container)',
borderColor: 'var(--sys-color-yellow-outline)',
'&, & *': {
color: 'var(--sys-color-on-surface) !important',
},
},
},
':host-context(.pretty-printed) & .cm-lineNumbers .cm-gutterElement': {
color: 'var(--sys-color-primary)',
},
});
/**
* Reveal position can either be a single point or a range.
*
* A single point can either be specified as a line/column combo or as an absolute
* editor offset.
*/
export type RevealPosition = number|{lineNumber: number, columnNumber?: number}|
{from: {lineNumber: number, columnNumber: number}, to: {lineNumber: number, columnNumber: number}};
// Infobar panel state, used to show additional panels below the editor.
export const addInfobar = CodeMirror.StateEffect.define<UI.Infobar.Infobar>();
export const removeInfobar = CodeMirror.StateEffect.define<UI.Infobar.Infobar>();
const infobarState = CodeMirror.StateField.define<UI.Infobar.Infobar[]>({
create(): UI.Infobar.Infobar[] {
return [];
},
update(current, tr): UI.Infobar.Infobar[] {
for (const effect of tr.effects) {
if (effect.is(addInfobar)) {
current = current.concat(effect.value);
} else if (effect.is(removeInfobar)) {
current = current.filter(b => b !== effect.value);
}
}
return current;
},
provide: (field): CodeMirror.Extension => CodeMirror.showPanel.computeN(
[field],
(state): Array<() => CodeMirror.Panel> =>
state.field(field).map((bar): (() => CodeMirror.Panel) => (): CodeMirror.Panel => ({dom: bar.element}))),
});