Path: blob/main/src/vs/workbench/contrib/bulkEdit/browser/bulkTextEdits.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { dispose, IDisposable, IReference } from '../../../../base/common/lifecycle.js';6import { URI } from '../../../../base/common/uri.js';7import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';8import { EditOperation, ISingleEditOperation } from '../../../../editor/common/core/editOperation.js';9import { Range } from '../../../../editor/common/core/range.js';10import { Selection } from '../../../../editor/common/core/selection.js';11import { EndOfLineSequence, ITextModel } from '../../../../editor/common/model.js';12import { ITextModelService, IResolvedTextEditorModel } from '../../../../editor/common/services/resolverService.js';13import { IProgress } from '../../../../platform/progress/common/progress.js';14import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js';15import { IUndoRedoService, UndoRedoGroup, UndoRedoSource } from '../../../../platform/undoRedo/common/undoRedo.js';16import { SingleModelEditStackElement, MultiModelEditStackElement } from '../../../../editor/common/model/editStack.js';17import { ResourceMap } from '../../../../base/common/map.js';18import { IModelService } from '../../../../editor/common/services/model.js';19import { ResourceTextEdit } from '../../../../editor/browser/services/bulkEditService.js';20import { CancellationToken } from '../../../../base/common/cancellation.js';21import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js';22import { SnippetParser } from '../../../../editor/contrib/snippet/browser/snippetParser.js';23import { ISnippetEdit } from '../../../../editor/contrib/snippet/browser/snippetSession.js';24import { TextModelEditSource } from '../../../../editor/common/textModelEditSource.js';2526type ValidationResult = { canApply: true } | { canApply: false; reason: URI };2728type ISingleSnippetEditOperation = ISingleEditOperation & { insertAsSnippet?: boolean; keepWhitespace?: boolean };2930class ModelEditTask implements IDisposable {3132readonly model: ITextModel;3334private _expectedModelVersionId: number | undefined;35protected _edits: ISingleSnippetEditOperation[];36protected _newEol: EndOfLineSequence | undefined;3738constructor(private readonly _modelReference: IReference<IResolvedTextEditorModel>) {39this.model = this._modelReference.object.textEditorModel;40this._edits = [];41}4243dispose() {44this._modelReference.dispose();45}4647isNoOp() {48if (this._edits.length > 0) {49// contains textual edits50return false;51}52if (this._newEol !== undefined && this._newEol !== this.model.getEndOfLineSequence()) {53// contains an eol change that is a real change54return false;55}56return true;57}5859addEdit(resourceEdit: ResourceTextEdit): void {60this._expectedModelVersionId = resourceEdit.versionId;61const { textEdit } = resourceEdit;6263if (typeof textEdit.eol === 'number') {64// honor eol-change65this._newEol = textEdit.eol;66}67if (!textEdit.range && !textEdit.text) {68// lacks both a range and the text69return;70}71if (Range.isEmpty(textEdit.range) && !textEdit.text) {72// no-op edit (replace empty range with empty text)73return;74}7576// create edit operation77let range: Range;78if (!textEdit.range) {79range = this.model.getFullModelRange();80} else {81range = Range.lift(textEdit.range);82}83this._edits.push({ ...EditOperation.replaceMove(range, textEdit.text), insertAsSnippet: textEdit.insertAsSnippet, keepWhitespace: textEdit.keepWhitespace });84}8586validate(): ValidationResult {87if (typeof this._expectedModelVersionId === 'undefined' || this.model.getVersionId() === this._expectedModelVersionId) {88return { canApply: true };89}90return { canApply: false, reason: this.model.uri };91}9293getBeforeCursorState(): Selection[] | null {94return null;95}9697apply(reason?: TextModelEditSource): void {98if (this._edits.length > 0) {99this._edits = this._edits100.map(this._transformSnippetStringToInsertText, this) // no editor -> no snippet mode101.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));102this.model.pushEditOperations(null, this._edits, () => null, undefined, reason);103}104if (this._newEol !== undefined) {105this.model.pushEOL(this._newEol);106}107}108109protected _transformSnippetStringToInsertText(edit: ISingleSnippetEditOperation): ISingleSnippetEditOperation {110// transform a snippet edit (and only those) into a normal text edit111// for that we need to parse the snippet and get its actual text, e.g without placeholder112// or variable syntaxes113if (!edit.insertAsSnippet) {114return edit;115}116if (!edit.text) {117return edit;118}119const text = SnippetParser.asInsertText(edit.text);120return { ...edit, insertAsSnippet: false, text };121}122}123124class EditorEditTask extends ModelEditTask {125126private readonly _editor: ICodeEditor;127128constructor(modelReference: IReference<IResolvedTextEditorModel>, editor: ICodeEditor) {129super(modelReference);130this._editor = editor;131}132133override getBeforeCursorState(): Selection[] | null {134return this._canUseEditor() ? this._editor.getSelections() : null;135}136137override apply(reason?: TextModelEditSource): void {138139// Check that the editor is still for the wanted model. It might have changed in the140// meantime and that means we cannot use the editor anymore (instead we perform the edit through the model)141if (!this._canUseEditor()) {142super.apply();143return;144}145146if (this._edits.length > 0) {147const snippetCtrl = SnippetController2.get(this._editor);148if (snippetCtrl && this._edits.some(edit => edit.insertAsSnippet)) {149// some edit is a snippet edit -> use snippet controller and ISnippetEdits150const snippetEdits: ISnippetEdit[] = [];151for (const edit of this._edits) {152if (edit.range && edit.text !== null) {153snippetEdits.push({154range: Range.lift(edit.range),155template: edit.insertAsSnippet ? edit.text : SnippetParser.escape(edit.text),156keepWhitespace: edit.keepWhitespace157});158}159}160snippetCtrl.apply(snippetEdits, { undoStopBefore: false, undoStopAfter: false });161162} else {163// normal edit164this._edits = this._edits165.map(this._transformSnippetStringToInsertText, this) // mixed edits (snippet and normal) -> no snippet mode166.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));167this._editor.executeEdits(reason, this._edits);168}169}170if (this._newEol !== undefined) {171if (this._editor.hasModel()) {172this._editor.getModel().pushEOL(this._newEol);173}174}175}176177private _canUseEditor(): boolean {178return this._editor?.getModel()?.uri.toString() === this.model.uri.toString();179}180}181182export class BulkTextEdits {183184private readonly _edits = new ResourceMap<ResourceTextEdit[]>();185186constructor(187private readonly _label: string,188private readonly _code: string,189private readonly _editor: ICodeEditor | undefined,190private readonly _undoRedoGroup: UndoRedoGroup,191private readonly _undoRedoSource: UndoRedoSource | undefined,192private readonly _progress: IProgress<void>,193private readonly _token: CancellationToken,194edits: ResourceTextEdit[],195@IEditorWorkerService private readonly _editorWorker: IEditorWorkerService,196@IModelService private readonly _modelService: IModelService,197@ITextModelService private readonly _textModelResolverService: ITextModelService,198@IUndoRedoService private readonly _undoRedoService: IUndoRedoService199) {200201for (const edit of edits) {202let array = this._edits.get(edit.resource);203if (!array) {204array = [];205this._edits.set(edit.resource, array);206}207array.push(edit);208}209}210211private _validateBeforePrepare(): void {212// First check if loaded models were not changed in the meantime213for (const array of this._edits.values()) {214for (const edit of array) {215if (typeof edit.versionId === 'number') {216const model = this._modelService.getModel(edit.resource);217if (model && model.getVersionId() !== edit.versionId) {218// model changed in the meantime219throw new Error(`${model.uri.toString()} has changed in the meantime`);220}221}222}223}224}225226private async _createEditsTasks(): Promise<ModelEditTask[]> {227228const tasks: ModelEditTask[] = [];229const promises: Promise<any>[] = [];230231for (const [key, edits] of this._edits) {232const promise = this._textModelResolverService.createModelReference(key).then(async ref => {233let task: ModelEditTask;234let makeMinimal = false;235if (this._editor?.getModel()?.uri.toString() === ref.object.textEditorModel.uri.toString()) {236task = new EditorEditTask(ref, this._editor);237makeMinimal = true;238} else {239task = new ModelEditTask(ref);240}241tasks.push(task);242243244if (!makeMinimal) {245edits.forEach(task.addEdit, task);246return;247}248249// group edits by type (snippet, metadata, or simple) and make simple groups more minimal250251const makeGroupMoreMinimal = async (start: number, end: number) => {252const oldEdits = edits.slice(start, end);253const newEdits = await this._editorWorker.computeMoreMinimalEdits(ref.object.textEditorModel.uri, oldEdits.map(e => e.textEdit), false);254if (!newEdits) {255oldEdits.forEach(task.addEdit, task);256} else {257newEdits.forEach(edit => task.addEdit(new ResourceTextEdit(ref.object.textEditorModel.uri, edit, undefined, undefined)));258}259};260261let start = 0;262let i = 0;263for (; i < edits.length; i++) {264if (edits[i].textEdit.insertAsSnippet || edits[i].metadata) {265await makeGroupMoreMinimal(start, i); // grouped edits until now266task.addEdit(edits[i]); // this edit267start = i + 1;268}269}270await makeGroupMoreMinimal(start, i);271272});273promises.push(promise);274}275276await Promise.all(promises);277return tasks;278}279280private _validateTasks(tasks: ModelEditTask[]): ValidationResult {281for (const task of tasks) {282const result = task.validate();283if (!result.canApply) {284return result;285}286}287return { canApply: true };288}289290async apply(reason?: TextModelEditSource): Promise<readonly URI[]> {291292this._validateBeforePrepare();293const tasks = await this._createEditsTasks();294295try {296if (this._token.isCancellationRequested) {297return [];298}299300const resources: URI[] = [];301const validation = this._validateTasks(tasks);302if (!validation.canApply) {303throw new Error(`${validation.reason.toString()} has changed in the meantime`);304}305if (tasks.length === 1) {306// This edit touches a single model => keep things simple307const task = tasks[0];308if (!task.isNoOp()) {309const singleModelEditStackElement = new SingleModelEditStackElement(this._label, this._code, task.model, task.getBeforeCursorState());310this._undoRedoService.pushElement(singleModelEditStackElement, this._undoRedoGroup, this._undoRedoSource);311task.apply(reason);312singleModelEditStackElement.close();313resources.push(task.model.uri);314}315this._progress.report(undefined);316} else {317// prepare multi model undo element318const multiModelEditStackElement = new MultiModelEditStackElement(319this._label,320this._code,321tasks.map(t => new SingleModelEditStackElement(this._label, this._code, t.model, t.getBeforeCursorState()))322);323this._undoRedoService.pushElement(multiModelEditStackElement, this._undoRedoGroup, this._undoRedoSource);324for (const task of tasks) {325task.apply();326this._progress.report(undefined);327resources.push(task.model.uri);328}329multiModelEditStackElement.close();330}331332return resources;333334} finally {335dispose(tasks);336}337}338}339340341