From 7c9040139b359f002003b1a5c4af0507a2c0d6bc Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Mon, 16 Jan 2023 11:39:08 +0100 Subject: [PATCH] Support `SnippetTextEdit` vscode API (#12047) Signed-off-by: Jonah Iden Co-authored-by: Mark Sujew --- packages/core/src/common/array-utils.ts | 7 ++ .../monaco/src/browser/monaco-workspace.ts | 30 ++++++- .../plugin-ext/src/common/plugin-api-rpc.ts | 5 +- .../plugin-ext/src/plugin/plugin-context.ts | 2 + .../plugin-ext/src/plugin/type-converters.ts | 10 ++- packages/plugin-ext/src/plugin/types-impl.ts | 86 ++++++++++++++++--- packages/plugin/src/theia.d.ts | 58 ++++++++++++- 7 files changed, 178 insertions(+), 20 deletions(-) diff --git a/packages/core/src/common/array-utils.ts b/packages/core/src/common/array-utils.ts index 62a08f5e32a9a..259b3822bc7c9 100644 --- a/packages/core/src/common/array-utils.ts +++ b/packages/core/src/common/array-utils.ts @@ -93,6 +93,13 @@ export namespace ArrayUtils { return -(low + 1); } + export function partition(array: T[], filter: (e: T, idx: number, arr: T[]) => boolean | undefined): [T[], T[]] { + const pass: T[] = []; + const fail: T[] = []; + array.forEach((e, idx, arr) => (filter(e, idx, arr) ? pass : fail).push(e)); + return [pass, fail]; + } + /** * @returns New array with all falsy values removed. The original array IS NOT modified. */ diff --git a/packages/monaco/src/browser/monaco-workspace.ts b/packages/monaco/src/browser/monaco-workspace.ts index dd5fbecb7bc0a..0c6390b573e33 100644 --- a/packages/monaco/src/browser/monaco-workspace.ts +++ b/packages/monaco/src/browser/monaco-workspace.ts @@ -26,6 +26,7 @@ import { MonacoTextModelService } from './monaco-text-model-service'; import { WillSaveMonacoModelEvent, MonacoEditorModel, MonacoModelContentChangedEvent } from './monaco-editor-model'; import { MonacoEditor } from './monaco-editor'; import { ProblemManager } from '@theia/markers/lib/browser'; +import { ArrayUtils } from '@theia/core/lib/common/types'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileSystemProviderCapabilities } from '@theia/filesystem/lib/common/files'; import * as monaco from '@theia/monaco-editor-core'; @@ -36,6 +37,9 @@ import { import { IEditorWorkerService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/editorWorker'; import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices'; import { EndOfLineSequence } from '@theia/monaco-editor-core/esm/vs/editor/common/model'; +import { SnippetParser } from '@theia/monaco-editor-core/esm/vs/editor/contrib/snippet/browser/snippetParser'; +import { TextEdit } from '@theia/monaco-editor-core/esm/vs/editor/common/languages'; +import { SnippetController2 } from '@theia/monaco-editor-core/esm/vs/editor/contrib/snippet/browser/snippetController2'; import { isObject, MaybePromise } from '@theia/core/lib/common'; export namespace WorkspaceFileEdit { @@ -227,7 +231,8 @@ export class MonacoWorkspace { let totalEdits = 0; let totalFiles = 0; const fileEdits = edits.filter(edit => edit instanceof MonacoResourceFileEdit); - const textEdits = edits.filter(edit => edit instanceof MonacoResourceTextEdit); + const [snippetEdits, textEdits] = ArrayUtils.partition(edits.filter(edit => edit instanceof MonacoResourceTextEdit) as MonacoResourceTextEdit[], + edit => edit.textEdit.insertAsSnippet && (edit.resource.toString() === this.editorManager.activeEditor?.getResourceUri()?.toString())); if (fileEdits.length > 0) { await this.performFileEdits(fileEdits); @@ -239,6 +244,10 @@ export class MonacoWorkspace { totalFiles += result.totalFiles; } + if (snippetEdits.length > 0) { + await this.performSnippetEdits(snippetEdits); + } + const ariaSummary = this.getAriaSummary(totalEdits, totalFiles); return { ariaSummary, success: true }; } catch (e) { @@ -288,7 +297,8 @@ export class MonacoWorkspace { const uri = monaco.Uri.parse(key); let eol: EndOfLineSequence | undefined; const editOperations: monaco.editor.IIdentifiedSingleEditOperation[] = []; - const minimalEdits = await StandaloneServices.get(IEditorWorkerService).computeMoreMinimalEdits(uri, value.map(v => v.textEdit)); + const minimalEdits = await StandaloneServices.get(IEditorWorkerService) + .computeMoreMinimalEdits(uri, value.map(edit => this.transformSnippetStringToInsertText(edit))); if (minimalEdits) { for (const textEdit of minimalEdits) { if (typeof textEdit.eol === 'number') { @@ -364,4 +374,20 @@ export class MonacoWorkspace { } } } + + protected async performSnippetEdits(edits: MonacoResourceTextEdit[]): Promise { + const activeEditor = MonacoEditor.getActive(this.editorManager)?.getControl(); + if (activeEditor) { + const snippetController: SnippetController2 = activeEditor.getContribution('snippetController2')!; + snippetController.apply(edits.map(edit => ({ range: monaco.Range.lift(edit.textEdit.range), template: edit.textEdit.text }))); + } + } + + protected transformSnippetStringToInsertText(resourceEdit: MonacoResourceTextEdit): TextEdit & { insertAsSnippet?: boolean } { + if (resourceEdit.textEdit.insertAsSnippet) { + return { ...resourceEdit.textEdit, insertAsSnippet: false, text: SnippetParser.asInsertText(resourceEdit.textEdit.text) }; + } else { + return resourceEdit.textEdit; + } + } } diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 7482e07b1cca3..d4522fab799bf 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -1407,7 +1407,7 @@ export interface WorkspaceEditMetadataDto { } | { light: UriComponents; dark: UriComponents; - }; + } | ThemeIcon; } export interface WorkspaceFileEditDto { @@ -1420,9 +1420,8 @@ export interface WorkspaceFileEditDto { export interface WorkspaceTextEditDto { resource: UriComponents; modelVersionId?: number; - textEdit: TextEdit; + textEdit: TextEdit & { insertAsSnippet?: boolean }; metadata?: WorkspaceEditMetadataDto; - } export namespace WorkspaceTextEditDto { export function is(arg: WorkspaceTextEditDto | WorkspaceFileEditDto): arg is WorkspaceTextEditDto { diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 3c5a85b728c69..3f9570b8570e0 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -61,6 +61,7 @@ import { CompletionItemKind, CompletionList, TextEdit, + SnippetTextEdit, CompletionTriggerKind, Diagnostic, DiagnosticRelatedInformation, @@ -1170,6 +1171,7 @@ export function createAPIFactory( Diagnostic, CompletionTriggerKind, TextEdit, + SnippetTextEdit, ProgressLocation, ProgressOptions, Progress, diff --git a/packages/plugin-ext/src/plugin/type-converters.ts b/packages/plugin-ext/src/plugin/type-converters.ts index 1e34095549a5c..0f7c8b54e0e29 100644 --- a/packages/plugin-ext/src/plugin/type-converters.ts +++ b/packages/plugin-ext/src/plugin/type-converters.ts @@ -316,6 +316,14 @@ export function fromTextEdit(edit: theia.TextEdit): model.TextEdit { }; } +function fromSnippetTextEdit(edit: theia.SnippetTextEdit): model.TextEdit & { insertAsSnippet?: boolean } { + return { + text: edit.snippet.value, + range: fromRange(edit.range), + insertAsSnippet: true + }; +} + export function convertDiagnosticToMarkerData(diagnostic: theia.Diagnostic): model.MarkerData { return { code: convertCode(diagnostic.code), @@ -570,7 +578,7 @@ export function fromWorkspaceEdit(value: theia.WorkspaceEdit, documents?: any): const workspaceTextEditDto: WorkspaceTextEditDto = { resource: uri, modelVersionId: doc?.version, - textEdit: uriOrEdits.map(fromTextEdit)[0], + textEdit: uriOrEdits.map(edit => (edit instanceof types.TextEdit) ? fromTextEdit(edit) : fromSnippetTextEdit(edit))[0], metadata: entry[2] as types.WorkspaceEditMetadata }; result.edits.push(workspaceTextEditDto); diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index ef3f8931911b8..8042be0c9e55e 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -1279,6 +1279,30 @@ export class NotebookRange implements theia.NotebookRange { } +export class SnippetTextEdit implements theia.SnippetTextEdit { + range: Range; + snippet: SnippetString; + + static isSnippetTextEdit(thing: unknown): thing is SnippetTextEdit { + return thing instanceof SnippetTextEdit || isObject(thing) + && Range.isRange((thing).range) + && SnippetString.isSnippetString((thing).snippet); + } + + static replace(range: Range, snippet: SnippetString): SnippetTextEdit { + return new SnippetTextEdit(range, snippet); + } + + static insert(position: Position, snippet: SnippetString): SnippetTextEdit { + return SnippetTextEdit.replace(new Range(position, position), snippet); + } + + constructor(range: Range, snippet: SnippetString) { + this.range = range; + this.snippet = snippet; + } +} + @es5ClassCompat export class NotebookEdit implements theia.NotebookEdit { range: theia.NotebookRange; @@ -1623,11 +1647,17 @@ export interface WorkspaceEditMetadata { } | { light: URI; dark: URI; - }; + } | ThemeIcon; +} + +export const enum FileEditType { + File = 1, + Text = 2, + Snippet = 6, } export interface FileOperation { - _type: 1; + _type: FileEditType.File; from: URI | undefined; to: URI | undefined; options?: FileOperationOptions; @@ -1635,16 +1665,26 @@ export interface FileOperation { } export interface FileTextEdit { - _type: 2; + _type: FileEditType.Text; uri: URI; edit: TextEdit; metadata?: WorkspaceEditMetadata; } +export interface FileSnippetTextEdit { + readonly _type: FileEditType.Snippet; + readonly uri: URI; + readonly range: Range; + readonly edit: SnippetTextEdit; + readonly metadata?: theia.WorkspaceEditEntryMetadata; +} + +type WorkspaceEditEntry = FileOperation | FileTextEdit | FileSnippetTextEdit | undefined; + @es5ClassCompat export class WorkspaceEdit implements theia.WorkspaceEdit { - private _edits = new Array(); + private _edits = new Array(); renameFile(from: theia.Uri, to: theia.Uri, options?: { overwrite?: boolean, ignoreIfExists?: boolean }, metadata?: WorkspaceEditMetadata): void { this._edits.push({ _type: 1, from, to, options, metadata }); @@ -1679,21 +1719,41 @@ export class WorkspaceEdit implements theia.WorkspaceEdit { return false; } - set(uri: URI, edits: TextEdit[]): void { + set(uri: URI, edits: ReadonlyArray): void; + set(uri: URI, edits: ReadonlyArray<[TextEdit | SnippetTextEdit, theia.WorkspaceEditEntryMetadata]>): void; + + set(uri: URI, edits: ReadonlyArray): void { if (!edits) { // remove all text edits for `uri` for (let i = 0; i < this._edits.length; i++) { const element = this._edits[i]; - if (element && element._type === 2 && element.uri.toString() === uri.toString()) { + if (element && + (element._type === FileEditType.Text || element._type === FileEditType.Snippet) && + element.uri.toString() === uri.toString()) { this._edits[i] = undefined; } } this._edits = this._edits.filter(e => !!e); } else { // append edit to the end - for (const edit of edits) { - if (edit) { - this._edits.push({ _type: 2, uri, edit }); + for (const editOrTuple of edits) { + if (!editOrTuple) { + continue; + } + + let edit: TextEdit | SnippetTextEdit; + let metadata: theia.WorkspaceEditEntryMetadata | undefined; + if (Array.isArray(editOrTuple)) { + edit = editOrTuple[0]; + metadata = editOrTuple[1]; + } else { + edit = editOrTuple; + } + + if (SnippetTextEdit.isSnippetTextEdit(edit)) { + this._edits.push({ _type: FileEditType.Snippet, uri, range: edit.range, edit, metadata }); + } else { + this._edits.push({ _type: FileEditType.Text, uri, edit }); } } } @@ -1715,7 +1775,7 @@ export class WorkspaceEdit implements theia.WorkspaceEdit { entries(): [URI, TextEdit[]][] { const textEdits = new Map(); for (const candidate of this._edits) { - if (candidate && candidate._type === 2) { + if (candidate && candidate._type === FileEditType.Text) { let textEdit = textEdits.get(candidate.uri.toString()); if (!textEdit) { textEdit = [candidate.uri, []]; @@ -1729,13 +1789,13 @@ export class WorkspaceEdit implements theia.WorkspaceEdit { return result; } - _allEntries(): ([URI, TextEdit[], WorkspaceEditMetadata] | [URI, URI, FileOperationOptions, WorkspaceEditMetadata])[] { - const res: ([URI, TextEdit[], WorkspaceEditMetadata] | [URI, URI, FileOperationOptions, WorkspaceEditMetadata])[] = []; + _allEntries(): ([URI, Array, theia.WorkspaceEditEntryMetadata] | [URI, URI, FileOperationOptions, WorkspaceEditMetadata])[] { + const res: ([URI, Array, theia.WorkspaceEditEntryMetadata] | [URI, URI, FileOperationOptions, WorkspaceEditMetadata])[] = []; for (const edit of this._edits) { if (!edit) { continue; } - if (edit._type === 1) { + if (edit._type === FileEditType.File) { res.push([edit.from!, edit.to!, edit.options!, edit.metadata!]); } else { res.push([edit.uri, [edit.edit], edit.metadata!]); diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 472de24b5e55d..dcf18b5fb2919 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -9501,7 +9501,15 @@ export module '@theia/plugin' { * @param uri A resource identifier. * @param edits An array of text edits. */ - set(uri: Uri, edits: TextEdit[]): void; + set(uri: Uri, edits: ReadonlyArray): void; + + /** + * Set (and replace) text edits or snippet edits with metadata for a resource. + * + * @param uri A resource identifier. + * @param edits An array of edits. + */ + set(uri: Uri, edits: ReadonlyArray<[TextEdit | SnippetTextEdit, WorkspaceEditEntryMetadata]>): void; /** * Get the text edits for a resource. @@ -14309,6 +14317,54 @@ export module '@theia/plugin' { readonly selections?: readonly NotebookRange[]; } + /** + * A snippet edit represents an interactive edit that is performed by + * the editor. + * + * *Note* that a snippet edit can always be performed as a normal {@link TextEdit text edit}. + * This will happen when no matching editor is open or when a {@link WorkspaceEdit workspace edit} + * contains snippet edits for multiple files. In that case only those that match the active editor + * will be performed as snippet edits and the others as normal text edits. + */ + export class SnippetTextEdit { + + /** + * Utility to create a replace snippet edit. + * + * @param range A range. + * @param snippet A snippet string. + * @return A new snippet edit object. + */ + static replace(range: Range, snippet: SnippetString): SnippetTextEdit; + + /** + * Utility to create an insert snippet edit. + * + * @param position A position, will become an empty range. + * @param snippet A snippet string. + * @return A new snippet edit object. + */ + static insert(position: Position, snippet: SnippetString): SnippetTextEdit; + + /** + * The range this edit applies to. + */ + range: Range; + + /** + * The {@link SnippetString snippet} this edit will perform. + */ + snippet: SnippetString; + + /** + * Create a new snippet edit. + * + * @param range A range. + * @param snippet A snippet string. + */ + constructor(range: Range, snippet: SnippetString); + } + /** * A notebook edit represents edits that should be applied to the contents of a notebook. */