| /* |
| * 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. |
| */ |
| |
| import * as Common from '../../../../core/common/common.js'; |
| import * as i18n from '../../../../core/i18n/i18n.js'; |
| import * as Platform from '../../../../core/platform/platform.js'; |
| import * as Formatter from '../../../../models/formatter/formatter.js'; |
| import * as TextUtils from '../../../../models/text_utils/text_utils.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 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', |
| }; |
| 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 { |
| EditorUpdate = 'EditorUpdate', |
| EditorScroll = 'EditorScroll', |
| } |
| |
| export type EventTypes = { |
| [Events.EditorUpdate]: CodeMirror.ViewUpdate, |
| [Events.EditorScroll]: void, |
| }; |
| |
| 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.ContentProvider.DeferredContent>; |
| 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 muteChangeEventsForSetContent: boolean; |
| private readonly sourcePosition: UI.Toolbar.ToolbarText; |
| private searchableView: UI.SearchableView.SearchableView|null; |
| private editable: boolean; |
| private positionToReveal: { |
| line: number, |
| column: (number|undefined), |
| shouldHighlight: (boolean|undefined), |
| }|null; |
| private lineToScrollTo: number|null; |
| private selectionToSet: TextUtils.TextRange.TextRange|null; |
| private loadedInternal: boolean; |
| private contentRequested: boolean; |
| private wasmDisassemblyInternal: Common.WasmDisassembly.WasmDisassembly|null; |
| contentSet: boolean; |
| constructor( |
| lazyContent: () => Promise<TextUtils.ContentProvider.DeferredContent>, |
| 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), 'largeicon-pretty-print'); |
| this.prettyToggle.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, () => { |
| void this.setPretty(!this.prettyToggle.toggled()); |
| }); |
| 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): void => { |
| 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.muteChangeEventsForSetContent = 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; |
| |
| Common.Settings.Settings.instance() |
| .moduleSetting('textEditorIndent') |
| .addChangeListener(this.#textEditorIndentChanged, this); |
| } |
| |
| disposeView(): void { |
| Common.Settings.Settings.instance() |
| .moduleSetting('textEditorIndent') |
| .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.EditorUpdate, update)), |
| TextEditor.Config.baseConfiguration(doc), |
| TextEditor.Config.closeBrackets, |
| TextEditor.Config.sourcesAutocompletion.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(), |
| scroll: () => this.dispatchEventToListeners(Events.EditorScroll), |
| 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() : [], |
| ]; |
| } |
| |
| protected onBlur(): void { |
| } |
| |
| protected onFocus(): void { |
| this.resetCurrentSearchResultIndex(); |
| } |
| |
| get wasmDisassembly(): Common.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 = canPrettyPrint && Boolean(autoPrettyPrint); |
| 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 = null; |
| if (this.wasmDisassemblyInternal) { |
| const disassembly = this.wasmDisassemblyInternal; |
| const lastBytecodeOffset = disassembly.lineNumberToBytecodeOffset(disassembly.lineNumbers - 1); |
| const bytecodeOffsetDigits = lastBytecodeOffset.toString(16).length + 1; |
| formatNumber = (lineNumber: number): string => { |
| 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): string => { |
| const line = this.prettyToRawLocation(lineNumber - 1, 0)[0] + 1; |
| if (lineNumber === 1) { |
| return String(line); |
| } |
| if (line !== this.prettyToRawLocation(lineNumber - 2, 0)[0] + 1) { |
| return String(line); |
| } |
| return '-'; |
| }; |
| } |
| return formatNumber ? CodeMirror.lineNumbers({formatNumber}) : []; |
| } |
| |
| private updateLineNumberFormatter(): void { |
| this.textEditor.dispatch({effects: config.lineNumbers.reconfigure(this.getLineNumberFormatter())}); |
| } |
| |
| 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; |
| } |
| |
| wasShown(): void { |
| void this.ensureContentLoaded(); |
| this.wasShownOrLoaded(); |
| } |
| |
| willHide(): void { |
| super.willHide(); |
| |
| this.clearPositionToReveal(); |
| } |
| |
| 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; |
| |
| const progressIndicator = new UI.ProgressIndicator.ProgressIndicator(); |
| progressIndicator.setTitle(i18nString(UIStrings.loading)); |
| progressIndicator.setTotalWork(100); |
| this.progressToolbarItem.element.appendChild(progressIndicator.element); |
| |
| progressIndicator.setWorked(1); |
| |
| const deferredContent = await this.lazyContent(); |
| let error, content; |
| if (deferredContent.content === null) { |
| error = deferredContent.error; |
| this.rawContent = deferredContent.error; |
| } else { |
| content = deferredContent.content; |
| if (deferredContent.isEncoded) { |
| const view = new DataView(Common.Base64.decode(deferredContent.content)); |
| const decoder = new TextDecoder(); |
| this.rawContent = decoder.decode(view, {stream: true}); |
| } else if ('wasmDisassemblyInfo' in deferredContent && deferredContent.wasmDisassemblyInfo) { |
| const {wasmDisassemblyInfo} = deferredContent; |
| this.rawContent = CodeMirror.Text.of(wasmDisassemblyInfo.lines); |
| this.wasmDisassemblyInternal = wasmDisassemblyInfo; |
| } else { |
| this.rawContent = content; |
| this.wasmDisassemblyInternal = null; |
| } |
| } |
| |
| // If the input is wasm but v8-based wasm disassembly failed, fall back to wasmparser for backwards compatibility. |
| if (content && this.contentType === 'application/wasm' && !this.wasmDisassemblyInternal) { |
| const worker = Common.Worker.WorkerWrapper.fromURL( |
| new URL('../../../../entrypoints/wasmparser_worker/wasmparser_worker-entrypoint.js', import.meta.url)); |
| const promise = new Promise<{ |
| lines: string[], |
| offsets: number[], |
| functionBodyOffsets: { |
| start: number, |
| end: number, |
| }[], |
| }>((resolve, reject) => { |
| worker.onmessage = |
| // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration) |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| ({data}: MessageEvent<any>): void => { |
| if ('event' in data) { |
| switch (data.event) { |
| case 'progress': |
| progressIndicator.setWorked(data.params.percentage); |
| break; |
| } |
| } else if ('method' in data) { |
| switch (data.method) { |
| case 'disassemble': |
| if ('error' in data) { |
| reject(data.error); |
| } else if ('result' in data) { |
| resolve(data.result); |
| } |
| break; |
| } |
| } |
| }; |
| worker.onerror = reject; |
| }); |
| worker.postMessage({method: 'disassemble', params: {content}}); |
| try { |
| const {lines, offsets, functionBodyOffsets} = await promise; |
| this.rawContent = content = CodeMirror.Text.of(lines); |
| this.wasmDisassemblyInternal = |
| new Common.WasmDisassembly.WasmDisassembly(lines, offsets, functionBodyOffsets); |
| } catch (e) { |
| this.rawContent = content = error = e.message; |
| } finally { |
| worker.terminate(); |
| } |
| } |
| |
| progressIndicator.setWorked(100); |
| progressIndicator.done(); |
| |
| 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 && TextUtils.TextUtils.isMinified(content || '')) { |
| await this.setPretty(true); |
| } else { |
| await this.setContent(this.rawContent || ''); |
| } |
| } |
| this.contentSet = true; |
| } |
| } |
| |
| revealPosition(position: {lineNumber: number, columnNumber?: number}|number, shouldHighlight?: boolean): void { |
| this.lineToScrollTo = null; |
| this.selectionToSet = null; |
| let line = 0, column = 0; |
| if (typeof position === 'number') { |
| 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; |
| } |
| } else { |
| line = position.lineNumber; |
| column = position.columnNumber ?? 0; |
| } |
| this.positionToReveal = {line, column, shouldHighlight: shouldHighlight}; |
| this.innerRevealPositionIfNeeded(); |
| } |
| |
| private innerRevealPositionIfNeeded(): void { |
| if (!this.positionToReveal) { |
| return; |
| } |
| |
| if (!this.loaded || !this.isShowing()) { |
| return; |
| } |
| |
| const location = this.uiLocationToEditorLocation(this.positionToReveal.line, this.positionToReveal.column); |
| |
| const {textEditor} = this; |
| textEditor.revealPosition(textEditor.createSelection(location), this.positionToReveal.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(); |
| } |
| |
| 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(): Promise<CodeMirror.Extension> { |
| const languageDesc = await CodeHighlighter.CodeHighlighter.languageFromMIME(this.contentType); |
| if (!languageDesc) { |
| return []; |
| } |
| if (this.contentType === 'text/jsx') { |
| return [ |
| languageDesc, |
| CodeMirror.javascript.javascriptLanguage.data.of({autocomplete: CodeMirror.completeAnyWord}), |
| ]; |
| } |
| return languageDesc; |
| } |
| |
| async updateLanguageMode(): Promise<void> { |
| const langExtension = await this.getLanguageSupport(); |
| this.textEditor.dispatch({effects: config.language.reconfigure(langExtension)}); |
| } |
| |
| async setContent(content: string|CodeMirror.Text): Promise<void> { |
| this.muteChangeEventsForSetContent = true; |
| const {textEditor} = this; |
| const wasLoaded = this.loadedInternal; |
| const scrollTop = textEditor.editor.scrollDOM.scrollTop; |
| this.loadedInternal = true; |
| |
| const languageSupport = await this.getLanguageSupport(); |
| 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; |
| } |
| this.muteChangeEventsForSetContent = false; |
| } |
| |
| 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 && 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; |
| } |
| |
| searchCanceled(): 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): number => 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)); |
| } |
| } |
| 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 { |
| } |
| |
| 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): string => { |
| if (selector === '$') { |
| return '$'; |
| } |
| if (selector === '&') { |
| return this.match[0]; |
| } |
| if (selector[0] === '<') { |
| return (this.match.groups && 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, |
| }; |
| } |
| |
| // TODO(crbug.com/1167717): Make this a const enum again |
| // eslint-disable-next-line rulesdir/const_enum |
| export 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): ActiveSearch | null => value && 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 && state.map(tr.changes)); |
| }, |
| }); |
| |
| 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; |
| } |
| 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); |
| } |
| } |
| pos += part.length; |
| } |
| } |
| return builder.finish(); |
| } |
| }, {decorations: (value): CodeMirror.DecorationSet => value.decorations}); |
| |
| const nonBreakableLineMark = new (class extends CodeMirror.GutterMarker { |
| 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: Common.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(--color-non-breakable-line) !important'}, |
| '.cm-searchMatch': { |
| border: '1px solid var(--color-search-match-border)', |
| borderRadius: '3px', |
| margin: '0 -1px', |
| '&.cm-searchMatch-selected': { |
| borderRadius: '1px', |
| backgroundColor: 'var(--color-selected-search-match-background)', |
| borderColor: 'var(--color-selected-search-match-background)', |
| '&, & *': { |
| color: 'var(--color-selected-search-match) !important', |
| }, |
| }, |
| }, |
| ':host-context(.pretty-printed) & .cm-lineNumbers .cm-gutterElement': { |
| color: 'var(--color-primary)', |
| }, |
| }); |