Path: blob/main/src/vs/workbench/contrib/bulkEdit/browser/bulkEditService.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 { CancellationToken } from '../../../../base/common/cancellation.js';6import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';7import { LinkedList } from '../../../../base/common/linkedList.js';8import { ResourceMap, ResourceSet } from '../../../../base/common/map.js';9import { URI } from '../../../../base/common/uri.js';10import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js';11import { IBulkEditOptions, IBulkEditPreviewHandler, IBulkEditResult, IBulkEditService, ResourceEdit, ResourceFileEdit, ResourceTextEdit } from '../../../../editor/browser/services/bulkEditService.js';12import { EditorOption } from '../../../../editor/common/config/editorOptions.js';13import { WorkspaceEdit } from '../../../../editor/common/languages.js';14import { localize } from '../../../../nls.js';15import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';16import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';17import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';18import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';19import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';20import { ILogService } from '../../../../platform/log/common/log.js';21import { IProgress, IProgressStep, Progress } from '../../../../platform/progress/common/progress.js';22import { Registry } from '../../../../platform/registry/common/platform.js';23import { UndoRedoGroup, UndoRedoSource } from '../../../../platform/undoRedo/common/undoRedo.js';24import { BulkCellEdits, ResourceNotebookCellEdit } from './bulkCellEdits.js';25import { BulkFileEdits } from './bulkFileEdits.js';26import { BulkTextEdits } from './bulkTextEdits.js';27import { IEditorService } from '../../../services/editor/common/editorService.js';28import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js';29import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js';30import { OpaqueEdits, ResourceAttachmentEdit } from './opaqueEdits.js';31import { TextModelEditSource } from '../../../../editor/common/textModelEditSource.js';32import { isMacintosh } from '../../../../base/common/platform.js';3334function liftEdits(edits: ResourceEdit[]): ResourceEdit[] {35return edits.map(edit => {36if (ResourceTextEdit.is(edit)) {37return ResourceTextEdit.lift(edit);38}39if (ResourceFileEdit.is(edit)) {40return ResourceFileEdit.lift(edit);41}42if (ResourceNotebookCellEdit.is(edit)) {43return ResourceNotebookCellEdit.lift(edit);44}4546if (ResourceAttachmentEdit.is(edit)) {47return ResourceAttachmentEdit.lift(edit);48}4950throw new Error('Unsupported edit');51});52}5354class BulkEdit {5556constructor(57private readonly _label: string | undefined,58private readonly _code: string | undefined,59private readonly _editor: ICodeEditor | undefined,60private readonly _progress: IProgress<IProgressStep>,61private readonly _token: CancellationToken,62private readonly _edits: ResourceEdit[],63private readonly _undoRedoGroup: UndoRedoGroup,64private readonly _undoRedoSource: UndoRedoSource | undefined,65private readonly _confirmBeforeUndo: boolean,66@IInstantiationService private readonly _instaService: IInstantiationService,67@ILogService private readonly _logService: ILogService,68) {6970}7172ariaMessage(): string {7374const otherResources = new ResourceMap<boolean>();75const textEditResources = new ResourceMap<boolean>();76let textEditCount = 0;77for (const edit of this._edits) {78if (edit instanceof ResourceTextEdit) {79textEditCount += 1;80textEditResources.set(edit.resource, true);81} else if (edit instanceof ResourceFileEdit) {82otherResources.set(edit.oldResource ?? edit.newResource!, true);83}84}85if (this._edits.length === 0) {86return localize('summary.0', "Made no edits");87} else if (otherResources.size === 0) {88if (textEditCount > 1 && textEditResources.size > 1) {89return localize('summary.nm', "Made {0} text edits in {1} files", textEditCount, textEditResources.size);90} else {91return localize('summary.n0', "Made {0} text edits in one file", textEditCount);92}93} else {94return localize('summary.textFiles', "Made {0} text edits in {1} files, also created or deleted {2} files", textEditCount, textEditResources.size, otherResources.size);95}96}9798async perform(reason?: TextModelEditSource): Promise<readonly URI[]> {99100if (this._edits.length === 0) {101return [];102}103104const ranges: number[] = [1];105for (let i = 1; i < this._edits.length; i++) {106if (Object.getPrototypeOf(this._edits[i - 1]) === Object.getPrototypeOf(this._edits[i])) {107ranges[ranges.length - 1]++;108} else {109ranges.push(1);110}111}112113// Show infinte progress when there is only 1 item since we do not know how long it takes114const increment = this._edits.length > 1 ? 0 : undefined;115this._progress.report({ increment, total: 100 });116// Increment by percentage points since progress API expects that117const progress: IProgress<void> = { report: _ => this._progress.report({ increment: 100 / this._edits.length }) };118119const resources: (readonly URI[])[] = [];120let index = 0;121for (const range of ranges) {122if (this._token.isCancellationRequested) {123break;124}125const group = this._edits.slice(index, index + range);126if (group[0] instanceof ResourceFileEdit) {127resources.push(await this._performFileEdits(<ResourceFileEdit[]>group, this._undoRedoGroup, this._undoRedoSource, this._confirmBeforeUndo, progress));128} else if (group[0] instanceof ResourceTextEdit) {129resources.push(await this._performTextEdits(<ResourceTextEdit[]>group, this._undoRedoGroup, this._undoRedoSource, progress, reason));130} else if (group[0] instanceof ResourceNotebookCellEdit) {131resources.push(await this._performCellEdits(<ResourceNotebookCellEdit[]>group, this._undoRedoGroup, this._undoRedoSource, progress));132} else if (group[0] instanceof ResourceAttachmentEdit) {133resources.push(await this._performOpaqueEdits(<ResourceAttachmentEdit[]>group, this._undoRedoGroup, this._undoRedoSource, progress));134} else {135console.log('UNKNOWN EDIT');136}137index = index + range;138}139140return resources.flat();141}142143private async _performFileEdits(edits: ResourceFileEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, confirmBeforeUndo: boolean, progress: IProgress<void>): Promise<readonly URI[]> {144this._logService.debug('_performFileEdits', JSON.stringify(edits));145const model = this._instaService.createInstance(BulkFileEdits, this._label || localize('workspaceEdit', "Workspace Edit"), this._code || 'undoredo.workspaceEdit', undoRedoGroup, undoRedoSource, confirmBeforeUndo, progress, this._token, edits);146return await model.apply();147}148149private async _performTextEdits(edits: ResourceTextEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, progress: IProgress<void>, reason: TextModelEditSource | undefined): Promise<readonly URI[]> {150this._logService.debug('_performTextEdits', JSON.stringify(edits));151const model = this._instaService.createInstance(BulkTextEdits, this._label || localize('workspaceEdit', "Workspace Edit"), this._code || 'undoredo.workspaceEdit', this._editor, undoRedoGroup, undoRedoSource, progress, this._token, edits);152return await model.apply(reason);153}154155private async _performCellEdits(edits: ResourceNotebookCellEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, progress: IProgress<void>): Promise<readonly URI[]> {156this._logService.debug('_performCellEdits', JSON.stringify(edits));157const model = this._instaService.createInstance(BulkCellEdits, undoRedoGroup, undoRedoSource, progress, this._token, edits);158return await model.apply();159}160161private async _performOpaqueEdits(edits: ResourceAttachmentEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, progress: IProgress<void>): Promise<readonly URI[]> {162this._logService.debug('_performOpaqueEdits', JSON.stringify(edits));163const model = this._instaService.createInstance(OpaqueEdits, undoRedoGroup, undoRedoSource, progress, this._token, edits);164return await model.apply();165}166}167168export class BulkEditService implements IBulkEditService {169170declare readonly _serviceBrand: undefined;171172private readonly _activeUndoRedoGroups = new LinkedList<UndoRedoGroup>();173private _previewHandler?: IBulkEditPreviewHandler;174175constructor(176@IInstantiationService private readonly _instaService: IInstantiationService,177@ILogService private readonly _logService: ILogService,178@IEditorService private readonly _editorService: IEditorService,179@ILifecycleService private readonly _lifecycleService: ILifecycleService,180@IDialogService private readonly _dialogService: IDialogService,181@IWorkingCopyService private readonly _workingCopyService: IWorkingCopyService,182@IConfigurationService private readonly _configService: IConfigurationService,183) { }184185setPreviewHandler(handler: IBulkEditPreviewHandler): IDisposable {186this._previewHandler = handler;187return toDisposable(() => {188if (this._previewHandler === handler) {189this._previewHandler = undefined;190}191});192}193194hasPreviewHandler(): boolean {195return Boolean(this._previewHandler);196}197198async apply(editsIn: ResourceEdit[] | WorkspaceEdit, options?: IBulkEditOptions): Promise<IBulkEditResult> {199let edits = liftEdits(Array.isArray(editsIn) ? editsIn : editsIn.edits);200201if (edits.length === 0) {202return { ariaSummary: localize('nothing', "Made no edits"), isApplied: false };203}204205if (this._previewHandler && (options?.showPreview || edits.some(value => value.metadata?.needsConfirmation))) {206edits = await this._previewHandler(edits, options);207}208209let codeEditor = options?.editor;210// try to find code editor211if (!codeEditor) {212const candidate = this._editorService.activeTextEditorControl;213if (isCodeEditor(candidate)) {214codeEditor = candidate;215} else if (isDiffEditor(candidate)) {216codeEditor = candidate.getModifiedEditor();217}218}219220if (codeEditor && codeEditor.getOption(EditorOption.readOnly)) {221// If the code editor is readonly still allow bulk edits to be applied #68549222codeEditor = undefined;223}224225// undo-redo-group: if a group id is passed then try to find it226// in the list of active edits. otherwise (or when not found)227// create a separate undo-redo-group228let undoRedoGroup: UndoRedoGroup | undefined;229let undoRedoGroupRemove = () => { };230if (typeof options?.undoRedoGroupId === 'number') {231for (const candidate of this._activeUndoRedoGroups) {232if (candidate.id === options.undoRedoGroupId) {233undoRedoGroup = candidate;234break;235}236}237}238if (!undoRedoGroup) {239undoRedoGroup = new UndoRedoGroup();240undoRedoGroupRemove = this._activeUndoRedoGroups.push(undoRedoGroup);241}242243const label = options?.quotableLabel || options?.label;244const bulkEdit = this._instaService.createInstance(245BulkEdit,246label,247options?.code,248codeEditor,249options?.progress ?? Progress.None,250options?.token ?? CancellationToken.None,251edits,252undoRedoGroup,253options?.undoRedoSource,254!!options?.confirmBeforeUndo255);256257let listener: IDisposable | undefined;258try {259listener = this._lifecycleService.onBeforeShutdown(e => e.veto(this._shouldVeto(label, e.reason), 'veto.blukEditService'));260const resources = await bulkEdit.perform(options?.reason);261262// when enabled (option AND setting) loop over all dirty working copies and trigger save263// for those that were involved in this bulk edit operation.264if (options?.respectAutoSaveConfig && this._configService.getValue(autoSaveSetting) === true && resources.length > 1) {265await this._saveAll(resources);266}267268return { ariaSummary: bulkEdit.ariaMessage(), isApplied: edits.length > 0 };269} catch (err) {270// console.log('apply FAILED');271// console.log(err);272this._logService.error(err);273throw err;274} finally {275listener?.dispose();276undoRedoGroupRemove();277}278}279280private async _saveAll(resources: readonly URI[]) {281const set = new ResourceSet(resources);282const saves = this._workingCopyService.dirtyWorkingCopies.map(async (copy) => {283if (set.has(copy.resource)) {284await copy.save();285}286});287288const result = await Promise.allSettled(saves);289for (const item of result) {290if (item.status === 'rejected') {291this._logService.warn(item.reason);292}293}294}295296private async _shouldVeto(label: string | undefined, reason: ShutdownReason): Promise<boolean> {297let message: string;298switch (reason) {299case ShutdownReason.CLOSE:300message = localize('closeTheWindow.message', "Are you sure you want to close the window?");301break;302case ShutdownReason.LOAD:303message = localize('changeWorkspace.message', "Are you sure you want to change the workspace?");304break;305case ShutdownReason.RELOAD:306message = localize('reloadTheWindow.message', "Are you sure you want to reload the window?");307break;308default:309message = isMacintosh ? localize('quitMessageMac', "Are you sure you want to quit?") : localize('quitMessage', "Are you sure you want to exit?");310break;311}312313const result = await this._dialogService.confirm({314message,315detail: localize('areYouSureQuiteBulkEdit.detail', "'{0}' is in progress.", label || localize('fileOperation', "File operation")),316});317318return !result.confirmed;319}320}321322registerSingleton(IBulkEditService, BulkEditService, InstantiationType.Delayed);323324const autoSaveSetting = 'files.refactoring.autoSave';325326Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfiguration({327id: 'files',328properties: {329[autoSaveSetting]: {330description: localize('refactoring.autoSave', "Controls if files that were part of a refactoring are saved automatically"),331default: true,332type: 'boolean'333}334}335});336337338