Path: blob/main/src/vs/workbench/contrib/bulkEdit/browser/bulkFileEdits.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*--------------------------------------------------------------------------------------------*/456import { WorkspaceFileEditOptions } from '../../../../editor/common/languages.js';7import { IFileService, FileSystemProviderCapabilities, IFileContent, IFileStatWithMetadata } from '../../../../platform/files/common/files.js';8import { IProgress } from '../../../../platform/progress/common/progress.js';9import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';10import { IWorkingCopyFileService, IFileOperationUndoRedoInfo, IMoveOperation, ICopyOperation, IDeleteOperation, ICreateOperation, ICreateFileOperation } from '../../../services/workingCopy/common/workingCopyFileService.js';11import { IWorkspaceUndoRedoElement, UndoRedoElementType, IUndoRedoService, UndoRedoGroup, UndoRedoSource } from '../../../../platform/undoRedo/common/undoRedo.js';12import { URI } from '../../../../base/common/uri.js';13import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';14import { ILogService } from '../../../../platform/log/common/log.js';15import { VSBuffer } from '../../../../base/common/buffer.js';16import { ResourceFileEdit } from '../../../../editor/browser/services/bulkEditService.js';17import { CancellationToken } from '../../../../base/common/cancellation.js';18import { ITextFileService } from '../../../services/textfile/common/textfiles.js';19import { Schemas } from '../../../../base/common/network.js';2021interface IFileOperation {22uris: URI[];23perform(token: CancellationToken): Promise<IFileOperation>;24}2526class Noop implements IFileOperation {27readonly uris = [];28async perform() { return this; }29toString(): string {30return '(noop)';31}32}3334class RenameEdit {35readonly type = 'rename';36constructor(37readonly newUri: URI,38readonly oldUri: URI,39readonly options: WorkspaceFileEditOptions40) { }41}4243class RenameOperation implements IFileOperation {4445constructor(46private readonly _edits: RenameEdit[],47private readonly _undoRedoInfo: IFileOperationUndoRedoInfo,48@IWorkingCopyFileService private readonly _workingCopyFileService: IWorkingCopyFileService,49@IFileService private readonly _fileService: IFileService,50) { }5152get uris() {53return this._edits.flatMap(edit => [edit.newUri, edit.oldUri]);54}5556async perform(token: CancellationToken): Promise<IFileOperation> {5758const moves: IMoveOperation[] = [];59const undoes: RenameEdit[] = [];60for (const edit of this._edits) {61// check: not overwriting, but ignoring, and the target file exists62const skip = edit.options.overwrite === undefined && edit.options.ignoreIfExists && await this._fileService.exists(edit.newUri);63if (!skip) {64moves.push({65file: { source: edit.oldUri, target: edit.newUri },66overwrite: edit.options.overwrite67});6869// reverse edit70undoes.push(new RenameEdit(edit.oldUri, edit.newUri, edit.options));71}72}7374if (moves.length === 0) {75return new Noop();76}7778await this._workingCopyFileService.move(moves, token, this._undoRedoInfo);79return new RenameOperation(undoes, { isUndoing: true }, this._workingCopyFileService, this._fileService);80}8182toString(): string {83return `(rename ${this._edits.map(edit => `${edit.oldUri} to ${edit.newUri}`).join(', ')})`;84}85}8687class CopyEdit {88readonly type = 'copy';89constructor(90readonly newUri: URI,91readonly oldUri: URI,92readonly options: WorkspaceFileEditOptions93) { }94}9596class CopyOperation implements IFileOperation {9798constructor(99private readonly _edits: CopyEdit[],100private readonly _undoRedoInfo: IFileOperationUndoRedoInfo,101@IWorkingCopyFileService private readonly _workingCopyFileService: IWorkingCopyFileService,102@IFileService private readonly _fileService: IFileService,103@IInstantiationService private readonly _instaService: IInstantiationService104) { }105106get uris() {107return this._edits.flatMap(edit => [edit.newUri, edit.oldUri]);108}109110async perform(token: CancellationToken): Promise<IFileOperation> {111112// (1) create copy operations, remove noops113const copies: ICopyOperation[] = [];114for (const edit of this._edits) {115//check: not overwriting, but ignoring, and the target file exists116const skip = edit.options.overwrite === undefined && edit.options.ignoreIfExists && await this._fileService.exists(edit.newUri);117if (!skip) {118copies.push({ file: { source: edit.oldUri, target: edit.newUri }, overwrite: edit.options.overwrite });119}120}121122if (copies.length === 0) {123return new Noop();124}125126// (2) perform the actual copy and use the return stats to build undo edits127const stats = await this._workingCopyFileService.copy(copies, token, this._undoRedoInfo);128const undoes: DeleteEdit[] = [];129130for (let i = 0; i < stats.length; i++) {131const stat = stats[i];132const edit = this._edits[i];133undoes.push(new DeleteEdit(stat.resource, { recursive: true, folder: this._edits[i].options.folder || stat.isDirectory, ...edit.options }, false));134}135136return this._instaService.createInstance(DeleteOperation, undoes, { isUndoing: true });137}138139toString(): string {140return `(copy ${this._edits.map(edit => `${edit.oldUri} to ${edit.newUri}`).join(', ')})`;141}142}143144class CreateEdit {145readonly type = 'create';146constructor(147readonly newUri: URI,148readonly options: WorkspaceFileEditOptions,149readonly contents: VSBuffer | undefined,150) { }151}152153class CreateOperation implements IFileOperation {154155constructor(156private readonly _edits: CreateEdit[],157private readonly _undoRedoInfo: IFileOperationUndoRedoInfo,158@IFileService private readonly _fileService: IFileService,159@IWorkingCopyFileService private readonly _workingCopyFileService: IWorkingCopyFileService,160@IInstantiationService private readonly _instaService: IInstantiationService,161@ITextFileService private readonly _textFileService: ITextFileService162) { }163164get uris() {165return this._edits.map(edit => edit.newUri);166}167168async perform(token: CancellationToken): Promise<IFileOperation> {169170const folderCreates: ICreateOperation[] = [];171const fileCreates: ICreateFileOperation[] = [];172const undoes: DeleteEdit[] = [];173174for (const edit of this._edits) {175if (edit.newUri.scheme === Schemas.untitled) {176continue; // ignore, will be handled by a later edit177}178if (edit.options.overwrite === undefined && edit.options.ignoreIfExists && await this._fileService.exists(edit.newUri)) {179continue; // not overwriting, but ignoring, and the target file exists180}181if (edit.options.folder) {182folderCreates.push({ resource: edit.newUri });183} else {184// If the contents are part of the edit they include the encoding, thus use them. Otherwise get the encoding for a new empty file.185const encodedReadable = typeof edit.contents !== 'undefined' ? edit.contents : await this._textFileService.getEncodedReadable(edit.newUri);186fileCreates.push({ resource: edit.newUri, contents: encodedReadable, overwrite: edit.options.overwrite });187}188undoes.push(new DeleteEdit(edit.newUri, edit.options, !edit.options.folder && !edit.contents));189}190191if (folderCreates.length === 0 && fileCreates.length === 0) {192return new Noop();193}194195await this._workingCopyFileService.createFolder(folderCreates, token, this._undoRedoInfo);196await this._workingCopyFileService.create(fileCreates, token, this._undoRedoInfo);197198return this._instaService.createInstance(DeleteOperation, undoes, { isUndoing: true });199}200201toString(): string {202return `(create ${this._edits.map(edit => edit.options.folder ? `folder ${edit.newUri}` : `file ${edit.newUri} with ${edit.contents?.byteLength || 0} bytes`).join(', ')})`;203}204}205206class DeleteEdit {207readonly type = 'delete';208constructor(209readonly oldUri: URI,210readonly options: WorkspaceFileEditOptions,211readonly undoesCreate: boolean,212) { }213}214215class DeleteOperation implements IFileOperation {216217constructor(218private _edits: DeleteEdit[],219private readonly _undoRedoInfo: IFileOperationUndoRedoInfo,220@IWorkingCopyFileService private readonly _workingCopyFileService: IWorkingCopyFileService,221@IFileService private readonly _fileService: IFileService,222@IConfigurationService private readonly _configurationService: IConfigurationService,223@IInstantiationService private readonly _instaService: IInstantiationService,224@ILogService private readonly _logService: ILogService225) { }226227get uris() {228return this._edits.map(edit => edit.oldUri);229}230231async perform(token: CancellationToken): Promise<IFileOperation> {232// delete file233234const deletes: IDeleteOperation[] = [];235const undoes: CreateEdit[] = [];236237for (const edit of this._edits) {238let fileStat: IFileStatWithMetadata | undefined;239try {240fileStat = await this._fileService.resolve(edit.oldUri, { resolveMetadata: true });241} catch (err) {242if (!edit.options.ignoreIfNotExists) {243throw new Error(`${edit.oldUri} does not exist and can not be deleted`);244}245continue;246}247248deletes.push({249resource: edit.oldUri,250recursive: edit.options.recursive,251useTrash: !edit.options.skipTrashBin && this._fileService.hasCapability(edit.oldUri, FileSystemProviderCapabilities.Trash) && this._configurationService.getValue<boolean>('files.enableTrash')252});253254255// read file contents for undo operation. when a file is too large it won't be restored256let fileContent: IFileContent | undefined;257let fileContentExceedsMaxSize = false;258if (!edit.undoesCreate && !edit.options.folder) {259fileContentExceedsMaxSize = typeof edit.options.maxSize === 'number' && fileStat.size > edit.options.maxSize;260if (!fileContentExceedsMaxSize) {261try {262fileContent = await this._fileService.readFile(edit.oldUri);263} catch (err) {264this._logService.error(err);265}266}267}268if (!fileContentExceedsMaxSize) {269undoes.push(new CreateEdit(edit.oldUri, edit.options, fileContent?.value));270}271}272273if (deletes.length === 0) {274return new Noop();275}276277await this._workingCopyFileService.delete(deletes, token, this._undoRedoInfo);278279if (undoes.length === 0) {280return new Noop();281}282return this._instaService.createInstance(CreateOperation, undoes, { isUndoing: true });283}284285toString(): string {286return `(delete ${this._edits.map(edit => edit.oldUri).join(', ')})`;287}288}289290class FileUndoRedoElement implements IWorkspaceUndoRedoElement {291292readonly type = UndoRedoElementType.Workspace;293294readonly resources: readonly URI[];295296constructor(297readonly label: string,298readonly code: string,299readonly operations: IFileOperation[],300readonly confirmBeforeUndo: boolean301) {302this.resources = operations.flatMap(op => op.uris);303}304305async undo(): Promise<void> {306await this._reverse();307}308309async redo(): Promise<void> {310await this._reverse();311}312313private async _reverse() {314for (let i = 0; i < this.operations.length; i++) {315const op = this.operations[i];316const undo = await op.perform(CancellationToken.None);317this.operations[i] = undo;318}319}320321toString(): string {322return this.operations.map(op => String(op)).join(', ');323}324}325326export class BulkFileEdits {327328constructor(329private readonly _label: string,330private readonly _code: string,331private readonly _undoRedoGroup: UndoRedoGroup,332private readonly _undoRedoSource: UndoRedoSource | undefined,333private readonly _confirmBeforeUndo: boolean,334private readonly _progress: IProgress<void>,335private readonly _token: CancellationToken,336private readonly _edits: ResourceFileEdit[],337@IInstantiationService private readonly _instaService: IInstantiationService,338@IUndoRedoService private readonly _undoRedoService: IUndoRedoService,339) { }340341async apply(): Promise<readonly URI[]> {342const undoOperations: IFileOperation[] = [];343const undoRedoInfo = { undoRedoGroupId: this._undoRedoGroup.id };344345const edits: Array<RenameEdit | CopyEdit | DeleteEdit | CreateEdit> = [];346for (const edit of this._edits) {347if (edit.newResource && edit.oldResource && !edit.options?.copy) {348edits.push(new RenameEdit(edit.newResource, edit.oldResource, edit.options ?? {}));349} else if (edit.newResource && edit.oldResource && edit.options?.copy) {350edits.push(new CopyEdit(edit.newResource, edit.oldResource, edit.options ?? {}));351} else if (!edit.newResource && edit.oldResource) {352edits.push(new DeleteEdit(edit.oldResource, edit.options ?? {}, false));353} else if (edit.newResource && !edit.oldResource) {354edits.push(new CreateEdit(edit.newResource, edit.options ?? {}, await edit.options.contents));355}356}357358if (edits.length === 0) {359return [];360}361362const groups: Array<RenameEdit | CopyEdit | DeleteEdit | CreateEdit>[] = [];363groups[0] = [edits[0]];364365for (let i = 1; i < edits.length; i++) {366const edit = edits[i];367const lastGroup = groups.at(-1);368if (lastGroup?.[0].type === edit.type) {369lastGroup.push(edit);370} else {371groups.push([edit]);372}373}374375for (const group of groups) {376377if (this._token.isCancellationRequested) {378break;379}380381let op: IFileOperation | undefined;382switch (group[0].type) {383case 'rename':384op = this._instaService.createInstance(RenameOperation, <RenameEdit[]>group, undoRedoInfo);385break;386case 'copy':387op = this._instaService.createInstance(CopyOperation, <CopyEdit[]>group, undoRedoInfo);388break;389case 'delete':390op = this._instaService.createInstance(DeleteOperation, <DeleteEdit[]>group, undoRedoInfo);391break;392case 'create':393op = this._instaService.createInstance(CreateOperation, <CreateEdit[]>group, undoRedoInfo);394break;395}396397if (op) {398const undoOp = await op.perform(this._token);399undoOperations.push(undoOp);400}401this._progress.report(undefined);402}403404const undoRedoElement = new FileUndoRedoElement(this._label, this._code, undoOperations, this._confirmBeforeUndo);405this._undoRedoService.pushElement(undoRedoElement, this._undoRedoGroup, this._undoRedoSource);406return undoRedoElement.resources;407}408}409410411