Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview.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 { ITextModelContentProvider, ITextModelService } from '../../../../../editor/common/services/resolverService.js';
7
import { URI } from '../../../../../base/common/uri.js';
8
import { ILanguageService } from '../../../../../editor/common/languages/language.js';
9
import { IModelService } from '../../../../../editor/common/services/model.js';
10
import { createTextBufferFactoryFromSnapshot } from '../../../../../editor/common/model/textModel.js';
11
import { WorkspaceEditMetadata } from '../../../../../editor/common/languages.js';
12
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
13
import { coalesceInPlace } from '../../../../../base/common/arrays.js';
14
import { Range } from '../../../../../editor/common/core/range.js';
15
import { EditOperation, ISingleEditOperation } from '../../../../../editor/common/core/editOperation.js';
16
import { ServicesAccessor, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
17
import { IFileService } from '../../../../../platform/files/common/files.js';
18
import { Emitter, Event } from '../../../../../base/common/event.js';
19
import { ConflictDetector } from '../conflicts.js';
20
import { ResourceMap } from '../../../../../base/common/map.js';
21
import { localize } from '../../../../../nls.js';
22
import { extUri } from '../../../../../base/common/resources.js';
23
import { ResourceEdit, ResourceFileEdit, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js';
24
import { Codicon } from '../../../../../base/common/codicons.js';
25
import { generateUuid } from '../../../../../base/common/uuid.js';
26
import { SnippetParser } from '../../../../../editor/contrib/snippet/browser/snippetParser.js';
27
import { MicrotaskDelay } from '../../../../../base/common/symbols.js';
28
import { Schemas } from '../../../../../base/common/network.js';
29
30
export class CheckedStates<T extends object> {
31
32
private readonly _states = new WeakMap<T, boolean>();
33
private _checkedCount: number = 0;
34
35
private readonly _onDidChange = new Emitter<T>();
36
readonly onDidChange: Event<T> = this._onDidChange.event;
37
38
dispose(): void {
39
this._onDidChange.dispose();
40
}
41
42
get checkedCount() {
43
return this._checkedCount;
44
}
45
46
isChecked(obj: T): boolean {
47
return this._states.get(obj) ?? false;
48
}
49
50
updateChecked(obj: T, value: boolean): void {
51
const valueNow = this._states.get(obj);
52
if (valueNow === value) {
53
return;
54
}
55
if (valueNow === undefined) {
56
if (value) {
57
this._checkedCount += 1;
58
}
59
} else {
60
if (value) {
61
this._checkedCount += 1;
62
} else {
63
this._checkedCount -= 1;
64
}
65
}
66
this._states.set(obj, value);
67
this._onDidChange.fire(obj);
68
}
69
}
70
71
export class BulkTextEdit {
72
73
constructor(
74
readonly parent: BulkFileOperation,
75
readonly textEdit: ResourceTextEdit
76
) { }
77
}
78
79
export const enum BulkFileOperationType {
80
TextEdit = 1,
81
Create = 2,
82
Delete = 4,
83
Rename = 8,
84
}
85
86
export class BulkFileOperation {
87
88
type = 0;
89
textEdits: BulkTextEdit[] = [];
90
originalEdits = new Map<number, ResourceTextEdit | ResourceFileEdit>();
91
newUri?: URI;
92
93
constructor(
94
readonly uri: URI,
95
readonly parent: BulkFileOperations
96
) { }
97
98
addEdit(index: number, type: BulkFileOperationType, edit: ResourceTextEdit | ResourceFileEdit) {
99
this.type |= type;
100
this.originalEdits.set(index, edit);
101
if (edit instanceof ResourceTextEdit) {
102
this.textEdits.push(new BulkTextEdit(this, edit));
103
104
} else if (type === BulkFileOperationType.Rename) {
105
this.newUri = edit.newResource;
106
}
107
}
108
109
needsConfirmation(): boolean {
110
for (const [, edit] of this.originalEdits) {
111
if (!this.parent.checked.isChecked(edit)) {
112
return true;
113
}
114
}
115
return false;
116
}
117
}
118
119
export class BulkCategory {
120
121
private static readonly _defaultMetadata = Object.freeze({
122
label: localize('default', "Other"),
123
icon: Codicon.symbolFile,
124
needsConfirmation: false
125
});
126
127
static keyOf(metadata?: WorkspaceEditMetadata) {
128
return metadata?.label || '<default>';
129
}
130
131
readonly operationByResource = new Map<string, BulkFileOperation>();
132
133
constructor(readonly metadata: WorkspaceEditMetadata = BulkCategory._defaultMetadata) { }
134
135
get fileOperations(): IterableIterator<BulkFileOperation> {
136
return this.operationByResource.values();
137
}
138
}
139
140
export class BulkFileOperations {
141
142
static async create(accessor: ServicesAccessor, bulkEdit: ResourceEdit[]): Promise<BulkFileOperations> {
143
const result = accessor.get(IInstantiationService).createInstance(BulkFileOperations, bulkEdit);
144
return await result._init();
145
}
146
147
readonly checked = new CheckedStates<ResourceEdit>();
148
149
readonly fileOperations: BulkFileOperation[] = [];
150
readonly categories: BulkCategory[] = [];
151
readonly conflicts: ConflictDetector;
152
153
constructor(
154
private readonly _bulkEdit: ResourceEdit[],
155
@IFileService private readonly _fileService: IFileService,
156
@IInstantiationService instaService: IInstantiationService,
157
) {
158
this.conflicts = instaService.createInstance(ConflictDetector, _bulkEdit);
159
}
160
161
dispose(): void {
162
this.checked.dispose();
163
this.conflicts.dispose();
164
}
165
166
async _init() {
167
const operationByResource = new Map<string, BulkFileOperation>();
168
const operationByCategory = new Map<string, BulkCategory>();
169
170
const newToOldUri = new ResourceMap<URI>();
171
172
for (let idx = 0; idx < this._bulkEdit.length; idx++) {
173
const edit = this._bulkEdit[idx];
174
175
let uri: URI;
176
let type: BulkFileOperationType;
177
178
// store inital checked state
179
this.checked.updateChecked(edit, !edit.metadata?.needsConfirmation);
180
181
if (edit instanceof ResourceTextEdit) {
182
type = BulkFileOperationType.TextEdit;
183
uri = edit.resource;
184
185
} else if (edit instanceof ResourceFileEdit) {
186
if (edit.newResource && edit.oldResource) {
187
type = BulkFileOperationType.Rename;
188
uri = edit.oldResource;
189
if (edit.options?.overwrite === undefined && edit.options?.ignoreIfExists && await this._fileService.exists(uri)) {
190
// noop -> "soft" rename to something that already exists
191
continue;
192
}
193
// map newResource onto oldResource so that text-edit appear for
194
// the same file element
195
newToOldUri.set(edit.newResource, uri);
196
197
} else if (edit.oldResource) {
198
type = BulkFileOperationType.Delete;
199
uri = edit.oldResource;
200
if (edit.options?.ignoreIfNotExists && !await this._fileService.exists(uri)) {
201
// noop -> "soft" delete something that doesn't exist
202
continue;
203
}
204
205
} else if (edit.newResource) {
206
type = BulkFileOperationType.Create;
207
uri = edit.newResource;
208
if (edit.options?.overwrite === undefined && edit.options?.ignoreIfExists && await this._fileService.exists(uri)) {
209
// noop -> "soft" create something that already exists
210
continue;
211
}
212
213
} else {
214
// invalid edit -> skip
215
continue;
216
}
217
218
} else {
219
// unsupported edit
220
continue;
221
}
222
223
const insert = (uri: URI, map: Map<string, BulkFileOperation>) => {
224
let key = extUri.getComparisonKey(uri, true);
225
let operation = map.get(key);
226
227
// rename
228
if (!operation && newToOldUri.has(uri)) {
229
uri = newToOldUri.get(uri)!;
230
key = extUri.getComparisonKey(uri, true);
231
operation = map.get(key);
232
}
233
234
if (!operation) {
235
operation = new BulkFileOperation(uri, this);
236
map.set(key, operation);
237
}
238
operation.addEdit(idx, type, edit);
239
};
240
241
insert(uri, operationByResource);
242
243
// insert into "this" category
244
const key = BulkCategory.keyOf(edit.metadata);
245
let category = operationByCategory.get(key);
246
if (!category) {
247
category = new BulkCategory(edit.metadata);
248
operationByCategory.set(key, category);
249
}
250
insert(uri, category.operationByResource);
251
}
252
253
operationByResource.forEach(value => this.fileOperations.push(value));
254
operationByCategory.forEach(value => this.categories.push(value));
255
256
// "correct" invalid parent-check child states that is
257
// unchecked file edits (rename, create, delete) uncheck
258
// all edits for a file, e.g no text change without rename
259
for (const file of this.fileOperations) {
260
if (file.type !== BulkFileOperationType.TextEdit) {
261
let checked = true;
262
for (const edit of file.originalEdits.values()) {
263
if (edit instanceof ResourceFileEdit) {
264
checked = checked && this.checked.isChecked(edit);
265
}
266
}
267
if (!checked) {
268
for (const edit of file.originalEdits.values()) {
269
this.checked.updateChecked(edit, checked);
270
}
271
}
272
}
273
}
274
275
// sort (once) categories atop which have unconfirmed edits
276
this.categories.sort((a, b) => {
277
if (a.metadata.needsConfirmation === b.metadata.needsConfirmation) {
278
return a.metadata.label.localeCompare(b.metadata.label);
279
} else if (a.metadata.needsConfirmation) {
280
return -1;
281
} else {
282
return 1;
283
}
284
});
285
286
return this;
287
}
288
289
getWorkspaceEdit(): ResourceEdit[] {
290
const result: ResourceEdit[] = [];
291
let allAccepted = true;
292
293
for (let i = 0; i < this._bulkEdit.length; i++) {
294
const edit = this._bulkEdit[i];
295
if (this.checked.isChecked(edit)) {
296
result[i] = edit;
297
continue;
298
}
299
allAccepted = false;
300
}
301
302
if (allAccepted) {
303
return this._bulkEdit;
304
}
305
306
// not all edits have been accepted
307
coalesceInPlace(result);
308
return result;
309
}
310
311
private async getFileEditOperation(edit: ResourceFileEdit): Promise<ISingleEditOperation | undefined> {
312
const content = await edit.options.contents;
313
if (!content) { return undefined; }
314
return EditOperation.replaceMove(Range.lift({ startLineNumber: 0, startColumn: 0, endLineNumber: Number.MAX_VALUE, endColumn: 0 }), content.toString());
315
}
316
317
async getFileEdits(uri: URI): Promise<ISingleEditOperation[]> {
318
319
for (const file of this.fileOperations) {
320
if (file.uri.toString() === uri.toString()) {
321
322
const result: Promise<ISingleEditOperation | undefined>[] = [];
323
let ignoreAll = false;
324
325
for (const edit of file.originalEdits.values()) {
326
if (edit instanceof ResourceFileEdit) {
327
result.push(this.getFileEditOperation(edit));
328
} else if (edit instanceof ResourceTextEdit) {
329
if (this.checked.isChecked(edit)) {
330
result.push(Promise.resolve(EditOperation.replaceMove(Range.lift(edit.textEdit.range), !edit.textEdit.insertAsSnippet ? edit.textEdit.text : SnippetParser.asInsertText(edit.textEdit.text))));
331
}
332
333
} else if (!this.checked.isChecked(edit)) {
334
// UNCHECKED WorkspaceFileEdit disables all text edits
335
ignoreAll = true;
336
}
337
}
338
339
if (ignoreAll) {
340
return [];
341
}
342
343
return (await Promise.all(result)).filter(r => r !== undefined).sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));
344
}
345
}
346
return [];
347
}
348
349
getUriOfEdit(edit: ResourceEdit): URI {
350
for (const file of this.fileOperations) {
351
for (const value of file.originalEdits.values()) {
352
if (value === edit) {
353
return file.uri;
354
}
355
}
356
}
357
throw new Error('invalid edit');
358
}
359
}
360
361
export class BulkEditPreviewProvider implements ITextModelContentProvider {
362
363
private static readonly Schema = 'vscode-bulkeditpreview-editor';
364
365
static emptyPreview = URI.from({ scheme: this.Schema, fragment: 'empty' });
366
367
368
static fromPreviewUri(uri: URI): URI {
369
return URI.parse(uri.query);
370
}
371
372
private readonly _disposables = new DisposableStore();
373
private readonly _ready: Promise<any>;
374
private readonly _modelPreviewEdits = new Map<string, ISingleEditOperation[]>();
375
private readonly _instanceId = generateUuid();
376
377
constructor(
378
private readonly _operations: BulkFileOperations,
379
@ILanguageService private readonly _languageService: ILanguageService,
380
@IModelService private readonly _modelService: IModelService,
381
@ITextModelService private readonly _textModelResolverService: ITextModelService
382
) {
383
this._disposables.add(this._textModelResolverService.registerTextModelContentProvider(BulkEditPreviewProvider.Schema, this));
384
this._ready = this._init();
385
}
386
387
dispose(): void {
388
this._disposables.dispose();
389
}
390
391
asPreviewUri(uri: URI): URI {
392
const path = uri.scheme === Schemas.untitled ? `/${uri.path}` : uri.path;
393
return URI.from({ scheme: BulkEditPreviewProvider.Schema, authority: this._instanceId, path, query: uri.toString() });
394
}
395
396
private async _init() {
397
for (const operation of this._operations.fileOperations) {
398
await this._applyTextEditsToPreviewModel(operation.uri);
399
}
400
this._disposables.add(Event.debounce(this._operations.checked.onDidChange, (_last, e) => e, MicrotaskDelay)(e => {
401
const uri = this._operations.getUriOfEdit(e);
402
this._applyTextEditsToPreviewModel(uri);
403
}));
404
}
405
406
private async _applyTextEditsToPreviewModel(uri: URI) {
407
const model = await this._getOrCreatePreviewModel(uri);
408
409
// undo edits that have been done before
410
const undoEdits = this._modelPreviewEdits.get(model.id);
411
if (undoEdits) {
412
model.applyEdits(undoEdits);
413
}
414
// apply new edits and keep (future) undo edits
415
const newEdits = await this._operations.getFileEdits(uri);
416
const newUndoEdits = model.applyEdits(newEdits, true);
417
this._modelPreviewEdits.set(model.id, newUndoEdits);
418
}
419
420
private async _getOrCreatePreviewModel(uri: URI) {
421
const previewUri = this.asPreviewUri(uri);
422
let model = this._modelService.getModel(previewUri);
423
if (!model) {
424
try {
425
// try: copy existing
426
const ref = await this._textModelResolverService.createModelReference(uri);
427
const sourceModel = ref.object.textEditorModel;
428
model = this._modelService.createModel(
429
createTextBufferFactoryFromSnapshot(sourceModel.createSnapshot()),
430
this._languageService.createById(sourceModel.getLanguageId()),
431
previewUri
432
);
433
ref.dispose();
434
435
} catch {
436
// create NEW model
437
model = this._modelService.createModel(
438
'',
439
this._languageService.createByFilepathOrFirstLine(previewUri),
440
previewUri
441
);
442
}
443
// this is a little weird but otherwise editors and other cusomers
444
// will dispose my models before they should be disposed...
445
// And all of this is off the eventloop to prevent endless recursion
446
queueMicrotask(async () => {
447
this._disposables.add(await this._textModelResolverService.createModelReference(model!.uri));
448
});
449
}
450
return model;
451
}
452
453
async provideTextContent(previewUri: URI) {
454
if (previewUri.toString() === BulkEditPreviewProvider.emptyPreview.toString()) {
455
return this._modelService.createModel('', null, previewUri);
456
}
457
await this._ready;
458
return this._modelService.getModel(previewUri);
459
}
460
}
461
462