Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/bulkEdit/browser/bulkEditService.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 { CancellationToken } from '../../../../base/common/cancellation.js';
7
import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
8
import { LinkedList } from '../../../../base/common/linkedList.js';
9
import { ResourceMap, ResourceSet } from '../../../../base/common/map.js';
10
import { URI } from '../../../../base/common/uri.js';
11
import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js';
12
import { IBulkEditOptions, IBulkEditPreviewHandler, IBulkEditResult, IBulkEditService, ResourceEdit, ResourceFileEdit, ResourceTextEdit } from '../../../../editor/browser/services/bulkEditService.js';
13
import { EditorOption } from '../../../../editor/common/config/editorOptions.js';
14
import { WorkspaceEdit } from '../../../../editor/common/languages.js';
15
import { localize } from '../../../../nls.js';
16
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
17
import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
18
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
19
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
20
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
21
import { ILogService } from '../../../../platform/log/common/log.js';
22
import { IProgress, IProgressStep, Progress } from '../../../../platform/progress/common/progress.js';
23
import { Registry } from '../../../../platform/registry/common/platform.js';
24
import { UndoRedoGroup, UndoRedoSource } from '../../../../platform/undoRedo/common/undoRedo.js';
25
import { BulkCellEdits, ResourceNotebookCellEdit } from './bulkCellEdits.js';
26
import { BulkFileEdits } from './bulkFileEdits.js';
27
import { BulkTextEdits } from './bulkTextEdits.js';
28
import { IEditorService } from '../../../services/editor/common/editorService.js';
29
import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js';
30
import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js';
31
import { OpaqueEdits, ResourceAttachmentEdit } from './opaqueEdits.js';
32
import { TextModelEditSource } from '../../../../editor/common/textModelEditSource.js';
33
import { isMacintosh } from '../../../../base/common/platform.js';
34
35
function liftEdits(edits: ResourceEdit[]): ResourceEdit[] {
36
return edits.map(edit => {
37
if (ResourceTextEdit.is(edit)) {
38
return ResourceTextEdit.lift(edit);
39
}
40
if (ResourceFileEdit.is(edit)) {
41
return ResourceFileEdit.lift(edit);
42
}
43
if (ResourceNotebookCellEdit.is(edit)) {
44
return ResourceNotebookCellEdit.lift(edit);
45
}
46
47
if (ResourceAttachmentEdit.is(edit)) {
48
return ResourceAttachmentEdit.lift(edit);
49
}
50
51
throw new Error('Unsupported edit');
52
});
53
}
54
55
class BulkEdit {
56
57
constructor(
58
private readonly _label: string | undefined,
59
private readonly _code: string | undefined,
60
private readonly _editor: ICodeEditor | undefined,
61
private readonly _progress: IProgress<IProgressStep>,
62
private readonly _token: CancellationToken,
63
private readonly _edits: ResourceEdit[],
64
private readonly _undoRedoGroup: UndoRedoGroup,
65
private readonly _undoRedoSource: UndoRedoSource | undefined,
66
private readonly _confirmBeforeUndo: boolean,
67
@IInstantiationService private readonly _instaService: IInstantiationService,
68
@ILogService private readonly _logService: ILogService,
69
) {
70
71
}
72
73
ariaMessage(): string {
74
75
const otherResources = new ResourceMap<boolean>();
76
const textEditResources = new ResourceMap<boolean>();
77
let textEditCount = 0;
78
for (const edit of this._edits) {
79
if (edit instanceof ResourceTextEdit) {
80
textEditCount += 1;
81
textEditResources.set(edit.resource, true);
82
} else if (edit instanceof ResourceFileEdit) {
83
otherResources.set(edit.oldResource ?? edit.newResource!, true);
84
}
85
}
86
if (this._edits.length === 0) {
87
return localize('summary.0', "Made no edits");
88
} else if (otherResources.size === 0) {
89
if (textEditCount > 1 && textEditResources.size > 1) {
90
return localize('summary.nm', "Made {0} text edits in {1} files", textEditCount, textEditResources.size);
91
} else {
92
return localize('summary.n0', "Made {0} text edits in one file", textEditCount);
93
}
94
} else {
95
return localize('summary.textFiles', "Made {0} text edits in {1} files, also created or deleted {2} files", textEditCount, textEditResources.size, otherResources.size);
96
}
97
}
98
99
async perform(reason?: TextModelEditSource): Promise<readonly URI[]> {
100
101
if (this._edits.length === 0) {
102
return [];
103
}
104
105
const ranges: number[] = [1];
106
for (let i = 1; i < this._edits.length; i++) {
107
if (Object.getPrototypeOf(this._edits[i - 1]) === Object.getPrototypeOf(this._edits[i])) {
108
ranges[ranges.length - 1]++;
109
} else {
110
ranges.push(1);
111
}
112
}
113
114
// Show infinte progress when there is only 1 item since we do not know how long it takes
115
const increment = this._edits.length > 1 ? 0 : undefined;
116
this._progress.report({ increment, total: 100 });
117
// Increment by percentage points since progress API expects that
118
const progress: IProgress<void> = { report: _ => this._progress.report({ increment: 100 / this._edits.length }) };
119
120
const resources: (readonly URI[])[] = [];
121
let index = 0;
122
for (const range of ranges) {
123
if (this._token.isCancellationRequested) {
124
break;
125
}
126
const group = this._edits.slice(index, index + range);
127
if (group[0] instanceof ResourceFileEdit) {
128
resources.push(await this._performFileEdits(<ResourceFileEdit[]>group, this._undoRedoGroup, this._undoRedoSource, this._confirmBeforeUndo, progress));
129
} else if (group[0] instanceof ResourceTextEdit) {
130
resources.push(await this._performTextEdits(<ResourceTextEdit[]>group, this._undoRedoGroup, this._undoRedoSource, progress, reason));
131
} else if (group[0] instanceof ResourceNotebookCellEdit) {
132
resources.push(await this._performCellEdits(<ResourceNotebookCellEdit[]>group, this._undoRedoGroup, this._undoRedoSource, progress));
133
} else if (group[0] instanceof ResourceAttachmentEdit) {
134
resources.push(await this._performOpaqueEdits(<ResourceAttachmentEdit[]>group, this._undoRedoGroup, this._undoRedoSource, progress));
135
} else {
136
console.log('UNKNOWN EDIT');
137
}
138
index = index + range;
139
}
140
141
return resources.flat();
142
}
143
144
private async _performFileEdits(edits: ResourceFileEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, confirmBeforeUndo: boolean, progress: IProgress<void>): Promise<readonly URI[]> {
145
this._logService.debug('_performFileEdits', JSON.stringify(edits));
146
const model = this._instaService.createInstance(BulkFileEdits, this._label || localize('workspaceEdit', "Workspace Edit"), this._code || 'undoredo.workspaceEdit', undoRedoGroup, undoRedoSource, confirmBeforeUndo, progress, this._token, edits);
147
return await model.apply();
148
}
149
150
private async _performTextEdits(edits: ResourceTextEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, progress: IProgress<void>, reason: TextModelEditSource | undefined): Promise<readonly URI[]> {
151
this._logService.debug('_performTextEdits', JSON.stringify(edits));
152
const model = this._instaService.createInstance(BulkTextEdits, this._label || localize('workspaceEdit', "Workspace Edit"), this._code || 'undoredo.workspaceEdit', this._editor, undoRedoGroup, undoRedoSource, progress, this._token, edits);
153
return await model.apply(reason);
154
}
155
156
private async _performCellEdits(edits: ResourceNotebookCellEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, progress: IProgress<void>): Promise<readonly URI[]> {
157
this._logService.debug('_performCellEdits', JSON.stringify(edits));
158
const model = this._instaService.createInstance(BulkCellEdits, undoRedoGroup, undoRedoSource, progress, this._token, edits);
159
return await model.apply();
160
}
161
162
private async _performOpaqueEdits(edits: ResourceAttachmentEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, progress: IProgress<void>): Promise<readonly URI[]> {
163
this._logService.debug('_performOpaqueEdits', JSON.stringify(edits));
164
const model = this._instaService.createInstance(OpaqueEdits, undoRedoGroup, undoRedoSource, progress, this._token, edits);
165
return await model.apply();
166
}
167
}
168
169
export class BulkEditService implements IBulkEditService {
170
171
declare readonly _serviceBrand: undefined;
172
173
private readonly _activeUndoRedoGroups = new LinkedList<UndoRedoGroup>();
174
private _previewHandler?: IBulkEditPreviewHandler;
175
176
constructor(
177
@IInstantiationService private readonly _instaService: IInstantiationService,
178
@ILogService private readonly _logService: ILogService,
179
@IEditorService private readonly _editorService: IEditorService,
180
@ILifecycleService private readonly _lifecycleService: ILifecycleService,
181
@IDialogService private readonly _dialogService: IDialogService,
182
@IWorkingCopyService private readonly _workingCopyService: IWorkingCopyService,
183
@IConfigurationService private readonly _configService: IConfigurationService,
184
) { }
185
186
setPreviewHandler(handler: IBulkEditPreviewHandler): IDisposable {
187
this._previewHandler = handler;
188
return toDisposable(() => {
189
if (this._previewHandler === handler) {
190
this._previewHandler = undefined;
191
}
192
});
193
}
194
195
hasPreviewHandler(): boolean {
196
return Boolean(this._previewHandler);
197
}
198
199
async apply(editsIn: ResourceEdit[] | WorkspaceEdit, options?: IBulkEditOptions): Promise<IBulkEditResult> {
200
let edits = liftEdits(Array.isArray(editsIn) ? editsIn : editsIn.edits);
201
202
if (edits.length === 0) {
203
return { ariaSummary: localize('nothing', "Made no edits"), isApplied: false };
204
}
205
206
if (this._previewHandler && (options?.showPreview || edits.some(value => value.metadata?.needsConfirmation))) {
207
edits = await this._previewHandler(edits, options);
208
}
209
210
let codeEditor = options?.editor;
211
// try to find code editor
212
if (!codeEditor) {
213
const candidate = this._editorService.activeTextEditorControl;
214
if (isCodeEditor(candidate)) {
215
codeEditor = candidate;
216
} else if (isDiffEditor(candidate)) {
217
codeEditor = candidate.getModifiedEditor();
218
}
219
}
220
221
if (codeEditor && codeEditor.getOption(EditorOption.readOnly)) {
222
// If the code editor is readonly still allow bulk edits to be applied #68549
223
codeEditor = undefined;
224
}
225
226
// undo-redo-group: if a group id is passed then try to find it
227
// in the list of active edits. otherwise (or when not found)
228
// create a separate undo-redo-group
229
let undoRedoGroup: UndoRedoGroup | undefined;
230
let undoRedoGroupRemove = () => { };
231
if (typeof options?.undoRedoGroupId === 'number') {
232
for (const candidate of this._activeUndoRedoGroups) {
233
if (candidate.id === options.undoRedoGroupId) {
234
undoRedoGroup = candidate;
235
break;
236
}
237
}
238
}
239
if (!undoRedoGroup) {
240
undoRedoGroup = new UndoRedoGroup();
241
undoRedoGroupRemove = this._activeUndoRedoGroups.push(undoRedoGroup);
242
}
243
244
const label = options?.quotableLabel || options?.label;
245
const bulkEdit = this._instaService.createInstance(
246
BulkEdit,
247
label,
248
options?.code,
249
codeEditor,
250
options?.progress ?? Progress.None,
251
options?.token ?? CancellationToken.None,
252
edits,
253
undoRedoGroup,
254
options?.undoRedoSource,
255
!!options?.confirmBeforeUndo
256
);
257
258
let listener: IDisposable | undefined;
259
try {
260
listener = this._lifecycleService.onBeforeShutdown(e => e.veto(this._shouldVeto(label, e.reason), 'veto.blukEditService'));
261
const resources = await bulkEdit.perform(options?.reason);
262
263
// when enabled (option AND setting) loop over all dirty working copies and trigger save
264
// for those that were involved in this bulk edit operation.
265
if (options?.respectAutoSaveConfig && this._configService.getValue(autoSaveSetting) === true && resources.length > 1) {
266
await this._saveAll(resources);
267
}
268
269
return { ariaSummary: bulkEdit.ariaMessage(), isApplied: edits.length > 0 };
270
} catch (err) {
271
// console.log('apply FAILED');
272
// console.log(err);
273
this._logService.error(err);
274
throw err;
275
} finally {
276
listener?.dispose();
277
undoRedoGroupRemove();
278
}
279
}
280
281
private async _saveAll(resources: readonly URI[]) {
282
const set = new ResourceSet(resources);
283
const saves = this._workingCopyService.dirtyWorkingCopies.map(async (copy) => {
284
if (set.has(copy.resource)) {
285
await copy.save();
286
}
287
});
288
289
const result = await Promise.allSettled(saves);
290
for (const item of result) {
291
if (item.status === 'rejected') {
292
this._logService.warn(item.reason);
293
}
294
}
295
}
296
297
private async _shouldVeto(label: string | undefined, reason: ShutdownReason): Promise<boolean> {
298
let message: string;
299
switch (reason) {
300
case ShutdownReason.CLOSE:
301
message = localize('closeTheWindow.message', "Are you sure you want to close the window?");
302
break;
303
case ShutdownReason.LOAD:
304
message = localize('changeWorkspace.message', "Are you sure you want to change the workspace?");
305
break;
306
case ShutdownReason.RELOAD:
307
message = localize('reloadTheWindow.message', "Are you sure you want to reload the window?");
308
break;
309
default:
310
message = isMacintosh ? localize('quitMessageMac', "Are you sure you want to quit?") : localize('quitMessage', "Are you sure you want to exit?");
311
break;
312
}
313
314
const result = await this._dialogService.confirm({
315
message,
316
detail: localize('areYouSureQuiteBulkEdit.detail', "'{0}' is in progress.", label || localize('fileOperation', "File operation")),
317
});
318
319
return !result.confirmed;
320
}
321
}
322
323
registerSingleton(IBulkEditService, BulkEditService, InstantiationType.Delayed);
324
325
const autoSaveSetting = 'files.refactoring.autoSave';
326
327
Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfiguration({
328
id: 'files',
329
properties: {
330
[autoSaveSetting]: {
331
description: localize('refactoring.autoSave', "Controls if files that were part of a refactoring are saved automatically"),
332
default: true,
333
type: 'boolean'
334
}
335
}
336
});
337
338