From e3033faeee942a15184eb7d3e95f80af51919251 Mon Sep 17 00:00:00 2001 From: Pascal Fong Kye Date: Wed, 24 Jun 2020 10:32:55 +0200 Subject: [PATCH] File operation events support multiple resources (#98988) * refactor: use array of resources * refactor: use an array of uricomponentspair * feat: move many resources * refactor: rename data to files * feat: use array of files for copy * refactor: use move with multiple resources * refactor: use move method with array of files * refactor: rename data to files * feat: moveOrCopy array of resources on paste * refactor: use concise loop syntax * test: assert number of events * refactor: rename uricomponentspair * support multiple files on WorkingCopyFileEvent * feat: support multiple resources onWillRunWorkingCopyFIleOperation onDidRunWorkingCopyFileOperation * refactor: make source optional for consistency * refactor: support resources for delete * test: isolate tests * fix: iterate over resources * feat: support operations on delete * feat: adopt deleting multiple resources * fix: typing and sequential flow of copyservice * fix: typing and naming * fix: typing and naming * fix: use different message for multiple overwrites * refactor: naming consistency * fix: use array resources * fix: message for multiple overwrites * fix format * clean up working copy file service * refactor multiple overwrites message helper * use openeditors to bulk open * split drop copy and move * add returns Co-authored-by: Benjamin Pasero --- .../api/browser/mainThreadDocuments.ts | 8 +- .../mainThreadFileSystemEventService.ts | 8 +- .../workbench/api/common/extHost.protocol.ts | 9 +- .../common/extHostFileSystemEventService.ts | 20 +- .../contrib/files/browser/fileActions.ts | 68 +-- .../files/browser/views/explorerViewer.ts | 69 ++- .../bulkEdit/browser/bulkFileEdits.ts | 4 +- .../textfile/browser/textFileService.ts | 4 +- .../common/textFileEditorModelManager.ts | 86 ++-- .../test/browser/textFileService.test.ts | 4 +- .../workingCopyFileOperationParticipant.ts | 4 +- .../common/workingCopyFileService.ts | 175 +++++--- .../browser/workingCopyFileService.test.ts | 393 ++++++++++++------ .../browser/api/mainThreadEditors.test.ts | 12 +- .../test/common/workbenchTestServices.ts | 8 +- 15 files changed, 557 insertions(+), 315 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadDocuments.ts b/src/vs/workbench/api/browser/mainThreadDocuments.ts index 025dae93162..45e8a6cdd18 100644 --- a/src/vs/workbench/api/browser/mainThreadDocuments.ts +++ b/src/vs/workbench/api/browser/mainThreadDocuments.ts @@ -126,8 +126,12 @@ export class MainThreadDocuments implements MainThreadDocumentsShape { })); this._toDispose.add(workingCopyFileService.onDidRunWorkingCopyFileOperation(e => { - if (e.source && (e.operation === FileOperation.MOVE || e.operation === FileOperation.DELETE)) { - this._modelReferenceCollection.remove(e.source); + if (e.operation === FileOperation.MOVE || e.operation === FileOperation.DELETE) { + for (const { source } of e.files) { + if (source) { + this._modelReferenceCollection.remove(source); + } + } } })); diff --git a/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts b/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts index aec20a76da9..8b407cfe40f 100644 --- a/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts +++ b/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts @@ -57,14 +57,14 @@ export class MainThreadFileSystemEventService { // BEFORE file operation workingCopyFileService.addFileOperationParticipant({ - participate: (target, source, operation, progress, timeout, token) => { - return proxy.$onWillRunFileOperation(operation, target, source, timeout, token); + participate: (files, operation, progress, timeout, token) => { + return proxy.$onWillRunFileOperation(operation, files, timeout, token); } }); // AFTER file operation - this._listener.add(textFileService.onDidCreateTextFile(e => proxy.$onDidRunFileOperation(FileOperation.CREATE, e.resource, undefined))); - this._listener.add(workingCopyFileService.onDidRunWorkingCopyFileOperation(e => proxy.$onDidRunFileOperation(e.operation, e.target, e.source))); + this._listener.add(textFileService.onDidCreateTextFile(e => proxy.$onDidRunFileOperation(FileOperation.CREATE, [{ target: e.resource }]))); + this._listener.add(workingCopyFileService.onDidRunWorkingCopyFileOperation(e => proxy.$onDidRunFileOperation(e.operation, e.files))); } dispose(): void { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 3e8649520e6..ecc9065c054 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1068,10 +1068,15 @@ export interface FileSystemEvents { deleted: UriComponents[]; } +export interface SourceTargetPair { + source?: UriComponents; + target: UriComponents; +} + export interface ExtHostFileSystemEventServiceShape { $onFileEvent(events: FileSystemEvents): void; - $onWillRunFileOperation(operation: files.FileOperation, target: UriComponents, source: UriComponents | undefined, timeout: number, token: CancellationToken): Promise; - $onDidRunFileOperation(operation: files.FileOperation, target: UriComponents, source: UriComponents | undefined): void; + $onWillRunFileOperation(operation: files.FileOperation, files: SourceTargetPair[], timeout: number, token: CancellationToken): Promise; + $onDidRunFileOperation(operation: files.FileOperation, files: SourceTargetPair[]): void; } export interface ObjectIdentifier { diff --git a/src/vs/workbench/api/common/extHostFileSystemEventService.ts b/src/vs/workbench/api/common/extHostFileSystemEventService.ts index f8114192102..41606d40393 100644 --- a/src/vs/workbench/api/common/extHostFileSystemEventService.ts +++ b/src/vs/workbench/api/common/extHostFileSystemEventService.ts @@ -5,10 +5,10 @@ import { AsyncEmitter, Emitter, Event, IWaitUntil } from 'vs/base/common/event'; import { IRelativePattern, parse } from 'vs/base/common/glob'; -import { URI, UriComponents } from 'vs/base/common/uri'; +import { URI } from 'vs/base/common/uri'; import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors'; import type * as vscode from 'vscode'; -import { ExtHostFileSystemEventServiceShape, FileSystemEvents, IMainContext, MainContext, MainThreadTextEditorsShape, IWorkspaceFileEditDto, IWorkspaceTextEditDto } from './extHost.protocol'; +import { ExtHostFileSystemEventServiceShape, FileSystemEvents, IMainContext, MainContext, MainThreadTextEditorsShape, IWorkspaceFileEditDto, IWorkspaceTextEditDto, SourceTargetPair } from './extHost.protocol'; import * as typeConverter from './extHostTypeConverters'; import { Disposable, WorkspaceEdit } from './extHostTypes'; import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; @@ -142,16 +142,16 @@ export class ExtHostFileSystemEventService implements ExtHostFileSystemEventServ //--- file operations - $onDidRunFileOperation(operation: FileOperation, target: UriComponents, source: UriComponents | undefined): void { + $onDidRunFileOperation(operation: FileOperation, files: SourceTargetPair[]): void { switch (operation) { case FileOperation.MOVE: - this._onDidRenameFile.fire(Object.freeze({ files: [{ oldUri: URI.revive(source!), newUri: URI.revive(target) }] })); + this._onDidRenameFile.fire(Object.freeze({ files: files.map(f => ({ oldUri: URI.revive(f.source!), newUri: URI.revive(f.target) })) })); break; case FileOperation.DELETE: - this._onDidDeleteFile.fire(Object.freeze({ files: [URI.revive(target)] })); + this._onDidDeleteFile.fire(Object.freeze({ files: files.map(f => URI.revive(f.target)) })); break; case FileOperation.CREATE: - this._onDidCreateFile.fire(Object.freeze({ files: [URI.revive(target)] })); + this._onDidCreateFile.fire(Object.freeze({ files: files.map(f => URI.revive(f.target)) })); break; default: //ignore, dont send @@ -179,16 +179,16 @@ export class ExtHostFileSystemEventService implements ExtHostFileSystemEventServ }; } - async $onWillRunFileOperation(operation: FileOperation, target: UriComponents, source: UriComponents | undefined, timeout: number, token: CancellationToken): Promise { + async $onWillRunFileOperation(operation: FileOperation, files: SourceTargetPair[], timeout: number, token: CancellationToken): Promise { switch (operation) { case FileOperation.MOVE: - await this._fireWillEvent(this._onWillRenameFile, { files: [{ oldUri: URI.revive(source!), newUri: URI.revive(target) }] }, timeout, token); + await this._fireWillEvent(this._onWillRenameFile, { files: files.map(f => ({ oldUri: URI.revive(f.source!), newUri: URI.revive(f.target) })) }, timeout, token); break; case FileOperation.DELETE: - await this._fireWillEvent(this._onWillDeleteFile, { files: [URI.revive(target)] }, timeout, token); + await this._fireWillEvent(this._onWillDeleteFile, { files: files.map(f => URI.revive(f.target)) }, timeout, token); break; case FileOperation.CREATE: - await this._fireWillEvent(this._onWillCreateFile, { files: [URI.revive(target)] }, timeout, token); + await this._fireWillEvent(this._onWillCreateFile, { files: files.map(f => URI.revive(f.target)) }, timeout, token); break; default: //ignore, dont send diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index 1ef66d58ce7..21ceb2e52a6 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -15,7 +15,7 @@ import { Action } from 'vs/base/common/actions'; import { dispose, IDisposable } from 'vs/base/common/lifecycle'; import { VIEWLET_ID, IExplorerService, IFilesConfiguration, VIEW_ID } from 'vs/workbench/contrib/files/common/files'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; -import { IFileService } from 'vs/platform/files/common/files'; +import { IFileService, IFileStatWithMetadata } from 'vs/platform/files/common/files'; import { toResource, SideBySideEditor } from 'vs/workbench/common/editor'; import { ExplorerViewPaneContainer } from 'vs/workbench/contrib/files/browser/explorerViewlet'; import { IQuickInputService, ItemActivation } from 'vs/platform/quickinput/common/quickInput'; @@ -222,7 +222,7 @@ async function deleteFiles(workingCopyFileService: IWorkingCopyFileService, dial // Call function try { - await Promise.all(distinctElements.map(e => workingCopyFileService.delete(e.resource, { useTrash: useTrash, recursive: true }))); + await workingCopyFileService.delete(distinctElements.map(e => e.resource), { useTrash, recursive: true }); } catch (error) { // Handle error to delete file(s) from a modal confirmation dialog @@ -947,7 +947,7 @@ export const renameHandler = async (accessor: ServicesAccessor) => { const targetResource = resources.joinPath(parentResource, value); if (stat.resource.toString() !== targetResource.toString()) { try { - await workingCopyFileService.move(stat.resource, targetResource); + await workingCopyFileService.move([{ source: stat.resource, target: targetResource }]); await refreshIfSeparator(value, explorerService); } catch (e) { notificationService.error(e); @@ -1033,7 +1033,7 @@ const downloadFileHandler = (accessor: ServicesAccessor) => { defaultUri }); if (destination) { - await workingCopyFileService.copy(s.resource, destination, true); + await workingCopyFileService.copy([{ source: s.resource, target: destination }], true); } else { // User canceled a download. In case there were multiple files selected we should cancel the remainder of the prompts #86100 canceled = true; @@ -1060,14 +1060,13 @@ export const pasteFileHandler = async (accessor: ServicesAccessor) => { const toPaste = resources.distinctParents(await clipboardService.readResources(), r => r); const element = context.length ? context[0] : explorerService.roots[0]; - // Check if target is ancestor of pasted folder - const stats = await Promise.all(toPaste.map(async fileToPaste => { + try { + // Check if target is ancestor of pasted folder + const sourceTargetPairs = await Promise.all(toPaste.map(async fileToPaste => { - if (element.resource.toString() !== fileToPaste.toString() && resources.isEqualOrParent(element.resource, fileToPaste)) { - throw new Error(nls.localize('fileIsAncestor', "File to paste is an ancestor of the destination folder")); - } - - try { + if (element.resource.toString() !== fileToPaste.toString() && resources.isEqualOrParent(element.resource, fileToPaste)) { + throw new Error(nls.localize('fileIsAncestor', "File to paste is an ancestor of the destination folder")); + } const fileToPasteStat = await fileService.resolve(fileToPaste); // Find target @@ -1081,30 +1080,33 @@ export const pasteFileHandler = async (accessor: ServicesAccessor) => { const incrementalNaming = configurationService.getValue().explorer.incrementalNaming; const targetFile = findValidPasteFileTarget(explorerService, target, { resource: fileToPaste, isDirectory: fileToPasteStat.isDirectory, allowOverwrite: pasteShouldMove }, incrementalNaming); - // Move/Copy File - if (pasteShouldMove) { - return await workingCopyFileService.move(fileToPaste, targetFile); - } else { - return await workingCopyFileService.copy(fileToPaste, targetFile); - } - } catch (e) { - onError(notificationService, new Error(nls.localize('fileDeleted', "The file to paste has been deleted or moved since you copied it. {0}", getErrorMessage(e)))); - return undefined; - } - })); + return { source: fileToPaste, target: targetFile }; + })); - if (pasteShouldMove) { - // Cut is done. Make sure to clear cut state. - await explorerService.setToCopy([], false); - pasteShouldMove = false; - } - if (stats.length >= 1) { - const stat = stats[0]; - if (stat && !stat.isDirectory && stats.length === 1) { - await editorService.openEditor({ resource: stat.resource, options: { pinned: true, preserveFocus: true } }); + // Move/Copy File + let stats: IFileStatWithMetadata[] = []; + if (pasteShouldMove) { + stats = await workingCopyFileService.move(sourceTargetPairs); + } else { + stats = await workingCopyFileService.copy(sourceTargetPairs); } - if (stat) { - await explorerService.select(stat.resource); + + if (stats.length >= 1) { + const stat = stats[0]; + if (stat && !stat.isDirectory && stats.length === 1) { + await editorService.openEditor({ resource: stat.resource, options: { pinned: true, preserveFocus: true } }); + } + if (stat) { + await explorerService.select(stat.resource); + } + } + } catch (e) { + onError(notificationService, new Error(nls.localize('fileDeleted', "The file(s) to paste have been deleted or moved since you copied them. {0}", getErrorMessage(e)))); + } finally { + if (pasteShouldMove) { + // Cut is done. Make sure to clear cut state. + await explorerService.setToCopy([], false); + pasteShouldMove = false; } } }; diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index 86c3f6b20ed..54d3e8fc359 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -723,6 +723,20 @@ const getFileOverwriteConfirm = (name: string) => { }; }; +const getMultipleFilesOverwriteConfirm = (files: URI[]) => { + if (files.length > 1) { + return { + message: localize('confirmManyOverwrites', "The following {0} files and/or folders already exist in the destination folder. Do you want to replace them?", files.length), + detail: getFileNamesMessage(files) + '\n' + localize('irreversible', "This action is irreversible!"), + primaryButton: localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), + type: 'warning' + }; + } else { + return getFileOverwriteConfirm(basename(files[0])); + } + +}; + interface IWebkitDataTransfer { items: IWebkitDataTransferItem[]; } @@ -1010,7 +1024,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { continue; } - await this.workingCopyFileService.delete(joinPath(target.resource, entry.name), { recursive: true }); + await this.workingCopyFileService.delete([joinPath(target.resource, entry.name)], { recursive: true }); } // Upload entry @@ -1263,7 +1277,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { const sourceFile = resource; const targetFile = joinPath(target.resource, basename(sourceFile)); - const stat = await this.workingCopyFileService.copy(sourceFile, targetFile, true); + const stat = (await this.workingCopyFileService.copy([{ source: sourceFile, target: targetFile }], true))[0]; // if we only add one file, just open it directly if (resources.length === 1 && !stat.isDirectory) { this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); @@ -1310,7 +1324,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { } const rootDropPromise = this.doHandleRootDrop(items.filter(s => s.isRoot), target); - await Promise.all(items.filter(s => !s.isRoot).map(source => this.doHandleExplorerDrop(source, target, isCopy)).concat(rootDropPromise)); + await Promise.all([this.doHandleExplorerDrop(items.filter(s => !s.isRoot), target, isCopy), rootDropPromise]); } private doHandleRootDrop(roots: ExplorerItem[], target: ExplorerItem): Promise { @@ -1346,36 +1360,39 @@ export class FileDragAndDrop implements ITreeDragAndDrop { return this.workspaceEditingService.updateFolders(0, workspaceCreationData.length, workspaceCreationData); } - private async doHandleExplorerDrop(source: ExplorerItem, target: ExplorerItem, isCopy: boolean): Promise { - // Reuse duplicate action if user copies - if (isCopy) { - const incrementalNaming = this.configurationService.getValue().explorer.incrementalNaming; - const stat = await this.workingCopyFileService.copy(source.resource, findValidPasteFileTarget(this.explorerService, target, { resource: source.resource, isDirectory: source.isDirectory, allowOverwrite: false }, incrementalNaming)); - if (!stat.isDirectory) { - await this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); - } + private async doHandleExplorerDropOnCopy(sources: ExplorerItem[], target: ExplorerItem): Promise { + // Reuse duplicate action when user copies + const incrementalNaming = this.configurationService.getValue().explorer.incrementalNaming; + const sourceTargetPairs = sources.map(({ resource, isDirectory }) => ({ source: resource, target: findValidPasteFileTarget(this.explorerService, target, { resource, isDirectory, allowOverwrite: false }, incrementalNaming) })); + const editors = (await this.workingCopyFileService.copy(sourceTargetPairs)).filter(stat => !stat.isDirectory).map(({ resource }) => ({ resource, options: { pinned: true } })); + await this.editorService.openEditors(editors); + } - return; - } + private async doHandleExplorerDropOnMove(sources: ExplorerItem[], target: ExplorerItem): Promise { - // Otherwise move - const targetResource = joinPath(target.resource, source.name); - if (source.isReadonly) { - // Do not allow moving readonly items - return Promise.resolve(); - } + // Do not allow moving readonly items + const sourceTargetPairs = sources.filter(source => !source.isReadonly).map(source => ({ source: source.resource, target: joinPath(target.resource, source.name) })); try { - await this.workingCopyFileService.move(source.resource, targetResource); + await this.workingCopyFileService.move(sourceTargetPairs); } catch (error) { // Conflict if ((error).fileOperationResult === FileOperationResult.FILE_MOVE_CONFLICT) { - const confirm = getFileOverwriteConfirm(source.name); + + const overwrites: URI[] = []; + for (const { target } of sourceTargetPairs) { + if (await this.fileService.exists(target)) { + overwrites.push(target); + } + } + + const confirm = getMultipleFilesOverwriteConfirm(overwrites); + // Move with overwrite if the user confirms const { confirmed } = await this.dialogService.confirm(confirm); if (confirmed) { try { - await this.workingCopyFileService.move(source.resource, targetResource, true /* overwrite */); + await this.workingCopyFileService.move(sourceTargetPairs, true /* overwrite */); } catch (error) { this.notificationService.error(error); } @@ -1388,6 +1405,14 @@ export class FileDragAndDrop implements ITreeDragAndDrop { } } + private async doHandleExplorerDrop(sources: ExplorerItem[], target: ExplorerItem, isCopy: boolean): Promise { + if (isCopy) { + return this.doHandleExplorerDropOnCopy(sources, target); + } else { + return this.doHandleExplorerDropOnMove(sources, target); + } + } + private static getStatsFromDragAndDropData(data: ElementsDragAndDropData, dragStartEvent?: DragEvent): ExplorerItem[] { if (data.context) { return data.context; diff --git a/src/vs/workbench/services/bulkEdit/browser/bulkFileEdits.ts b/src/vs/workbench/services/bulkEdit/browser/bulkFileEdits.ts index f371ee227a4..7b7a9b2e450 100644 --- a/src/vs/workbench/services/bulkEdit/browser/bulkFileEdits.ts +++ b/src/vs/workbench/services/bulkEdit/browser/bulkFileEdits.ts @@ -46,7 +46,7 @@ class RenameOperation implements IFileOperation { if (this.options.overwrite === undefined && this.options.ignoreIfExists && await this._fileService.exists(this.newUri)) { return new Noop(); // not overwriting, but ignoring, and the target file exists } - await this._workingCopyFileService.move(this.oldUri, this.newUri, this.options.overwrite); + await this._workingCopyFileService.move([{ source: this.oldUri, target: this.newUri }], this.options.overwrite); return new RenameOperation(this.oldUri, this.newUri, this.options, this._workingCopyFileService, this._fileService); } } @@ -109,7 +109,7 @@ class DeleteOperation implements IFileOperation { } const useTrash = this._fileService.hasCapability(this.oldUri, FileSystemProviderCapabilities.Trash) && this._configurationService.getValue('files.enableTrash'); - await this._workingCopyFileService.delete(this.oldUri, { useTrash, recursive: this.options.recursive }); + await this._workingCopyFileService.delete([this.oldUri], { useTrash, recursive: this.options.recursive }); return this._instaService.createInstance(CreateOperation, this.oldUri, this.options, contents); } } diff --git a/src/vs/workbench/services/textfile/browser/textFileService.ts b/src/vs/workbench/services/textfile/browser/textFileService.ts index 58328694581..1f60e61aa8d 100644 --- a/src/vs/workbench/services/textfile/browser/textFileService.ts +++ b/src/vs/workbench/services/textfile/browser/textFileService.ts @@ -155,7 +155,7 @@ export abstract class AbstractTextFileService extends Disposable implements ITex async create(resource: URI, value?: string | ITextSnapshot | VSBuffer, options?: ICreateFileOptions): Promise { // file operation participation - await this.workingCopyFileService.runFileOperationParticipants(resource, undefined, FileOperation.CREATE); + await this.workingCopyFileService.runFileOperationParticipants([{ target: resource, source: undefined }], FileOperation.CREATE); // create file on disk const stat = await this.doCreate(resource, value, options); @@ -246,7 +246,7 @@ export abstract class AbstractTextFileService extends Disposable implements ITex // However, this will only work if the source exists // and is not orphaned, so we need to check that too. if (this.fileService.canHandleResource(source) && this.uriIdentityService.extUri.isEqual(source, target) && (await this.fileService.exists(source))) { - await this.workingCopyFileService.move(source, target); + await this.workingCopyFileService.move([{ source, target }]); return this.save(target, options); } diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts index 64678af9e2d..782930dc058 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts @@ -135,50 +135,56 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE private onWillRunWorkingCopyFileOperation(e: WorkingCopyFileEvent): void { // Move / Copy: remember models to restore after the operation - const source = e.source; - if (source && (e.operation === FileOperation.COPY || e.operation === FileOperation.MOVE)) { + if (e.operation === FileOperation.COPY || e.operation === FileOperation.MOVE) { - // find all models that related to either source or target (can be many if resource is a folder) - const sourceModels: TextFileEditorModel[] = []; - const targetModels: TextFileEditorModel[] = []; - for (const model of this.models) { - const resource = model.resource; - - if (extUri.isEqualOrParent(resource, e.target)) { - // EXPLICITLY do not ignorecase, see https://github.com/Microsoft/vscode/issues/56384 - targetModels.push(model); - } - - if (this.uriIdentityService.extUri.isEqualOrParent(resource, source)) { - sourceModels.push(model); - } - } - - // remember each source model to load again after move is done - // with optional content to restore if it was dirty const modelsToRestore: { source: URI, target: URI, snapshot?: ITextSnapshot; mode?: string; encoding?: string; }[] = []; - for (const sourceModel of sourceModels) { - const sourceModelResource = sourceModel.resource; - // If the source is the actual model, just use target as new resource - let targetModelResource: URI; - if (this.uriIdentityService.extUri.isEqual(sourceModelResource, e.source)) { - targetModelResource = e.target; + for (const { source, target } of e.files) { + if (source) { + + // find all models that related to either source or target (can be many if resource is a folder) + const sourceModels: TextFileEditorModel[] = []; + const targetModels: TextFileEditorModel[] = []; + for (const model of this.models) { + const resource = model.resource; + + if (extUri.isEqualOrParent(resource, target)) { + // EXPLICITLY do not ignorecase, see https://github.com/Microsoft/vscode/issues/56384 + targetModels.push(model); + } + + if (this.uriIdentityService.extUri.isEqualOrParent(resource, source)) { + sourceModels.push(model); + } + } + + // remember each source model to load again after move is done + // with optional content to restore if it was dirty + for (const sourceModel of sourceModels) { + const sourceModelResource = sourceModel.resource; + + // If the source is the actual model, just use target as new resource + let targetModelResource: URI; + if (this.uriIdentityService.extUri.isEqual(sourceModelResource, source)) { + targetModelResource = target; + } + + // Otherwise a parent folder of the source is being moved, so we need + // to compute the target resource based on that + else { + targetModelResource = joinPath(target, sourceModelResource.path.substr(source.path.length + 1)); + } + + modelsToRestore.push({ + source: sourceModelResource, + target: targetModelResource, + mode: sourceModel.getMode(), + encoding: sourceModel.getEncoding(), + snapshot: sourceModel.isDirty() ? sourceModel.createSnapshot() : undefined + }); + } + } - - // Otherwise a parent folder of the source is being moved, so we need - // to compute the target resource based on that - else { - targetModelResource = joinPath(e.target, sourceModelResource.path.substr(source.path.length + 1)); - } - - modelsToRestore.push({ - source: sourceModelResource, - target: targetModelResource, - mode: sourceModel.getMode(), - encoding: sourceModel.getEncoding(), - snapshot: sourceModel.isDirty() ? sourceModel.createSnapshot() : undefined - }); } this.mapCorrelationIdToModelsToRestore.set(e.correlationId, modelsToRestore); diff --git a/src/vs/workbench/services/textfile/test/browser/textFileService.test.ts b/src/vs/workbench/services/textfile/test/browser/textFileService.test.ts index 062219d1e51..c7548eb593f 100644 --- a/src/vs/workbench/services/textfile/test/browser/textFileService.test.ts +++ b/src/vs/workbench/services/textfile/test/browser/textFileService.test.ts @@ -111,8 +111,8 @@ suite('Files - TextFileService', () => { let eventCounter = 0; const disposable1 = accessor.workingCopyFileService.addFileOperationParticipant({ - participate: async target => { - assert.equal(target.toString(), model.resource.toString()); + participate: async files => { + assert.equal(files[0].target, model.resource.toString()); eventCounter++; } }); diff --git a/src/vs/workbench/services/workingCopy/common/workingCopyFileOperationParticipant.ts b/src/vs/workbench/services/workingCopy/common/workingCopyFileOperationParticipant.ts index 4b0928035ee..7d60ea29d7f 100644 --- a/src/vs/workbench/services/workingCopy/common/workingCopyFileOperationParticipant.ts +++ b/src/vs/workbench/services/workingCopy/common/workingCopyFileOperationParticipant.ts @@ -33,7 +33,7 @@ export class WorkingCopyFileOperationParticipant extends Disposable { return toDisposable(() => remove()); } - async participate(target: URI, source: URI | undefined, operation: FileOperation): Promise { + async participate(files: { source?: URI, target: URI }[], operation: FileOperation): Promise { const timeout = this.configurationService.getValue('files.participants.timeout'); if (timeout <= 0) { return; // disabled @@ -53,7 +53,7 @@ export class WorkingCopyFileOperationParticipant extends Disposable { } try { - const promise = participant.participate(target, source, operation, progress, timeout, cts.token); + const promise = participant.participate(files, operation, progress, timeout, cts.token); await raceTimeout(promise, timeout, () => cts.dispose(true /* cancel */)); } catch (err) { this.logService.warn(err); diff --git a/src/vs/workbench/services/workingCopy/common/workingCopyFileService.ts b/src/vs/workbench/services/workingCopy/common/workingCopyFileService.ts index dfec25cce8e..6a07bd68642 100644 --- a/src/vs/workbench/services/workingCopy/common/workingCopyFileService.ts +++ b/src/vs/workbench/services/workingCopy/common/workingCopyFileService.ts @@ -18,6 +18,19 @@ import { WorkingCopyFileOperationParticipant } from 'vs/workbench/services/worki export const IWorkingCopyFileService = createDecorator('workingCopyFileService'); +interface SourceTargetPair { + + /** + * The source resource that is defined for move operations. + */ + readonly source?: URI; + + /** + * The target resource the event is about. + */ + readonly target: URI +} + export interface WorkingCopyFileEvent extends IWaitUntil { /** @@ -32,25 +45,19 @@ export interface WorkingCopyFileEvent extends IWaitUntil { readonly operation: FileOperation; /** - * The resource the event is about. + * The array of source/target pair of files involved in given operation. */ - readonly target: URI; - - /** - * A property that is defined for move operations. - */ - readonly source?: URI; + readonly files: SourceTargetPair[] } export interface IWorkingCopyFileOperationParticipant { /** - * Participate in a file operation of a working copy. Allows to - * change the working copy before it is being saved to disk. + * Participate in a file operation of working copies. Allows to + * change the working copies before they are being saved to disk. */ participate( - target: URI, - source: URI | undefined, + files: SourceTargetPair[], operation: FileOperation, progress: IProgress, timeout: number, @@ -114,40 +121,43 @@ export interface IWorkingCopyFileService { /** * Execute all known file operation participants. */ - runFileOperationParticipants(target: URI, source: URI | undefined, operation: FileOperation): Promise + runFileOperationParticipants(files: SourceTargetPair[], operation: FileOperation): Promise + + //#endregion //#region File operations /** - * Will move working copies matching the provided resource and children - * to the target resource using the associated file service for that resource. + * Will move working copies matching the provided resources and corresponding children + * to the target resources using the associated file service for those resources. * * Working copy owners can listen to the `onWillRunWorkingCopyFileOperation` and * `onDidRunWorkingCopyFileOperation` events to participate. */ - move(source: URI, target: URI, overwrite?: boolean): Promise; + move(files: SourceTargetPair[], overwrite?: boolean): Promise; /** - * Will copy working copies matching the provided resource and children - * to the target using the associated file service for that resource. + * Will copy working copies matching the provided resources and corresponding children + * to the target resources using the associated file service for those resources. * * Working copy owners can listen to the `onWillRunWorkingCopyFileOperation` and * `onDidRunWorkingCopyFileOperation` events to participate. */ - copy(source: URI, target: URI, overwrite?: boolean): Promise; + copy(files: SourceTargetPair[], overwrite?: boolean): Promise; /** - * Will delete working copies matching the provided resource and children - * using the associated file service for that resource. + * Will delete working copies matching the provided resources and children + * using the associated file service for those resources. * * Working copy owners can listen to the `onWillRunWorkingCopyFileOperation` and * `onDidRunWorkingCopyFileOperation` events to participate. */ - delete(resource: URI, options?: { useTrash?: boolean, recursive?: boolean }): Promise; + delete(resources: URI[], options?: { useTrash?: boolean, recursive?: boolean }): Promise; //#endregion + //#region Path related /** @@ -209,93 +219,118 @@ export class WorkingCopyFileService extends Disposable implements IWorkingCopyFi }); } - async move(source: URI, target: URI, overwrite?: boolean): Promise { - return this.moveOrCopy(source, target, true, overwrite); + //#region File operations + + async move(files: SourceTargetPair[], overwrite?: boolean): Promise { + return this.doMoveOrCopy(files, true, overwrite); } - async copy(source: URI, target: URI, overwrite?: boolean): Promise { - return this.moveOrCopy(source, target, false, overwrite); + async copy(files: SourceTargetPair[], overwrite?: boolean): Promise { + return this.doMoveOrCopy(files, false, overwrite); } - private async moveOrCopy(source: URI, target: URI, move: boolean, overwrite?: boolean): Promise { + private async doMoveOrCopy(files: SourceTargetPair[], move: boolean, overwrite?: boolean): Promise { + const stats: IFileStatWithMetadata[] = []; // validate move/copy operation before starting - const validateMoveOrCopy = await (move ? this.fileService.canMove(source, target, overwrite) : this.fileService.canCopy(source, target, overwrite)); - if (validateMoveOrCopy instanceof Error) { - throw validateMoveOrCopy; + for (const { source, target } of files) { + const validateMoveOrCopy = await (move ? this.fileService.canMove(source!, target, overwrite) : this.fileService.canCopy(source!, target, overwrite)); + if (validateMoveOrCopy instanceof Error) { + throw validateMoveOrCopy; + } } // file operation participant - await this.runFileOperationParticipants(target, source, move ? FileOperation.MOVE : FileOperation.COPY); + await this.runFileOperationParticipants(files, move ? FileOperation.MOVE : FileOperation.COPY); - // Before doing the heave operations, check first if source and target + // Before doing the heavy operations, check first if source and target // are either identical or are considered to be identical for the file // system. In that case we want the model to stay as is and only do the // raw file operation. - if (this.uriIdentityService.extUri.isEqual(source, target)) { - if (move) { - return this.fileService.move(source, target, overwrite); + const remainingFiles: SourceTargetPair[] = []; + for (const { source, target } of files) { + if (this.uriIdentityService.extUri.isEqual(source, target)) { + if (move) { + stats.push(await this.fileService.move(source!, target, overwrite)); + } else { + stats.push(await this.fileService.copy(source!, target, overwrite)); + } } else { - return this.fileService.copy(source, target, overwrite); + remainingFiles.push({ source, target }); } } - // before event - const event = { correlationId: this.correlationIds++, operation: move ? FileOperation.MOVE : FileOperation.COPY, target, source }; - await this._onWillRunWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); + // Now handle all the file operations that are not identical files + if (remainingFiles.length > 0) { - // handle dirty working copies depending on the operation: - // - move: revert both source and target (if any) - // - copy: revert target (if any) - const dirtyWorkingCopies = (move ? [...this.getDirty(source), ...this.getDirty(target)] : this.getDirty(target)); - await Promise.all(dirtyWorkingCopies.map(dirtyWorkingCopy => dirtyWorkingCopy.revert({ soft: true }))); + // before event + const event = { correlationId: this.correlationIds++, operation: move ? FileOperation.MOVE : FileOperation.COPY, files: remainingFiles }; + await this._onWillRunWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); - // now we can rename the source to target via file operation - let stat: IFileStatWithMetadata; - try { - if (move) { - stat = await this.fileService.move(source, target, overwrite); - } else { - stat = await this.fileService.copy(source, target, overwrite); + + // handle dirty working copies depending on the operation: + // - move: revert both source and target (if any) + // - copy: revert target (if any) + for (const { source, target } of remainingFiles) { + const dirtyWorkingCopies = (move ? [...this.getDirty(source!), ...this.getDirty(target)] : this.getDirty(target)); + await Promise.all(dirtyWorkingCopies.map(dirtyWorkingCopy => dirtyWorkingCopy.revert({ soft: true }))); } - } catch (error) { - // error event - await this._onDidFailWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); + // now we can rename the source to target via file operation + try { + for (const { source, target } of remainingFiles) { + if (move) { + stats.push(await this.fileService.move(source!, target, overwrite)); + } else { + stats.push(await this.fileService.copy(source!, target, overwrite)); + } + } + } catch (error) { - throw error; + // error event + await this._onDidFailWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); + + throw error; + } + + // after event + await this._onDidRunWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); } - // after event - await this._onDidRunWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); - - return stat; + return stats; } - async delete(resource: URI, options?: { useTrash?: boolean, recursive?: boolean }): Promise { + async delete(resources: URI[], options?: { useTrash?: boolean, recursive?: boolean }): Promise { // validate delete operation before starting - const validateDelete = await this.fileService.canDelete(resource, options); - if (validateDelete instanceof Error) { - throw validateDelete; + for (const resource of resources) { + const validateDelete = await this.fileService.canDelete(resource, options); + if (validateDelete instanceof Error) { + throw validateDelete; + } } // file operation participant - await this.runFileOperationParticipants(resource, undefined, FileOperation.DELETE); + const files = resources.map(target => ({ target })); + await this.runFileOperationParticipants(files, FileOperation.DELETE); // before events - const event = { correlationId: this.correlationIds++, operation: FileOperation.DELETE, target: resource }; + const event = { correlationId: this.correlationIds++, operation: FileOperation.DELETE, files }; await this._onWillRunWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); // Check for any existing dirty working copies for the resource // and do a soft revert before deleting to be able to close // any opened editor with these working copies - const dirtyWorkingCopies = this.getDirty(resource); - await Promise.all(dirtyWorkingCopies.map(dirtyWorkingCopy => dirtyWorkingCopy.revert({ soft: true }))); + for (const resource of resources) { + const dirtyWorkingCopies = this.getDirty(resource); + await Promise.all(dirtyWorkingCopies.map(dirtyWorkingCopy => dirtyWorkingCopy.revert({ soft: true }))); + } // Now actually delete from disk try { - await this.fileService.del(resource, options); + for (const resource of resources) { + await this.fileService.del(resource, options); + } } catch (error) { // error event @@ -308,6 +343,8 @@ export class WorkingCopyFileService extends Disposable implements IWorkingCopyFi await this._onDidRunWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); } + //#endregion + //#region File operation participants @@ -317,8 +354,8 @@ export class WorkingCopyFileService extends Disposable implements IWorkingCopyFi return this.fileOperationParticipants.addFileOperationParticipant(participant); } - runFileOperationParticipants(target: URI, source: URI | undefined, operation: FileOperation): Promise { - return this.fileOperationParticipants.participate(target, source, operation); + runFileOperationParticipants(files: SourceTargetPair[], operation: FileOperation): Promise { + return this.fileOperationParticipants.participate(files, operation); } //#endregion diff --git a/src/vs/workbench/services/workingCopy/test/browser/workingCopyFileService.test.ts b/src/vs/workbench/services/workingCopy/test/browser/workingCopyFileService.test.ts index db3f0a8e4fb..70d59ffc7ad 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/workingCopyFileService.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/workingCopyFileService.test.ts @@ -16,7 +16,6 @@ import { TestWorkingCopy } from 'vs/workbench/services/workingCopy/test/common/w suite('WorkingCopyFileService', () => { let instantiationService: IInstantiationService; - let model: TextFileEditorModel; let accessor: TestServiceAccessor; setup(() => { @@ -25,144 +24,116 @@ suite('WorkingCopyFileService', () => { }); teardown(() => { - model?.dispose(); (accessor.textFileService.files).dispose(); }); test('delete - dirty file', async function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); - (accessor.textFileService.files).add(model.resource, model); + await testDelete([toResource.call(this, '/path/file.txt')]); + }); - await model.load(); - model!.textEditorModel!.setValue('foo'); - assert.ok(accessor.workingCopyService.isDirty(model.resource)); - - let eventCounter = 0; - let correlationId: number | undefined = undefined; - - const participant = accessor.workingCopyFileService.addFileOperationParticipant({ - participate: async (target, source, operation) => { - assert.equal(target.toString(), model.resource.toString()); - assert.equal(operation, FileOperation.DELETE); - eventCounter++; - } - }); - - const listener1 = accessor.workingCopyFileService.onWillRunWorkingCopyFileOperation(e => { - assert.equal(e.target.toString(), model.resource.toString()); - assert.equal(e.operation, FileOperation.DELETE); - correlationId = e.correlationId; - eventCounter++; - }); - - const listener2 = accessor.workingCopyFileService.onDidRunWorkingCopyFileOperation(e => { - assert.equal(e.target.toString(), model.resource.toString()); - assert.equal(e.operation, FileOperation.DELETE); - assert.equal(e.correlationId, correlationId); - eventCounter++; - }); - - await accessor.workingCopyFileService.delete(model.resource); - assert.ok(!accessor.workingCopyService.isDirty(model.resource)); - - assert.equal(eventCounter, 3); - - participant.dispose(); - listener1.dispose(); - listener2.dispose(); + test('delete multiple - dirty files', async function () { + await testDelete([ + toResource.call(this, '/path/file1.txt'), + toResource.call(this, '/path/file2.txt'), + toResource.call(this, '/path/file3.txt'), + toResource.call(this, '/path/file4.txt')]); }); test('move - dirty file', async function () { - await testMoveOrCopy(toResource.call(this, '/path/file.txt'), toResource.call(this, '/path/file_target.txt'), true); + await testMoveOrCopy([{ source: toResource.call(this, '/path/file.txt'), target: toResource.call(this, '/path/file_target.txt') }], true); + }); + + test('move - source identical to target', async function () { + let sourceModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); + (accessor.textFileService.files).add(sourceModel.resource, sourceModel); + + const eventCounter = await testEventsMoveOrCopy([{ source: sourceModel.resource, target: sourceModel.resource }], true); + + sourceModel.dispose(); + assert.equal(eventCounter, 1); + }); + + test('move - one source == target and another source != target', async function () { + let sourceModel1: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file1.txt'), 'utf8', undefined); + let sourceModel2: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file2.txt'), 'utf8', undefined); + let targetModel2: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file_target2.txt'), 'utf8', undefined); + (accessor.textFileService.files).add(sourceModel1.resource, sourceModel1); + (accessor.textFileService.files).add(sourceModel2.resource, sourceModel2); + (accessor.textFileService.files).add(targetModel2.resource, targetModel2); + + const eventCounter = await testEventsMoveOrCopy([ + { source: sourceModel1.resource, target: sourceModel1.resource }, + { source: sourceModel2.resource, target: targetModel2.resource } + ], true); + + sourceModel1.dispose(); + sourceModel2.dispose(); + targetModel2.dispose(); + assert.equal(eventCounter, 3); + }); + + test('move multiple - dirty file', async function () { + await testMoveOrCopy([ + { source: toResource.call(this, '/path/file1.txt'), target: toResource.call(this, '/path/file1_target.txt') }, + { source: toResource.call(this, '/path/file2.txt'), target: toResource.call(this, '/path/file2_target.txt') }], + true); }); test('move - dirty file (target exists and is dirty)', async function () { - await testMoveOrCopy(toResource.call(this, '/path/file.txt'), toResource.call(this, '/path/file_target.txt'), true, true); + await testMoveOrCopy([{ source: toResource.call(this, '/path/file.txt'), target: toResource.call(this, '/path/file_target.txt') }], true, true); }); test('copy - dirty file', async function () { - await testMoveOrCopy(toResource.call(this, '/path/file.txt'), toResource.call(this, '/path/file_target.txt'), false); + await testMoveOrCopy([{ source: toResource.call(this, '/path/file.txt'), target: toResource.call(this, '/path/file_target.txt') }], false); + }); + + test('copy - source identical to target', async function () { + let sourceModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); + (accessor.textFileService.files).add(sourceModel.resource, sourceModel); + + const eventCounter = await testEventsMoveOrCopy([{ source: sourceModel.resource, target: sourceModel.resource }]); + + sourceModel.dispose(); + assert.equal(eventCounter, 1); + }); + + test('copy - one source == target and another source != target', async function () { + let sourceModel1: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file1.txt'), 'utf8', undefined); + let sourceModel2: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file2.txt'), 'utf8', undefined); + let targetModel2: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file_target2.txt'), 'utf8', undefined); + (accessor.textFileService.files).add(sourceModel1.resource, sourceModel1); + (accessor.textFileService.files).add(sourceModel2.resource, sourceModel2); + (accessor.textFileService.files).add(targetModel2.resource, targetModel2); + + const eventCounter = await testEventsMoveOrCopy([ + { source: sourceModel1.resource, target: sourceModel1.resource }, + { source: sourceModel2.resource, target: targetModel2.resource } + ]); + + sourceModel1.dispose(); + sourceModel2.dispose(); + targetModel2.dispose(); + assert.equal(eventCounter, 3); + }); + + test('copy multiple - dirty file', async function () { + await testMoveOrCopy([ + { source: toResource.call(this, '/path/file1.txt'), target: toResource.call(this, '/path/file_target1.txt') }, + { source: toResource.call(this, '/path/file2.txt'), target: toResource.call(this, '/path/file_target2.txt') }, + { source: toResource.call(this, '/path/file3.txt'), target: toResource.call(this, '/path/file_target3.txt') }], + false); }); test('copy - dirty file (target exists and is dirty)', async function () { - await testMoveOrCopy(toResource.call(this, '/path/file.txt'), toResource.call(this, '/path/file_target.txt'), false, true); + await testMoveOrCopy([{ source: toResource.call(this, '/path/file.txt'), target: toResource.call(this, '/path/file_target.txt') }], false, true); }); - async function testMoveOrCopy(source: URI, target: URI, move: boolean, targetDirty?: boolean): Promise { - let sourceModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, source, 'utf8', undefined); - let targetModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, target, 'utf8', undefined); - (accessor.textFileService.files).add(sourceModel.resource, sourceModel); - (accessor.textFileService.files).add(targetModel.resource, targetModel); - - await sourceModel.load(); - sourceModel.textEditorModel!.setValue('foo'); - assert.ok(accessor.textFileService.isDirty(sourceModel.resource)); - - if (targetDirty) { - await targetModel.load(); - targetModel.textEditorModel!.setValue('bar'); - assert.ok(accessor.textFileService.isDirty(targetModel.resource)); - } - - let eventCounter = 0; - let correlationId: number | undefined = undefined; - - const participant = accessor.workingCopyFileService.addFileOperationParticipant({ - participate: async (target, source, operation) => { - assert.equal(target.toString(), targetModel.resource.toString()); - assert.equal(source?.toString(), sourceModel.resource.toString()); - assert.equal(operation, move ? FileOperation.MOVE : FileOperation.COPY); - eventCounter++; - } - }); - - const listener1 = accessor.workingCopyFileService.onWillRunWorkingCopyFileOperation(e => { - assert.equal(e.target.toString(), targetModel.resource.toString()); - assert.equal(e.source?.toString(), sourceModel.resource.toString()); - assert.equal(e.operation, move ? FileOperation.MOVE : FileOperation.COPY); - eventCounter++; - correlationId = e.correlationId; - }); - - const listener2 = accessor.workingCopyFileService.onDidRunWorkingCopyFileOperation(e => { - assert.equal(e.target.toString(), targetModel.resource.toString()); - assert.equal(e.source?.toString(), sourceModel.resource.toString()); - assert.equal(e.operation, move ? FileOperation.MOVE : FileOperation.COPY); - eventCounter++; - assert.equal(e.correlationId, correlationId); - }); - - if (move) { - await accessor.workingCopyFileService.move(sourceModel.resource, targetModel.resource, true); - } else { - await accessor.workingCopyFileService.copy(sourceModel.resource, targetModel.resource, true); - } - - assert.equal(targetModel.textEditorModel!.getValue(), 'foo'); - - if (move) { - assert.ok(!accessor.textFileService.isDirty(sourceModel.resource)); - } else { - assert.ok(accessor.textFileService.isDirty(sourceModel.resource)); - } - assert.ok(accessor.textFileService.isDirty(targetModel.resource)); - - assert.equal(eventCounter, 3); - - sourceModel.dispose(); - targetModel.dispose(); - - participant.dispose(); - listener1.dispose(); - listener2.dispose(); - } - test('getDirty', async function () { const model1 = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file-1.txt'), 'utf8', undefined); - (accessor.textFileService.files).add(model.resource, model); + (accessor.textFileService.files).add(model1.resource, model1); const model2 = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file-2.txt'), 'utf8', undefined); - (accessor.textFileService.files).add(model.resource, model); + (accessor.textFileService.files).add(model2.resource, model2); let dirty = accessor.workingCopyFileService.getDirty(model1.resource); assert.equal(dirty.length, 0); @@ -190,7 +161,7 @@ suite('WorkingCopyFileService', () => { test('registerWorkingCopyProvider', async function () { const model1 = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file-1.txt'), 'utf8', undefined); - (accessor.textFileService.files).add(model.resource, model); + (accessor.textFileService.files).add(model1.resource, model1); await model1.load(); model1.textEditorModel!.setValue('foo'); @@ -212,4 +183,192 @@ suite('WorkingCopyFileService', () => { model1.dispose(); }); + + async function testEventsMoveOrCopy(files: { source: URI, target: URI }[], move?: boolean): Promise { + let eventCounter = 0; + + const participant = accessor.workingCopyFileService.addFileOperationParticipant({ + participate: async files => { + eventCounter++; + } + }); + + const listener1 = accessor.workingCopyFileService.onWillRunWorkingCopyFileOperation(e => { + eventCounter++; + }); + + const listener2 = accessor.workingCopyFileService.onDidRunWorkingCopyFileOperation(e => { + eventCounter++; + }); + + if (move) { + await accessor.workingCopyFileService.move(files, true); + } else { + await accessor.workingCopyFileService.copy(files, true); + } + + participant.dispose(); + listener1.dispose(); + listener2.dispose(); + return eventCounter; + } + + async function testMoveOrCopy(files: { source: URI, target: URI }[], move: boolean, targetDirty?: boolean): Promise { + + let eventCounter = 0; + const models = await Promise.all(files.map(async ({ source, target }, i) => { + let sourceModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, source, 'utf8', undefined); + let targetModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, target, 'utf8', undefined); + (accessor.textFileService.files).add(sourceModel.resource, sourceModel); + (accessor.textFileService.files).add(targetModel.resource, targetModel); + + await sourceModel.load(); + sourceModel.textEditorModel!.setValue('foo' + i); + assert.ok(accessor.textFileService.isDirty(sourceModel.resource)); + if (targetDirty) { + await targetModel.load(); + targetModel.textEditorModel!.setValue('bar' + i); + assert.ok(accessor.textFileService.isDirty(targetModel.resource)); + } + + return { sourceModel, targetModel }; + })); + + const participant = accessor.workingCopyFileService.addFileOperationParticipant({ + participate: async (files, operation) => { + for (let i = 0; i < files.length; i++) { + const { target, source } = files[i]; + const { targetModel, sourceModel } = models[i]; + + assert.equal(target.toString(), targetModel.resource.toString()); + assert.equal(source?.toString(), sourceModel.resource.toString()); + } + + eventCounter++; + + assert.equal(operation, move ? FileOperation.MOVE : FileOperation.COPY); + } + }); + + let correlationId: number; + + const listener1 = accessor.workingCopyFileService.onWillRunWorkingCopyFileOperation(e => { + for (let i = 0; i < e.files.length; i++) { + const { target, source } = files[i]; + const { targetModel, sourceModel } = models[i]; + + assert.equal(target.toString(), targetModel.resource.toString()); + assert.equal(source?.toString(), sourceModel.resource.toString()); + } + + eventCounter++; + + correlationId = e.correlationId; + assert.equal(e.operation, move ? FileOperation.MOVE : FileOperation.COPY); + }); + + const listener2 = accessor.workingCopyFileService.onDidRunWorkingCopyFileOperation(e => { + for (let i = 0; i < e.files.length; i++) { + const { target, source } = files[i]; + const { targetModel, sourceModel } = models[i]; + assert.equal(target.toString(), targetModel.resource.toString()); + assert.equal(source?.toString(), sourceModel.resource.toString()); + } + + eventCounter++; + + assert.equal(e.operation, move ? FileOperation.MOVE : FileOperation.COPY); + assert.equal(e.correlationId, correlationId); + }); + + if (move) { + await accessor.workingCopyFileService.move(models.map(m => ({ source: m.sourceModel.resource, target: m.targetModel.resource })), true); + } else { + await accessor.workingCopyFileService.copy(models.map(m => ({ source: m.sourceModel.resource, target: m.targetModel.resource })), true); + } + + for (let i = 0; i < models.length; i++) { + const { sourceModel, targetModel } = models[i]; + + assert.equal(targetModel.textEditorModel!.getValue(), 'foo' + i); + + if (move) { + assert.ok(!accessor.textFileService.isDirty(sourceModel.resource)); + } else { + assert.ok(accessor.textFileService.isDirty(sourceModel.resource)); + } + assert.ok(accessor.textFileService.isDirty(targetModel.resource)); + + sourceModel.dispose(); + targetModel.dispose(); + } + assert.equal(eventCounter, 3); + + participant.dispose(); + listener1.dispose(); + listener2.dispose(); + } + + async function testDelete(resources: URI[]) { + + const models = await Promise.all(resources.map(async resource => { + const model = instantiationService.createInstance(TextFileEditorModel, resource, 'utf8', undefined); + (accessor.textFileService.files).add(model.resource, model); + + await model.load(); + model!.textEditorModel!.setValue('foo'); + assert.ok(accessor.workingCopyService.isDirty(model.resource)); + return model; + })); + + let eventCounter = 0; + let correlationId: number | undefined = undefined; + + const participant = accessor.workingCopyFileService.addFileOperationParticipant({ + participate: async (files, operation) => { + for (let i = 0; i < models.length; i++) { + const model = models[i]; + const file = files[i]; + assert.equal(file.target.toString(), model.resource.toString()); + } + assert.equal(operation, FileOperation.DELETE); + eventCounter++; + } + }); + + const listener1 = accessor.workingCopyFileService.onWillRunWorkingCopyFileOperation(e => { + for (let i = 0; i < models.length; i++) { + const model = models[i]; + const file = e.files[i]; + assert.equal(file.target.toString(), model.resource.toString()); + } + assert.equal(e.operation, FileOperation.DELETE); + correlationId = e.correlationId; + eventCounter++; + }); + + const listener2 = accessor.workingCopyFileService.onDidRunWorkingCopyFileOperation(e => { + for (let i = 0; i < models.length; i++) { + const model = models[i]; + const file = e.files[i]; + assert.equal(file.target.toString(), model.resource.toString()); + } + assert.equal(e.operation, FileOperation.DELETE); + assert.equal(e.correlationId, correlationId); + eventCounter++; + }); + + await accessor.workingCopyFileService.delete(models.map(m => m.resource)); + for (const model of models) { + assert.ok(!accessor.workingCopyService.isDirty(model.resource)); + model.dispose(); + } + + assert.equal(eventCounter, 3); + + participant.dispose(); + listener1.dispose(); + listener2.dispose(); + } + }); diff --git a/src/vs/workbench/test/browser/api/mainThreadEditors.test.ts b/src/vs/workbench/test/browser/api/mainThreadEditors.test.ts index 306c6662332..3964d05782a 100644 --- a/src/vs/workbench/test/browser/api/mainThreadEditors.test.ts +++ b/src/vs/workbench/test/browser/api/mainThreadEditors.test.ts @@ -107,16 +107,20 @@ suite('MainThreadEditors', () => { }); services.set(IWorkingCopyFileService, new class extends mock() { onDidRunWorkingCopyFileOperation = Event.None; - move(source: URI, target: URI) { + move(files: { source: URI, target: URI }[]) { + const { source, target } = files[0]; movedResources.set(source, target); return Promise.resolve(Object.create(null)); } - copy(source: URI, target: URI) { + copy(files: { source: URI, target: URI }[]) { + const { source, target } = files[0]; copiedResources.set(source, target); return Promise.resolve(Object.create(null)); } - delete(resource: URI) { - deletedResources.add(resource); + delete(resources: URI[]) { + for (const resource of resources) { + deletedResources.add(resource); + } return Promise.resolve(undefined); } }); diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index 5ec62b286e5..30eab90aa6a 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -134,17 +134,17 @@ export class TestWorkingCopyFileService implements IWorkingCopyFileService { addFileOperationParticipant(participant: IWorkingCopyFileOperationParticipant): IDisposable { return Disposable.None; } - async runFileOperationParticipants(target: URI, source: URI | undefined, operation: FileOperation): Promise { } + async runFileOperationParticipants(files: { source?: URI, target: URI }[], operation: FileOperation): Promise { } - async delete(resource: URI, options?: { useTrash?: boolean | undefined; recursive?: boolean | undefined; } | undefined): Promise { } + async delete(resources: URI[], options?: { useTrash?: boolean | undefined; recursive?: boolean | undefined; } | undefined): Promise { } registerWorkingCopyProvider(provider: (resourceOrFolder: URI) => IWorkingCopy[]): IDisposable { return Disposable.None; } getDirty(resource: URI): IWorkingCopy[] { return []; } - move(source: URI, target: URI, overwrite?: boolean | undefined): Promise { throw new Error('Method not implemented.'); } + move(files: { source: URI; target: URI; }[], overwrite?: boolean | undefined): Promise { throw new Error('Method not implemented.'); } - copy(source: URI, target: URI, overwrite?: boolean | undefined): Promise { throw new Error('Method not implemented.'); } + copy(files: { source: URI; target: URI; }[], overwrite?: boolean | undefined): Promise { throw new Error('Method not implemented.'); } } export function mock(): Ctor {