// ***************************************************************************** // Copyright (C) 2018 Red Hat, Inc. and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { TextEditorConfiguration, TextEditorsMain, TextEditorConfigurationUpdate, SingleEditOperation } from '../common/plugin-api-rpc'; import { Range as ApiRange } from '../common/plugin-api-rpc-model'; import { Selection, Range, TextEditorLineNumbersStyle, SnippetString, Position, TextEditorRevealType, EndOfLine } from './types-impl'; import * as theia from '@theia/plugin'; import { DocumentDataExt } from './document-data'; import { readonly, illegalArgument } from '../common/errors'; import { TextEditorCursorStyle } from '../common/editor-options'; import { ok } from '../common/assert'; import * as Converter from './type-converters'; export class TextEditorExt implements theia.TextEditor { private _viewColumn: theia.ViewColumn | undefined; private _document: DocumentDataExt; private _options: TextEditorOptionsExt; private _diffInformation: theia.TextEditorDiffInformation[] | undefined; private disposed = false; constructor( private readonly proxy: TextEditorsMain, readonly id: string, document: DocumentDataExt, private _selections: Selection[], options: TextEditorConfiguration, private _visibleRanges: Range[], viewColumn: theia.ViewColumn | undefined) { this._options = new TextEditorOptionsExt(proxy, id, options); this._viewColumn = viewColumn; this._document = document; } get document(): theia.TextDocument { return this._document.document; } set document(doc) { throw readonly('Document'); } acceptViewColumn(val: theia.ViewColumn): void { this._viewColumn = val; } dispose(): void { this.disposed = true; } get options(): theia.TextEditorOptions { return this._options; } set options(val: theia.TextEditorOptions) { if (!this.disposed) { this._options.assign(val); } } acceptOptions(options: TextEditorConfiguration): void { ok(!this.disposed); this._options.accept(options); } get selection(): Selection { return this._selections && this._selections[0]; } set selection(val: Selection) { if (!(val instanceof Selection)) { throw illegalArgument('selection'); } this._selections = [val]; this.trySetSelection(); } private trySetSelection(): Promise { const selection = this._selections.map(Converter.fromSelection); return this.runOnProxy(() => this.proxy.$trySetSelections(this.id, selection)); } get selections(): Selection[] { return this._selections; } set selections(val: Selection[]) { if (!Array.isArray(val) || val.some(s => !(s instanceof Selection))) { throw illegalArgument('selections'); } this._selections = val; this.trySetSelection(); } acceptSelections(selections: Selection[]): void { ok(!this.disposed); this._selections = selections; } get visibleRanges(): Range[] { return this._visibleRanges; } set visibleRanges(val: Range[]) { throw readonly('visibleRanges'); } acceptVisibleRanges(range: Range[]): void { ok(!this.disposed); this._visibleRanges = range; } get viewColumn(): theia.ViewColumn | undefined { return this._viewColumn; } set viewColumn(value) { throw readonly('viewColumn'); } _acceptViewColumn(value: theia.ViewColumn): void { ok(!this.disposed); this._viewColumn = value; } // eslint-disable-next-line max-len edit(callback: (editBuilder: theia.TextEditorEdit) => void, options: { undoStopBefore: boolean; undoStopAfter: boolean; } = { undoStopBefore: true, undoStopAfter: true }): Promise { if (this.disposed) { return Promise.reject(new Error('TextEditor#edit not possible on closed editor')); } const edit = new TextEditorEdit(this._document.document, options); callback(edit); return this.applyEdit(edit); } // eslint-disable-next-line max-len insertSnippet(snippet: SnippetString, location?: Position | Range | Position[] | Range[], options: { undoStopBefore: boolean; undoStopAfter: boolean; } = { undoStopBefore: true, undoStopAfter: true }): Promise { if (this.disposed) { return Promise.reject(new Error('TextEditor#insertSnippet not possible on closed editors')); } let ranges: ApiRange[]; if (!location || (Array.isArray(location) && location.length === 0)) { ranges = this._selections.map(s => Converter.fromRange(s)!); } else if (location instanceof Position) { const { lineNumber, column } = Converter.fromPosition(location); ranges = [{ startLineNumber: lineNumber, startColumn: column, endLineNumber: lineNumber, endColumn: column }]; } else if (location instanceof Range) { ranges = [Converter.fromRange(location)!]; } else { ranges = []; for (const posOrRange of location) { if (posOrRange instanceof Range) { ranges.push(Converter.fromRange(posOrRange)!); } else { const { lineNumber, column } = Converter.fromPosition(posOrRange); ranges.push({ startLineNumber: lineNumber, startColumn: column, endLineNumber: lineNumber, endColumn: column }); } } } return this.proxy.$tryInsertSnippet(this.id, snippet.value, ranges, options); } setDecorations(decorationType: theia.TextEditorDecorationType, rangesOrOptions: Range[] | theia.DecorationOptions[]): void { this.runOnProxy(() => { if (Converter.isDecorationOptionsArr(rangesOrOptions)) { return this.proxy.$trySetDecorations( this.id, decorationType.key, Converter.fromRangeOrRangeWithMessage(rangesOrOptions) ); } else { const ranges: number[] = new Array(4 * rangesOrOptions.length); const len = rangesOrOptions.length; for (let i = 0; i < len; i++) { const range = rangesOrOptions[i]; ranges[4 * i] = range.start.line + 1; ranges[4 * i + 1] = range.start.character + 1; ranges[4 * i + 2] = range.end.line + 1; ranges[4 * i + 3] = range.end.character + 1; } return this.proxy.$trySetDecorationsFast( this.id, decorationType.key, ranges ); } }); } revealRange(range: Range, revealType?: theia.TextEditorRevealType): void { this.runOnProxy(() => this.proxy.$tryRevealRange(this.id, Converter.fromRange(range)!, (revealType || TextEditorRevealType.Default))); } private applyEdit(edit: TextEditorEdit): Promise { const editData = edit.finalize(); const editRanges = editData.edits.map(e => e.range); editRanges.sort((a, b) => { if (a.end.line === b.end.line) { if (a.end.character === b.end.character) { if (a.start.line === b.start.line) { return a.start.character - b.start.character; } return a.start.line - b.start.line; } return a.end.character - b.end.character; } return a.end.line - b.end.line; }); const count = editRanges.length - 1; for (let i = 0; i < count; i++) { const rangeEnd = editRanges[i].end; const nextRangeStart = editRanges[i + 1].start; if (nextRangeStart.isBefore(rangeEnd)) { return Promise.reject( new Error('Overlapping ranges are not allowed!') ); } } // prepare data for serialization const edits: SingleEditOperation[] = editData.edits.map(e => ({ range: Converter.fromRange(e.range)!, text: e.text, forceMoveMarkers: e.forceMoveMarkers })); return this.proxy.$tryApplyEdits(this.id, editData.documentVersionId, edits, { setEndOfLine: editData.setEndOfLine, undoStopBefore: editData.undoStopBefore, undoStopAfter: editData.undoStopAfter }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any private runOnProxy(callback: () => Promise): Promise { if (this.disposed) { console.warn('TextEditor is disposed!'); return Promise.resolve(undefined); } return callback().then(() => this, err => { if (!(err instanceof Error && err.name === 'DISPOSED')) { console.warn(err); } return undefined; }); } show(column?: theia.ViewColumn): void { this.proxy.$tryShowTextDocument(this.document.uri, { viewColumn: column, preview: true, }); } hide(): void { this.proxy.$tryHideEditor(this.id); } getDiffInformation(): Promise { return this.proxy.$getDiffInformation(this.id); } _acceptDiffInformation(diffInformation: theia.TextEditorDiffInformation[] | undefined): void { // ok(!this._disposed); this._diffInformation = diffInformation; } get diffInformation(): theia.TextEditorDiffInformation[] | undefined { return this._diffInformation; } } export class TextEditorOptionsExt implements theia.TextEditorOptions { private _tabSize: number; private _indentSize: number | 'tabSize'; private _insertSpace: boolean; private _cursorStyle: TextEditorCursorStyle; private _lineNumbers: TextEditorLineNumbersStyle; constructor(private readonly proxy: TextEditorsMain, private readonly id: string, source: TextEditorConfiguration) { this.accept(source); } accept(source: TextEditorConfiguration): void { this._tabSize = source.tabSize; this._indentSize = source.indentSize; this._insertSpace = source.insertSpaces; this._cursorStyle = source.cursorStyle; this._lineNumbers = source.lineNumbers; } get tabSize(): number { return this._tabSize; } set tabSize(val: number | string | undefined) { const tabSize = this.validateTabSize(val); if (!tabSize) { return; // ignore invalid values } if (typeof tabSize === 'number') { if (this._tabSize === tabSize) { return; } this._tabSize = tabSize; } warnOnError(this.proxy.$trySetOptions(this.id, { tabSize })); } private validateTabSize(val: number | string | undefined): number | 'auto' | undefined { if (val === 'auto') { return 'auto'; } if (typeof val === 'number') { const r = Math.floor(val); return r > 0 ? r : undefined; } if (typeof val === 'string') { const r = parseInt(val, undefined); if (isNaN(r)) { return undefined; } return r > 0 ? r : undefined; } return undefined; } get indentSize(): number { if (this._indentSize === 'tabSize') { return this.tabSize; } return this._indentSize; } set indentSize(val: number | string | undefined) { const indentSize = this.validateIndentSize(val); if (!indentSize) { return; // ignore invalid values } if (typeof indentSize === 'number') { if (this._indentSize === indentSize) { return; } this._indentSize = indentSize; } else if (val === 'tabSize') { this._indentSize = val; } warnOnError(this.proxy.$trySetOptions(this.id, { indentSize })); } private validateIndentSize(val: number | string | undefined): number | 'tabSize' | undefined { if (val === 'tabSize') { return 'tabSize'; } if (typeof val === 'number') { const r = Math.floor(val); return r > 0 ? r : undefined; } if (typeof val === 'string') { const r = parseInt(val, undefined); if (isNaN(r)) { return undefined; } return r > 0 ? r : undefined; } return undefined; } get insertSpaces(): boolean | string { return this._insertSpace; } set insertSpaces(val: boolean | string) { const insertSpaces = this.validateInsertSpaces(val); if (typeof insertSpaces === 'boolean') { if (this._insertSpace === insertSpaces) { return; } this._insertSpace = insertSpaces; } warnOnError(this.proxy.$trySetOptions(this.id, { insertSpaces })); } private validateInsertSpaces(val: boolean | string): boolean | 'auto' { if (val === 'auto') { return 'auto'; } return val === 'false' ? false : Boolean(val); } get cursorStyle(): TextEditorCursorStyle { return this._cursorStyle; } set cursorStyle(val: TextEditorCursorStyle) { if (this._cursorStyle === val) { return; } this._cursorStyle = val; warnOnError(this.proxy.$trySetOptions(this.id, { cursorStyle: val })); } get lineNumbers(): TextEditorLineNumbersStyle { return this._lineNumbers; } set lineNumbers(val: TextEditorLineNumbersStyle) { if (this._lineNumbers === val) { return; } this._lineNumbers = val; warnOnError(this.proxy.$trySetOptions(this.id, { lineNumbers: val })); } public assign(newOptions: theia.TextEditorOptions): void { const configurationUpdate: TextEditorConfigurationUpdate = {}; let hasUpdate = false; if (typeof newOptions.tabSize !== 'undefined') { const tabSize = this.validateTabSize(newOptions.tabSize); if (tabSize === 'auto') { hasUpdate = true; configurationUpdate.tabSize = tabSize; } else if (typeof tabSize === 'number' && this._tabSize !== tabSize) { this._tabSize = tabSize; hasUpdate = true; configurationUpdate.tabSize = tabSize; } } if (typeof newOptions.indentSize !== 'undefined') { const indentSize = this.validateIndentSize(newOptions.indentSize); if (indentSize === 'tabSize') { hasUpdate = true; configurationUpdate.indentSize = indentSize; } else if (typeof indentSize === 'number' && this._indentSize !== indentSize) { // reflect the new indentSize value immediately this._indentSize = indentSize; hasUpdate = true; configurationUpdate.indentSize = indentSize; } } if (typeof newOptions.insertSpaces !== 'undefined') { const insertSpaces = this.validateInsertSpaces(newOptions.insertSpaces); if (insertSpaces === 'auto') { hasUpdate = true; configurationUpdate.insertSpaces = insertSpaces; } else if (this.insertSpaces !== insertSpaces) { this.insertSpaces = insertSpaces; hasUpdate = true; configurationUpdate.insertSpaces = insertSpaces; } } if (typeof newOptions.cursorStyle !== 'undefined') { if (this._cursorStyle !== newOptions.cursorStyle) { this._cursorStyle = newOptions.cursorStyle; hasUpdate = true; configurationUpdate.cursorStyle = newOptions.cursorStyle; } } if (typeof newOptions.lineNumbers !== 'undefined') { if (this._lineNumbers !== newOptions.lineNumbers) { this._lineNumbers = newOptions.lineNumbers; hasUpdate = true; configurationUpdate.lineNumbers = newOptions.lineNumbers; } } if (hasUpdate) { warnOnError(this.proxy.$trySetOptions(this.id, configurationUpdate)); } } } export interface TextEditOperation { range: theia.Range; text?: string; forceMoveMarkers: boolean; } export interface EditData { documentVersionId: number; edits: TextEditOperation[]; setEndOfLine: EndOfLine | undefined; undoStopBefore: boolean; undoStopAfter: boolean; } export class TextEditorEdit { private readonly documentVersionId: number; private collectedEdits: TextEditOperation[]; private eol: EndOfLine | undefined; private readonly undoStopBefore: boolean; private readonly undoStopAfter: boolean; constructor(private document: theia.TextDocument, options: { undoStopBefore: boolean; undoStopAfter: boolean }) { this.documentVersionId = document.version; this.collectedEdits = []; this.undoStopBefore = options.undoStopBefore; this.undoStopAfter = options.undoStopAfter; } finalize(): EditData { return { documentVersionId: this.documentVersionId, edits: this.collectedEdits, setEndOfLine: this.eol, undoStopAfter: this.undoStopAfter, undoStopBefore: this.undoStopBefore }; } replace(location: Position | Range | Selection, val: string): void { let range: Range; if (location instanceof Position) { range = new Range(location, location); } else if (location instanceof Range) { range = location; } else { throw new Error('Unknown location'); } this.addEdit(range, val, false); } insert(location: Position, val: string): void { this.addEdit(new Range(location, location), val, true); } delete(location: Range | Selection): void { let range: Range; if (location instanceof Range) { range = location; } else { throw new Error('Unknown location'); } this.addEdit(range, undefined, true); } setEndOfLine(endOfLine: EndOfLine): void { if (endOfLine !== EndOfLine.CRLF && endOfLine !== EndOfLine.LF) { throw illegalArgument('endOfLine'); } this.eol = endOfLine; } private addEdit(range: Range, text: string | undefined, moveMarkers: boolean): void { const validatedRange = this.document.validateRange(range); this.collectedEdits.push({ range: validatedRange, forceMoveMarkers: moveMarkers, text: text }); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any function warnOnError(promise: Promise): void { promise.then(undefined, err => { console.warn(err); }); }