| // Copyright 2011 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 @devtools/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; |
| protected 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({ |
| title: i18nString(UIStrings.source), |
| viewId: '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)', |
| }, |
| }), |
| sourceFrameInfobarState, |
| ]; |
| } |
| |
| 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 { |
| this.formattedMap = null; |
| 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 { |
| super.wasShown(); |
| 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 = document.createElement('devtools-progress'); |
| progressIndicator.title = i18nString(UIStrings.loading); |
| progressIndicator.totalWork = 100; |
| this.progressToolbarItem.element.appendChild(progressIndicator); |
| |
| progressIndicator.worked = 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.worked = 100; |
| progressIndicator.done = true; |
| |
| 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.#revealPositionIfNeeded(); |
| } |
| |
| #revealPositionIfNeeded(): 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.#scrollToLineIfNeeded(); |
| } |
| |
| #scrollToLineIfNeeded(): 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.#setSelectionIfNeeded(); |
| } |
| |
| #setSelectionIfNeeded(): 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.#revealPositionIfNeeded(); |
| this.#setSelectionIfNeeded(); |
| this.#scrollToLineIfNeeded(); |
| 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; |
| } |
| |
| supportsWholeWordSearch(): 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, |
| }; |
| } |
| |
| 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?.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}}; |
| |
| /** This is usually an Infobar but is also used for AiCodeCompletionSummaryToolbar **/ |
| export interface SourceFrameInfobar { |
| element: HTMLElement; |
| order?: number; |
| } |
| |
| /** Infobar panel state, used to show additional panels below the editor. **/ |
| export const addSourceFrameInfobar = CodeMirror.StateEffect.define<SourceFrameInfobar>(); |
| export const removeSourceFrameInfobar = CodeMirror.StateEffect.define<SourceFrameInfobar>(); |
| |
| const sourceFrameInfobarState = CodeMirror.StateField.define<SourceFrameInfobar[]>({ |
| create(): SourceFrameInfobar[] { |
| return []; |
| }, |
| update(current, tr): SourceFrameInfobar[] { |
| for (const effect of tr.effects) { |
| if (effect.is(addSourceFrameInfobar)) { |
| current = current.concat(effect.value); |
| } else if (effect.is(removeSourceFrameInfobar)) { |
| current = current.filter(b => b.element !== effect.value.element); |
| } |
| } |
| return current; |
| }, |
| provide: (field): CodeMirror.Extension => CodeMirror.showPanel.computeN( |
| [field], |
| (state): Array<() => CodeMirror.Panel> => |
| state.field(field) |
| .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) |
| .map((bar): (() => CodeMirror.Panel) => (): CodeMirror.Panel => ({dom: bar.element}))), |
| }); |