Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/bulkEdit/browser/bulkTextEdits.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { dispose, IDisposable, IReference } from '../../../../base/common/lifecycle.js';
7
import { URI } from '../../../../base/common/uri.js';
8
import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';
9
import { EditOperation, ISingleEditOperation } from '../../../../editor/common/core/editOperation.js';
10
import { Range } from '../../../../editor/common/core/range.js';
11
import { Selection } from '../../../../editor/common/core/selection.js';
12
import { EndOfLineSequence, ITextModel } from '../../../../editor/common/model.js';
13
import { ITextModelService, IResolvedTextEditorModel } from '../../../../editor/common/services/resolverService.js';
14
import { IProgress } from '../../../../platform/progress/common/progress.js';
15
import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js';
16
import { IUndoRedoService, UndoRedoGroup, UndoRedoSource } from '../../../../platform/undoRedo/common/undoRedo.js';
17
import { SingleModelEditStackElement, MultiModelEditStackElement } from '../../../../editor/common/model/editStack.js';
18
import { ResourceMap } from '../../../../base/common/map.js';
19
import { IModelService } from '../../../../editor/common/services/model.js';
20
import { ResourceTextEdit } from '../../../../editor/browser/services/bulkEditService.js';
21
import { CancellationToken } from '../../../../base/common/cancellation.js';
22
import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js';
23
import { SnippetParser } from '../../../../editor/contrib/snippet/browser/snippetParser.js';
24
import { ISnippetEdit } from '../../../../editor/contrib/snippet/browser/snippetSession.js';
25
import { TextModelEditSource } from '../../../../editor/common/textModelEditSource.js';
26
27
type ValidationResult = { canApply: true } | { canApply: false; reason: URI };
28
29
type ISingleSnippetEditOperation = ISingleEditOperation & { insertAsSnippet?: boolean; keepWhitespace?: boolean };
30
31
class ModelEditTask implements IDisposable {
32
33
readonly model: ITextModel;
34
35
private _expectedModelVersionId: number | undefined;
36
protected _edits: ISingleSnippetEditOperation[];
37
protected _newEol: EndOfLineSequence | undefined;
38
39
constructor(private readonly _modelReference: IReference<IResolvedTextEditorModel>) {
40
this.model = this._modelReference.object.textEditorModel;
41
this._edits = [];
42
}
43
44
dispose() {
45
this._modelReference.dispose();
46
}
47
48
isNoOp() {
49
if (this._edits.length > 0) {
50
// contains textual edits
51
return false;
52
}
53
if (this._newEol !== undefined && this._newEol !== this.model.getEndOfLineSequence()) {
54
// contains an eol change that is a real change
55
return false;
56
}
57
return true;
58
}
59
60
addEdit(resourceEdit: ResourceTextEdit): void {
61
this._expectedModelVersionId = resourceEdit.versionId;
62
const { textEdit } = resourceEdit;
63
64
if (typeof textEdit.eol === 'number') {
65
// honor eol-change
66
this._newEol = textEdit.eol;
67
}
68
if (!textEdit.range && !textEdit.text) {
69
// lacks both a range and the text
70
return;
71
}
72
if (Range.isEmpty(textEdit.range) && !textEdit.text) {
73
// no-op edit (replace empty range with empty text)
74
return;
75
}
76
77
// create edit operation
78
let range: Range;
79
if (!textEdit.range) {
80
range = this.model.getFullModelRange();
81
} else {
82
range = Range.lift(textEdit.range);
83
}
84
this._edits.push({ ...EditOperation.replaceMove(range, textEdit.text), insertAsSnippet: textEdit.insertAsSnippet, keepWhitespace: textEdit.keepWhitespace });
85
}
86
87
validate(): ValidationResult {
88
if (typeof this._expectedModelVersionId === 'undefined' || this.model.getVersionId() === this._expectedModelVersionId) {
89
return { canApply: true };
90
}
91
return { canApply: false, reason: this.model.uri };
92
}
93
94
getBeforeCursorState(): Selection[] | null {
95
return null;
96
}
97
98
apply(reason?: TextModelEditSource): void {
99
if (this._edits.length > 0) {
100
this._edits = this._edits
101
.map(this._transformSnippetStringToInsertText, this) // no editor -> no snippet mode
102
.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));
103
this.model.pushEditOperations(null, this._edits, () => null, undefined, reason);
104
}
105
if (this._newEol !== undefined) {
106
this.model.pushEOL(this._newEol);
107
}
108
}
109
110
protected _transformSnippetStringToInsertText(edit: ISingleSnippetEditOperation): ISingleSnippetEditOperation {
111
// transform a snippet edit (and only those) into a normal text edit
112
// for that we need to parse the snippet and get its actual text, e.g without placeholder
113
// or variable syntaxes
114
if (!edit.insertAsSnippet) {
115
return edit;
116
}
117
if (!edit.text) {
118
return edit;
119
}
120
const text = SnippetParser.asInsertText(edit.text);
121
return { ...edit, insertAsSnippet: false, text };
122
}
123
}
124
125
class EditorEditTask extends ModelEditTask {
126
127
private readonly _editor: ICodeEditor;
128
129
constructor(modelReference: IReference<IResolvedTextEditorModel>, editor: ICodeEditor) {
130
super(modelReference);
131
this._editor = editor;
132
}
133
134
override getBeforeCursorState(): Selection[] | null {
135
return this._canUseEditor() ? this._editor.getSelections() : null;
136
}
137
138
override apply(reason?: TextModelEditSource): void {
139
140
// Check that the editor is still for the wanted model. It might have changed in the
141
// meantime and that means we cannot use the editor anymore (instead we perform the edit through the model)
142
if (!this._canUseEditor()) {
143
super.apply();
144
return;
145
}
146
147
if (this._edits.length > 0) {
148
const snippetCtrl = SnippetController2.get(this._editor);
149
if (snippetCtrl && this._edits.some(edit => edit.insertAsSnippet)) {
150
// some edit is a snippet edit -> use snippet controller and ISnippetEdits
151
const snippetEdits: ISnippetEdit[] = [];
152
for (const edit of this._edits) {
153
if (edit.range && edit.text !== null) {
154
snippetEdits.push({
155
range: Range.lift(edit.range),
156
template: edit.insertAsSnippet ? edit.text : SnippetParser.escape(edit.text),
157
keepWhitespace: edit.keepWhitespace
158
});
159
}
160
}
161
snippetCtrl.apply(snippetEdits, { undoStopBefore: false, undoStopAfter: false });
162
163
} else {
164
// normal edit
165
this._edits = this._edits
166
.map(this._transformSnippetStringToInsertText, this) // mixed edits (snippet and normal) -> no snippet mode
167
.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));
168
this._editor.executeEdits(reason, this._edits);
169
}
170
}
171
if (this._newEol !== undefined) {
172
if (this._editor.hasModel()) {
173
this._editor.getModel().pushEOL(this._newEol);
174
}
175
}
176
}
177
178
private _canUseEditor(): boolean {
179
return this._editor?.getModel()?.uri.toString() === this.model.uri.toString();
180
}
181
}
182
183
export class BulkTextEdits {
184
185
private readonly _edits = new ResourceMap<ResourceTextEdit[]>();
186
187
constructor(
188
private readonly _label: string,
189
private readonly _code: string,
190
private readonly _editor: ICodeEditor | undefined,
191
private readonly _undoRedoGroup: UndoRedoGroup,
192
private readonly _undoRedoSource: UndoRedoSource | undefined,
193
private readonly _progress: IProgress<void>,
194
private readonly _token: CancellationToken,
195
edits: ResourceTextEdit[],
196
@IEditorWorkerService private readonly _editorWorker: IEditorWorkerService,
197
@IModelService private readonly _modelService: IModelService,
198
@ITextModelService private readonly _textModelResolverService: ITextModelService,
199
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService
200
) {
201
202
for (const edit of edits) {
203
let array = this._edits.get(edit.resource);
204
if (!array) {
205
array = [];
206
this._edits.set(edit.resource, array);
207
}
208
array.push(edit);
209
}
210
}
211
212
private _validateBeforePrepare(): void {
213
// First check if loaded models were not changed in the meantime
214
for (const array of this._edits.values()) {
215
for (const edit of array) {
216
if (typeof edit.versionId === 'number') {
217
const model = this._modelService.getModel(edit.resource);
218
if (model && model.getVersionId() !== edit.versionId) {
219
// model changed in the meantime
220
throw new Error(`${model.uri.toString()} has changed in the meantime`);
221
}
222
}
223
}
224
}
225
}
226
227
private async _createEditsTasks(): Promise<ModelEditTask[]> {
228
229
const tasks: ModelEditTask[] = [];
230
const promises: Promise<any>[] = [];
231
232
for (const [key, edits] of this._edits) {
233
const promise = this._textModelResolverService.createModelReference(key).then(async ref => {
234
let task: ModelEditTask;
235
let makeMinimal = false;
236
if (this._editor?.getModel()?.uri.toString() === ref.object.textEditorModel.uri.toString()) {
237
task = new EditorEditTask(ref, this._editor);
238
makeMinimal = true;
239
} else {
240
task = new ModelEditTask(ref);
241
}
242
tasks.push(task);
243
244
245
if (!makeMinimal) {
246
edits.forEach(task.addEdit, task);
247
return;
248
}
249
250
// group edits by type (snippet, metadata, or simple) and make simple groups more minimal
251
252
const makeGroupMoreMinimal = async (start: number, end: number) => {
253
const oldEdits = edits.slice(start, end);
254
const newEdits = await this._editorWorker.computeMoreMinimalEdits(ref.object.textEditorModel.uri, oldEdits.map(e => e.textEdit), false);
255
if (!newEdits) {
256
oldEdits.forEach(task.addEdit, task);
257
} else {
258
newEdits.forEach(edit => task.addEdit(new ResourceTextEdit(ref.object.textEditorModel.uri, edit, undefined, undefined)));
259
}
260
};
261
262
let start = 0;
263
let i = 0;
264
for (; i < edits.length; i++) {
265
if (edits[i].textEdit.insertAsSnippet || edits[i].metadata) {
266
await makeGroupMoreMinimal(start, i); // grouped edits until now
267
task.addEdit(edits[i]); // this edit
268
start = i + 1;
269
}
270
}
271
await makeGroupMoreMinimal(start, i);
272
273
});
274
promises.push(promise);
275
}
276
277
await Promise.all(promises);
278
return tasks;
279
}
280
281
private _validateTasks(tasks: ModelEditTask[]): ValidationResult {
282
for (const task of tasks) {
283
const result = task.validate();
284
if (!result.canApply) {
285
return result;
286
}
287
}
288
return { canApply: true };
289
}
290
291
async apply(reason?: TextModelEditSource): Promise<readonly URI[]> {
292
293
this._validateBeforePrepare();
294
const tasks = await this._createEditsTasks();
295
296
try {
297
if (this._token.isCancellationRequested) {
298
return [];
299
}
300
301
const resources: URI[] = [];
302
const validation = this._validateTasks(tasks);
303
if (!validation.canApply) {
304
throw new Error(`${validation.reason.toString()} has changed in the meantime`);
305
}
306
if (tasks.length === 1) {
307
// This edit touches a single model => keep things simple
308
const task = tasks[0];
309
if (!task.isNoOp()) {
310
const singleModelEditStackElement = new SingleModelEditStackElement(this._label, this._code, task.model, task.getBeforeCursorState());
311
this._undoRedoService.pushElement(singleModelEditStackElement, this._undoRedoGroup, this._undoRedoSource);
312
task.apply(reason);
313
singleModelEditStackElement.close();
314
resources.push(task.model.uri);
315
}
316
this._progress.report(undefined);
317
} else {
318
// prepare multi model undo element
319
const multiModelEditStackElement = new MultiModelEditStackElement(
320
this._label,
321
this._code,
322
tasks.map(t => new SingleModelEditStackElement(this._label, this._code, t.model, t.getBeforeCursorState()))
323
);
324
this._undoRedoService.pushElement(multiModelEditStackElement, this._undoRedoGroup, this._undoRedoSource);
325
for (const task of tasks) {
326
task.apply();
327
this._progress.report(undefined);
328
resources.push(task.model.uri);
329
}
330
multiModelEditStackElement.close();
331
}
332
333
return resources;
334
335
} finally {
336
dispose(tasks);
337
}
338
}
339
}
340
341