diff --git a/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts b/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts index 38f3b5fdca9..8b5df403a18 100644 --- a/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts +++ b/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts @@ -15,6 +15,9 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ILogService } from 'vs/platform/log/common/log'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; +import { URI } from 'vs/base/common/uri'; +import { IWaitUntil } from 'vs/base/common/event'; @extHostCustomer export class MainThreadFileSystemEventService { @@ -28,6 +31,7 @@ export class MainThreadFileSystemEventService { @IProgressService progressService: IProgressService, @IConfigurationService configService: IConfigurationService, @ILogService logService: ILogService, + @IWorkingCopyFileService workingCopyFileService: IWorkingCopyFileService ) { const proxy = extHostContext.getProxy(ExtHostContext.ExtHostFileSystemEventService); @@ -66,9 +70,7 @@ export class MainThreadFileSystemEventService { messages.set(FileOperation.DELETE, localize('msg-delete', "Running 'File Delete' participants...")); messages.set(FileOperation.MOVE, localize('msg-rename', "Running 'File Rename' participants...")); - - this._listener.add(textFileService.onWillRunOperation(e => { - + function participateInFileOperation(e: IWaitUntil, operation: FileOperation, target: URI, source?: URI): void { const timeout = configService.getValue('files.participants.timeout'); if (timeout <= 0) { return; // disabled @@ -76,19 +78,19 @@ export class MainThreadFileSystemEventService { const p = progressService.withProgress({ location: ProgressLocation.Window }, progress => { - progress.report({ message: messages.get(e.operation) }); + progress.report({ message: messages.get(operation) }); return new Promise((resolve, reject) => { const cts = new CancellationTokenSource(); const timeoutHandle = setTimeout(() => { - logService.trace('CANCELLED file participants because of timeout', timeout, e.target, e.operation); + logService.trace('CANCELLED file participants because of timeout', timeout, target, operation); cts.cancel(); reject(new Error('timeout')); }, timeout); - proxy.$onWillRunFileOperation(e.operation, e.target, e.source, timeout, cts.token) + proxy.$onWillRunFileOperation(operation, target, source, timeout, cts.token) .then(resolve, reject) .finally(() => clearTimeout(timeoutHandle)); }); @@ -96,10 +98,14 @@ export class MainThreadFileSystemEventService { }); e.waitUntil(p); - })); + } + + this._listener.add(textFileService.onWillCreateTextFile(e => participateInFileOperation(e, FileOperation.CREATE, e.resource))); + this._listener.add(workingCopyFileService.onBeforeWorkingCopyFileOperation(e => participateInFileOperation(e, e.operation, e.target, e.source))); // AFTER file operation - this._listener.add(textFileService.onDidRunOperation(e => proxy.$onDidRunFileOperation(e.operation, e.target, e.source))); + 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))); } dispose(): void { diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index 43503864a71..3e989eb1c49 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -46,6 +46,7 @@ import { mnemonicButtonLabel } from 'vs/base/common/labels'; import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; import { IWorkingCopyService, IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopyService'; import { sequence } from 'vs/base/common/async'; +import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; export const NEW_FILE_COMMAND_ID = 'explorer.newFile'; export const NEW_FILE_LABEL = nls.localize('newFile', "New File"); @@ -144,7 +145,7 @@ export class GlobalNewUntitledFileAction extends Action { } } -async function deleteFiles(workingCopyService: IWorkingCopyService, textFileService: ITextFileService, dialogService: IDialogService, configurationService: IConfigurationService, elements: ExplorerItem[], useTrash: boolean, skipConfirm = false): Promise { +async function deleteFiles(workingCopyService: IWorkingCopyService, workingCopyFileService: IWorkingCopyFileService, dialogService: IDialogService, configurationService: IConfigurationService, elements: ExplorerItem[], useTrash: boolean, skipConfirm = false): Promise { let primaryButton: string; if (useTrash) { primaryButton = isWindows ? nls.localize('deleteButtonLabelRecycleBin', "&&Move to Recycle Bin") : nls.localize({ key: 'deleteButtonLabelTrash', comment: ['&& denotes a mnemonic'] }, "&&Move to Trash"); @@ -244,7 +245,7 @@ async function deleteFiles(workingCopyService: IWorkingCopyService, textFileServ // Call function try { - await Promise.all(distinctElements.map(e => textFileService.delete(e.resource, { useTrash: useTrash, recursive: true }))); + await Promise.all(distinctElements.map(e => workingCopyFileService.delete(e.resource, { useTrash: useTrash, recursive: true }))); } catch (error) { // Handle error to delete file(s) from a modal confirmation dialog @@ -274,7 +275,7 @@ async function deleteFiles(workingCopyService: IWorkingCopyService, textFileServ skipConfirm = true; - return deleteFiles(workingCopyService, textFileService, dialogService, configurationService, elements, useTrash, skipConfirm); + return deleteFiles(workingCopyService, workingCopyFileService, dialogService, configurationService, elements, useTrash, skipConfirm); } } } @@ -934,7 +935,7 @@ CommandsRegistry.registerCommand({ export const renameHandler = (accessor: ServicesAccessor) => { const explorerService = accessor.get(IExplorerService); - const textFileService = accessor.get(ITextFileService); + const workingCopyFileService = accessor.get(IWorkingCopyFileService); const notificationService = accessor.get(INotificationService); const stats = explorerService.getContext(false); @@ -951,7 +952,7 @@ export const renameHandler = (accessor: ServicesAccessor) => { const targetResource = resources.joinPath(parentResource, value); if (stat.resource.toString() !== targetResource.toString()) { try { - await textFileService.move(stat.resource, targetResource); + await workingCopyFileService.move(stat.resource, targetResource); refreshIfSeparator(value, explorerService); } catch (e) { notificationService.error(e); @@ -967,7 +968,7 @@ export const moveFileToTrashHandler = async (accessor: ServicesAccessor) => { const explorerService = accessor.get(IExplorerService); const stats = explorerService.getContext(true).filter(s => !s.isRoot); if (stats.length) { - await deleteFiles(accessor.get(IWorkingCopyService), accessor.get(ITextFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), stats, true); + await deleteFiles(accessor.get(IWorkingCopyService), accessor.get(IWorkingCopyFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), stats, true); } }; @@ -976,7 +977,7 @@ export const deleteFileHandler = async (accessor: ServicesAccessor) => { const stats = explorerService.getContext(true).filter(s => !s.isRoot); if (stats.length) { - await deleteFiles(accessor.get(IWorkingCopyService), accessor.get(ITextFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), stats, false); + await deleteFiles(accessor.get(IWorkingCopyService), accessor.get(IWorkingCopyFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), stats, false); } }; @@ -1002,7 +1003,7 @@ export const cutFileHandler = (accessor: ServicesAccessor) => { export const DOWNLOAD_COMMAND_ID = 'explorer.download'; const downloadFileHandler = (accessor: ServicesAccessor) => { const fileService = accessor.get(IFileService); - const textFileService = accessor.get(ITextFileService); + const workingCopyFileService = accessor.get(IWorkingCopyFileService); const fileDialogService = accessor.get(IFileDialogService); const explorerService = accessor.get(IExplorerService); const stats = explorerService.getContext(true); @@ -1037,7 +1038,7 @@ const downloadFileHandler = (accessor: ServicesAccessor) => { defaultUri }); if (destination) { - await textFileService.copy(s.resource, destination, true); + await workingCopyFileService.copy(s.resource, 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; @@ -1055,7 +1056,7 @@ export const pasteFileHandler = async (accessor: ServicesAccessor) => { const clipboardService = accessor.get(IClipboardService); const explorerService = accessor.get(IExplorerService); const fileService = accessor.get(IFileService); - const textFileService = accessor.get(ITextFileService); + const workingCopyFileService = accessor.get(IWorkingCopyFileService); const notificationService = accessor.get(INotificationService); const editorService = accessor.get(IEditorService); const configurationService = accessor.get(IConfigurationService); @@ -1087,9 +1088,9 @@ export const pasteFileHandler = async (accessor: ServicesAccessor) => { // Move/Copy File if (pasteShouldMove) { - return await textFileService.move(fileToPaste, targetFile); + return await workingCopyFileService.move(fileToPaste, targetFile); } else { - return await textFileService.copy(fileToPaste, targetFile); + 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)))); diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index 79913360a0a..ff97dc81a12 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -37,7 +37,7 @@ import { Schemas } from 'vs/base/common/network'; import { DesktopDragAndDropData, ExternalElementsDragAndDropData, ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView'; import { isMacintosh, isWeb } from 'vs/base/common/platform'; import { IDialogService, IConfirmation, getFileNamesMessage } from 'vs/platform/dialogs/common/dialogs'; -import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; import { IHostService } from 'vs/workbench/services/host/browser/host'; import { IWorkspaceEditingService } from 'vs/workbench/services/workspaces/common/workspaceEditing'; import { URI } from 'vs/base/common/uri'; @@ -641,7 +641,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { @IFileService private fileService: IFileService, @IConfigurationService private configurationService: IConfigurationService, @IInstantiationService private instantiationService: IInstantiationService, - @ITextFileService private textFileService: ITextFileService, + @IWorkingCopyFileService private workingCopyFileService: IWorkingCopyFileService, @IHostService private hostService: IHostService, @IWorkspaceEditingService private workspaceEditingService: IWorkspaceEditingService, @IWorkingCopyService private workingCopyService: IWorkingCopyService @@ -953,7 +953,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { } const copyTarget = joinPath(target.resource, basename(sourceFile)); - const stat = await this.textFileService.copy(sourceFile, copyTarget, true); + const stat = await this.workingCopyFileService.copy(sourceFile, copyTarget, true); // 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 } }); @@ -1040,7 +1040,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { // Reuse duplicate action if user copies if (isCopy) { const incrementalNaming = this.configurationService.getValue().explorer.incrementalNaming; - const stat = await this.textFileService.copy(source.resource, findValidPasteFileTarget(this.explorerService, target, { resource: source.resource, isDirectory: source.isDirectory, allowOverwrite: false }, 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 } }); } @@ -1056,7 +1056,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { } try { - await this.textFileService.move(source.resource, targetResource); + await this.workingCopyFileService.move(source.resource, targetResource); } catch (error) { // Conflict if ((error).fileOperationResult === FileOperationResult.FILE_MOVE_CONFLICT) { @@ -1065,7 +1065,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { const { confirmed } = await this.dialogService.confirm(confirm); if (confirmed) { try { - await this.textFileService.move(source.resource, targetResource, true /* overwrite */); + await this.workingCopyFileService.move(source.resource, targetResource, true /* overwrite */); } catch (error) { this.notificationService.error(error); } diff --git a/src/vs/workbench/services/bulkEdit/browser/bulkEditService.ts b/src/vs/workbench/services/bulkEdit/browser/bulkEditService.ts index 199f78af912..414a94ac93b 100644 --- a/src/vs/workbench/services/bulkEdit/browser/bulkEditService.ts +++ b/src/vs/workbench/services/bulkEdit/browser/bulkEditService.ts @@ -25,6 +25,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; type ValidationResult = { canApply: true } | { canApply: false, reason: URI }; @@ -237,6 +238,7 @@ class BulkEdit { @ILogService private readonly _logService: ILogService, @IFileService private readonly _fileService: IFileService, @ITextFileService private readonly _textFileService: ITextFileService, + @IWorkingCopyFileService private readonly _workingCopyFileService: IWorkingCopyFileService, @IConfigurationService private readonly _configurationService: IConfigurationService ) { this._editor = editor; @@ -309,7 +311,7 @@ class BulkEdit { if (options.overwrite === undefined && options.ignoreIfExists && await this._fileService.exists(edit.newUri)) { continue; // not overwriting, but ignoring, and the target file exists } - await this._textFileService.move(edit.oldUri, edit.newUri, options.overwrite); + await this._workingCopyFileService.move(edit.oldUri, edit.newUri, options.overwrite); } else if (!edit.newUri && edit.oldUri) { // delete file @@ -318,7 +320,7 @@ class BulkEdit { if (useTrash && !(this._fileService.hasCapability(edit.oldUri, FileSystemProviderCapabilities.Trash))) { useTrash = false; // not supported by provider } - await this._textFileService.delete(edit.oldUri, { useTrash, recursive: options.recursive }); + await this._workingCopyFileService.delete(edit.oldUri, { useTrash, recursive: options.recursive }); } else if (!options.ignoreIfNotExists) { throw new Error(`${edit.oldUri} does not exist and can not be deleted`); } diff --git a/src/vs/workbench/services/keybinding/test/electron-browser/keybindingEditing.test.ts b/src/vs/workbench/services/keybinding/test/electron-browser/keybindingEditing.test.ts index 924ac2bb3b1..0e60ab8f43f 100644 --- a/src/vs/workbench/services/keybinding/test/electron-browser/keybindingEditing.test.ts +++ b/src/vs/workbench/services/keybinding/test/electron-browser/keybindingEditing.test.ts @@ -51,6 +51,7 @@ import { TestWindowConfiguration, TestTextFileService } from 'vs/workbench/test/ import { ILabelService } from 'vs/platform/label/common/label'; import { LabelService } from 'vs/workbench/services/label/common/labelService'; import { IFilesConfigurationService, FilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; +import { WorkingCopyFileService, IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; class TestEnvironmentService extends NativeWorkbenchEnvironmentService { @@ -108,6 +109,8 @@ suite('KeybindingsEditing', () => { fileService.registerProvider(Schemas.file, diskFileSystemProvider); fileService.registerProvider(Schemas.userData, new FileUserDataProvider(environmentService.appSettingsHome, environmentService.backupHome, diskFileSystemProvider, environmentService)); instantiationService.stub(IFileService, fileService); + instantiationService.stub(IWorkingCopyService, new TestWorkingCopyService()); + instantiationService.stub(IWorkingCopyFileService, instantiationService.createInstance(WorkingCopyFileService)); instantiationService.stub(ITextFileService, instantiationService.createInstance(TestTextFileService)); instantiationService.stub(ITextModelService, instantiationService.createInstance(TextModelResolverService)); instantiationService.stub(IBackupFileService, new TestBackupFileService()); diff --git a/src/vs/workbench/services/textfile/browser/textFileService.ts b/src/vs/workbench/services/textfile/browser/textFileService.ts index 94a61318684..05a2d75dfcd 100644 --- a/src/vs/workbench/services/textfile/browser/textFileService.ts +++ b/src/vs/workbench/services/textfile/browser/textFileService.ts @@ -5,11 +5,11 @@ import * as nls from 'vs/nls'; import { URI } from 'vs/base/common/uri'; -import { Emitter, AsyncEmitter } from 'vs/base/common/event'; -import { IResult, ITextFileOperationResult, ITextFileService, ITextFileStreamContent, ITextFileEditorModel, ITextFileContent, IResourceEncodings, IReadTextFileOptions, IWriteTextFileOptions, toBufferOrReadable, TextFileOperationError, TextFileOperationResult, FileOperationWillRunEvent, FileOperationDidRunEvent, ITextFileSaveOptions, ITextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textfiles'; +import { AsyncEmitter } from 'vs/base/common/event'; +import { IResult, ITextFileOperationResult, ITextFileService, ITextFileStreamContent, ITextFileEditorModel, ITextFileContent, IResourceEncodings, IReadTextFileOptions, IWriteTextFileOptions, toBufferOrReadable, TextFileOperationError, TextFileOperationResult, ITextFileSaveOptions, ITextFileEditorModelManager, TextFileCreateEvent } from 'vs/workbench/services/textfile/common/textfiles'; import { IRevertOptions, IEncodingSupport } from 'vs/workbench/common/editor'; import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle'; -import { IFileService, FileOperationError, FileOperationResult, IFileStatWithMetadata, ICreateFileOptions, FileOperation } from 'vs/platform/files/common/files'; +import { IFileService, FileOperationError, FileOperationResult, IFileStatWithMetadata, ICreateFileOptions } from 'vs/platform/files/common/files'; import { Disposable } from 'vs/base/common/lifecycle'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { IUntitledTextEditorService, IUntitledTextEditorModelManager } from 'vs/workbench/services/untitled/common/untitledTextEditorService'; @@ -20,7 +20,7 @@ import { ResourceMap } from 'vs/base/common/map'; import { Schemas } from 'vs/base/common/network'; import { createTextBufferFactoryFromSnapshot, createTextBufferFactoryFromStream } from 'vs/editor/common/model/textModel'; import { IModelService } from 'vs/editor/common/services/modelService'; -import { isEqualOrParent, isEqual, joinPath, dirname, basename, toLocalResource } from 'vs/base/common/resources'; +import { isEqual, joinPath, dirname, basename, toLocalResource } from 'vs/base/common/resources'; import { IDialogService, IFileDialogService, IConfirmation } from 'vs/platform/dialogs/common/dialogs'; import { VSBuffer } from 'vs/base/common/buffer'; import { ITextSnapshot, ITextModel } from 'vs/editor/common/model'; @@ -45,11 +45,11 @@ export abstract class AbstractTextFileService extends Disposable implements ITex //#region events - private _onWillRunOperation = this._register(new AsyncEmitter()); - readonly onWillRunOperation = this._onWillRunOperation.event; + private _onWillCreateTextFile = this._register(new AsyncEmitter()); + readonly onWillCreateTextFile = this._onWillCreateTextFile.event; - private _onDidRunOperation = this._register(new Emitter()); - readonly onDidRunOperation = this._onDidRunOperation.event; + private _onDidCreateTextFile = this._register(new AsyncEmitter()); + readonly onDidCreateTextFile = this._onDidCreateTextFile.event; //#endregion @@ -85,7 +85,7 @@ export abstract class AbstractTextFileService extends Disposable implements ITex this.lifecycleService.onShutdown(this.dispose, this); } - //#region text file read / write + //#region text file read / write / create async read(resource: URI, options?: IReadTextFileOptions): Promise { const content = await this.fileService.readFile(resource, options); @@ -141,19 +141,12 @@ export abstract class AbstractTextFileService extends Disposable implements ITex } } - async write(resource: URI, value: string | ITextSnapshot, options?: IWriteTextFileOptions): Promise { - return this.fileService.writeFile(resource, toBufferOrReadable(value), options); - } - - //#endregion - - //#region text file IO primitives (create, move, copy, delete) - async create(resource: URI, value?: string | ITextSnapshot, options?: ICreateFileOptions): Promise { // before event - await this._onWillRunOperation.fireAsync({ operation: FileOperation.CREATE, target: resource }, CancellationToken.None); + await this._onWillCreateTextFile.fireAsync({ resource }, CancellationToken.None); + // create file on disk const stat = await this.doCreate(resource, value, options); // If we had an existing model for the given resource, load @@ -166,7 +159,7 @@ export abstract class AbstractTextFileService extends Disposable implements ITex } // after event - this._onDidRunOperation.fire(new FileOperationDidRunEvent(FileOperation.CREATE, resource)); + await this._onDidCreateTextFile.fireAsync({ resource }, CancellationToken.None); return stat; } @@ -175,127 +168,13 @@ export abstract class AbstractTextFileService extends Disposable implements ITex return this.fileService.createFile(resource, toBufferOrReadable(value), options); } - async move(source: URI, target: URI, overwrite?: boolean): Promise { - return this.moveOrCopy(source, target, true, overwrite); - } - - async copy(source: URI, target: URI, overwrite?: boolean): Promise { - return this.moveOrCopy(source, target, false, overwrite); - } - - private async moveOrCopy(source: URI, target: URI, move: boolean, overwrite?: boolean): Promise { - - // before event - await this._onWillRunOperation.fireAsync({ operation: move ? FileOperation.MOVE : FileOperation.COPY, target, source }, CancellationToken.None); - - // find all models that related to either source or target (can be many if resource is a folder) - const sourceModels: ITextFileEditorModel[] = []; - const targetModels: ITextFileEditorModel[] = []; - for (const model of this.getFileModels()) { - const resource = model.resource; - - if (isEqualOrParent(resource, target, false /* do not ignorecase, see https://github.com/Microsoft/vscode/issues/56384 */)) { - targetModels.push(model); - } - - if (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 - type ModelToRestore = { resource: URI; snapshot?: ITextSnapshot; encoding?: string; mode?: string }; - const modelsToRestore: ModelToRestore[] = []; - for (const sourceModel of sourceModels) { - const sourceModelResource = sourceModel.resource; - - // If the source is the actual model, just use target as new resource - let modelToRestoreResource: URI; - if (isEqual(sourceModelResource, source)) { - modelToRestoreResource = target; - } - - // Otherwise a parent folder of the source is being moved, so we need - // to compute the target resource based on that - else { - modelToRestoreResource = joinPath(target, sourceModelResource.path.substr(source.path.length + 1)); - } - - const modelToRestore: ModelToRestore = { resource: modelToRestoreResource, encoding: sourceModel.getEncoding() }; - if (sourceModel.isDirty()) { - modelToRestore.snapshot = sourceModel.createSnapshot(); - } - - modelsToRestore.push(modelToRestore); - } - - // handle dirty models depending on the operation: - // - move: revert both source and target (if any) - // - copy: revert target (if any) - const dirtyModelsToRevert = (move ? [...sourceModels, ...targetModels] : [...targetModels]).filter(model => model.isDirty()); - await this.doRevertFiles(dirtyModelsToRevert.map(dirtyModel => dirtyModel.resource), { soft: true }); - - // 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); - } - } catch (error) { - - // in case of any error, ensure to set dirty flag back - dirtyModelsToRevert.forEach(dirtyModel => dirtyModel.setDirty(true)); - - throw error; - } - - // finally, restore models that we had loaded previously - await Promise.all(modelsToRestore.map(async modelToRestore => { - - // restore the model, forcing a reload. this is important because - // we know the file has changed on disk after the move and the - // model might have still existed with the previous state. this - // ensures we are not tracking a stale state. - const restoredModel = await this.files.resolve(modelToRestore.resource, { reload: { async: false }, encoding: modelToRestore.encoding, mode: modelToRestore.mode }); - - // restore previous dirty content if any and ensure to mark - // the model as dirty - if (modelToRestore.snapshot && restoredModel.isResolved()) { - this.modelService.updateModel(restoredModel.textEditorModel, createTextBufferFactoryFromSnapshot(modelToRestore.snapshot)); - - restoredModel.setDirty(true); - } - })); - - // after event - this._onDidRunOperation.fire(new FileOperationDidRunEvent(move ? FileOperation.MOVE : FileOperation.COPY, target, source)); - - return stat; - } - - async delete(resource: URI, options?: { useTrash?: boolean, recursive?: boolean }): Promise { - - // before event - await this._onWillRunOperation.fireAsync({ operation: FileOperation.DELETE, target: resource }, CancellationToken.None); - - // Check for any existing dirty file model for the resource - // and do a soft revert before deleting to be able to close - // any opened editor with these files - const dirtyFiles = this.getDirtyFileModels().map(dirtyFileModel => dirtyFileModel.resource).filter(dirty => isEqualOrParent(dirty, resource)); - await this.doRevertFiles(dirtyFiles, { soft: true }); - - // Now actually delete from disk - await this.fileService.del(resource, options); - - // after event - this._onDidRunOperation.fire(new FileOperationDidRunEvent(FileOperation.DELETE, resource)); + async write(resource: URI, value: string | ITextSnapshot, options?: IWriteTextFileOptions): Promise { + return this.fileService.writeFile(resource, toBufferOrReadable(value), options); } //#endregion + //#region save async save(resource: URI, options?: ITextFileSaveOptions): Promise { diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts index a3a6cdba937..accb590b32b 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts @@ -13,7 +13,7 @@ import { ITextFileEditorModel, ITextFileEditorModelManager, IModelLoadOrCreateOp import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ResourceMap } from 'vs/base/common/map'; -import { IFileService, FileChangesEvent } from 'vs/platform/files/common/files'; +import { IFileService, FileChangesEvent, FileOperation } from 'vs/platform/files/common/files'; import { distinct, coalesce } from 'vs/base/common/arrays'; import { ResourceQueue } from 'vs/base/common/async'; import { onUnexpectedError } from 'vs/base/common/errors'; @@ -21,6 +21,11 @@ import { TextFileSaveParticipant } from 'vs/workbench/services/textfile/common/t import { SaveReason } from 'vs/workbench/common/editor'; import { CancellationToken } from 'vs/base/common/cancellation'; import { INotificationService } from 'vs/platform/notification/common/notification'; +import { IWorkingCopyFileService, WorkingCopyFileEvent } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; +import { ITextSnapshot } from 'vs/editor/common/model'; +import { joinPath, isEqualOrParent, isEqual } from 'vs/base/common/resources'; +import { createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel'; +import { IModelService } from 'vs/editor/common/services/modelService'; export class TextFileEditorModelManager extends Disposable implements ITextFileEditorModelManager { @@ -66,7 +71,9 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE @ILifecycleService private readonly lifecycleService: ILifecycleService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IFileService private readonly fileService: IFileService, - @INotificationService private readonly notificationService: INotificationService + @INotificationService private readonly notificationService: INotificationService, + @IWorkingCopyFileService private readonly workingCopyFileService: IWorkingCopyFileService, + @IModelService private readonly modelService: IModelService ) { super(); @@ -78,6 +85,11 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE // Update models from file change events this._register(this.fileService.onFileChanges(e => this.onFileChanges(e))); + // Working copy operations + this._register(this.workingCopyFileService.onWillRunWorkingCopyFileOperation(e => this.onWillRunWorkingCopyFileOperation(e))); + this._register(this.workingCopyFileService.onDidFailWorkingCopyFileOperation(e => this.onDidFailWorkingCopyFileOperation(e))); + this._register(this.workingCopyFileService.onDidRunWorkingCopyFileOperation(e => this.onDidRunWorkingCopyFileOperation(e))); + // Lifecycle this.lifecycleService.onShutdown(this.dispose, this); } @@ -111,6 +123,106 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE } } + private readonly mapCorrelationIdToModelsToRestore = new Map(); + + 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)) { + + // find all models that related to either source or target (can be many if resource is a folder) + const sourceModels: ITextFileEditorModel[] = []; + const targetModels: ITextFileEditorModel[] = []; + for (const model of this.getAll()) { + const resource = model.resource; + + if (isEqualOrParent(resource, e.target, false /* do not ignorecase, see https://github.com/Microsoft/vscode/issues/56384 */)) { + targetModels.push(model); + } + + if (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; encoding?: string; mode?: 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 (isEqual(sourceModelResource, e.source)) { + targetModelResource = e.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(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); + } + } + + private onDidFailWorkingCopyFileOperation(e: WorkingCopyFileEvent): void { + + // Move / Copy: restore dirty flag on models to restore that were dirty + if ((e.operation === FileOperation.COPY || e.operation === FileOperation.MOVE)) { + const modelsToRestore = this.mapCorrelationIdToModelsToRestore.get(e.correlationId); + if (modelsToRestore) { + this.mapCorrelationIdToModelsToRestore.delete(e.correlationId); + + modelsToRestore.forEach(model => { + // snapshot presence means this model used to be dirty + if (model.snapshot) { + this.get(model.source)?.setDirty(true); + } + }); + } + } + } + + private onDidRunWorkingCopyFileOperation(e: WorkingCopyFileEvent): void { + + // Move / Copy: restore models that were loaded before the operation took place + if ((e.operation === FileOperation.COPY || e.operation === FileOperation.MOVE)) { + e.waitUntil((async () => { + const modelsToRestore = this.mapCorrelationIdToModelsToRestore.get(e.correlationId); + if (modelsToRestore) { + this.mapCorrelationIdToModelsToRestore.delete(e.correlationId); + + await Promise.all(modelsToRestore.map(async modelToRestore => { + + // restore the model, forcing a reload. this is important because + // we know the file has changed on disk after the move and the + // model might have still existed with the previous state. this + // ensures we are not tracking a stale state. + const restoredModel = await this.resolve(modelToRestore.target, { reload: { async: false }, encoding: modelToRestore.encoding, mode: modelToRestore.mode }); + + // restore previous dirty content if any and ensure to mark + // the model as dirty + if (modelToRestore.snapshot && restoredModel.isResolved()) { + this.modelService.updateModel(restoredModel.textEditorModel, createTextBufferFactoryFromSnapshot(modelToRestore.snapshot)); + } + })); + } + })()); + } + } + get(resource: URI): ITextFileEditorModel | undefined { return this.mapResourceToModel.get(resource); } diff --git a/src/vs/workbench/services/textfile/common/textfiles.ts b/src/vs/workbench/services/textfile/common/textfiles.ts index c9532133e00..d9e119946a0 100644 --- a/src/vs/workbench/services/textfile/common/textfiles.ts +++ b/src/vs/workbench/services/textfile/common/textfiles.ts @@ -7,7 +7,7 @@ import { URI } from 'vs/base/common/uri'; import { Event, IWaitUntil } from 'vs/base/common/event'; import { IDisposable } from 'vs/base/common/lifecycle'; import { IEncodingSupport, IModeSupport, ISaveOptions, IRevertOptions, SaveReason } from 'vs/workbench/common/editor'; -import { IBaseStatWithMetadata, IFileStatWithMetadata, IReadFileOptions, IWriteFileOptions, FileOperationError, FileOperationResult, FileOperation } from 'vs/platform/files/common/files'; +import { IBaseStatWithMetadata, IFileStatWithMetadata, IReadFileOptions, IWriteFileOptions, FileOperationError, FileOperationResult } from 'vs/platform/files/common/files'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ITextEditorModel } from 'vs/editor/common/services/resolverService'; import { ITextBufferFactory, ITextModel, ITextSnapshot } from 'vs/editor/common/model'; @@ -21,20 +21,14 @@ import { IProgress, IProgressStep } from 'vs/platform/progress/common/progress'; export const ITextFileService = createDecorator('textFileService'); +export interface TextFileCreateEvent extends IWaitUntil { + readonly resource: URI; +} + export interface ITextFileService extends IDisposable { _serviceBrand: undefined; - /** - * An event that is fired before attempting a certain file operation. - */ - readonly onWillRunOperation: Event; - - /** - * An event that is fired after a file operation has been performed. - */ - readonly onDidRunOperation: Event; - /** * Access to the manager of text file editor models providing further * methods to work with them. @@ -101,41 +95,21 @@ export interface ITextFileService extends IDisposable { */ write(resource: URI, value: string | ITextSnapshot, options?: IWriteTextFileOptions): Promise; + /** + * An event that is fired before attempting to create a text file. + */ + readonly onWillCreateTextFile: Event; + + /** + * An event that is fired after a text file has been created. + */ + readonly onDidCreateTextFile: Event; + /** * Create a file. If the file exists it will be overwritten with the contents if * the options enable to overwrite. */ create(resource: URI, contents?: string | ITextSnapshot, options?: { overwrite?: boolean }): Promise; - - /** - * Move a file. If the file is dirty, its contents will be preserved and restored. - */ - move(source: URI, target: URI, overwrite?: boolean): Promise; - - /** - * Copy a file. If the file is dirty, its contents will be preserved and restored. - */ - copy(source: URI, target: URI, overwrite?: boolean): Promise; - - /** - * Delete a file. If the file is dirty, it will get reverted and then deleted from disk. - */ - delete(resource: URI, options?: { useTrash?: boolean, recursive?: boolean }): Promise; -} - -export interface FileOperationWillRunEvent extends IWaitUntil { - operation: FileOperation; - target: URI; - source?: URI; -} - -export class FileOperationDidRunEvent { - - constructor( - readonly operation: FileOperation, - readonly target: URI, - readonly source?: URI | undefined - ) { } } export interface IReadTextFileOptions extends IReadFileOptions { 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 d7191832368..9c613755fa5 100644 --- a/src/vs/workbench/services/textfile/test/browser/textFileService.test.ts +++ b/src/vs/workbench/services/textfile/test/browser/textFileService.test.ts @@ -2,8 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import * as assert from 'assert'; -import { URI } from 'vs/base/common/uri'; import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle'; import { TestLifecycleService, TestContextService, TestFileService, TestFilesConfigurationService, TestFileDialogService, TestTextFileService, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; import { toResource } from 'vs/base/test/common/utils'; @@ -17,11 +17,13 @@ import { IModelService } from 'vs/editor/common/services/modelService'; import { ModelServiceImpl } from 'vs/editor/common/services/modelServiceImpl'; import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; class ServiceAccessor { constructor( @ILifecycleService public lifecycleService: TestLifecycleService, @ITextFileService public textFileService: TestTextFileService, + @IWorkingCopyFileService public workingCopyFileService: IWorkingCopyFileService, @IFilesConfigurationService public filesConfigurationService: TestFilesConfigurationService, @IWorkspaceContextService public contextService: TestContextService, @IModelService public modelService: ModelServiceImpl, @@ -43,9 +45,7 @@ suite('Files - TextFileService', () => { }); teardown(() => { - if (model) { - model.dispose(); - } + model?.dispose(); (accessor.textFileService.files).dispose(); }); @@ -133,71 +133,21 @@ suite('Files - TextFileService', () => { model!.textEditorModel!.setValue('foo'); assert.ok(accessor.textFileService.isDirty(model.resource)); + let eventCounter = 0; + + accessor.textFileService.onWillCreateTextFile(e => { + assert.equal(e.resource.toString(), model.resource.toString()); + eventCounter++; + }); + + accessor.textFileService.onDidCreateTextFile(e => { + assert.equal(e.resource.toString(), model.resource.toString()); + eventCounter++; + }); await accessor.textFileService.create(model.resource, 'Foo'); assert.ok(!accessor.textFileService.isDirty(model.resource)); + + assert.equal(eventCounter, 2); }); - - 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 model.load(); - model!.textEditorModel!.setValue('foo'); - assert.ok(accessor.textFileService.isDirty(model.resource)); - - await accessor.textFileService.delete(model.resource); - assert.ok(!accessor.textFileService.isDirty(model.resource)); - }); - - test('move - dirty file', async function () { - await testMoveOrCopy(toResource.call(this, '/path/file.txt'), toResource.call(this, '/path/file_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); - }); - - test('copy - dirty file', async function () { - await testMoveOrCopy(toResource.call(this, '/path/file.txt'), toResource.call(this, '/path/file_target.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); - }); - - 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)); - } - - if (move) { - await accessor.textFileService.move(sourceModel.resource, targetModel.resource, true); - } else { - await accessor.textFileService.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)); - - sourceModel.dispose(); - targetModel.dispose(); - } }); diff --git a/src/vs/workbench/services/workingCopy/common/workingCopyFileService.ts b/src/vs/workbench/services/workingCopy/common/workingCopyFileService.ts new file mode 100644 index 00000000000..166cb52c36e --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/workingCopyFileService.ts @@ -0,0 +1,237 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { Event, AsyncEmitter, IWaitUntil } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IFileService, FileOperation, IFileStatWithMetadata } from 'vs/platform/files/common/files'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IWorkingCopyService, IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { isEqualOrParent, isEqual } from 'vs/base/common/resources'; + +export const IWorkingCopyFileService = createDecorator('workingCopyFileService'); + +export interface WorkingCopyFileEvent extends IWaitUntil { + + /** + * An identifier to correlate the operation through the + * different event types (before, after, error). + */ + readonly correlationId: number; + + /** + * The file operation that is taking place. + */ + readonly operation: FileOperation; + + /** + * The resource the event is about. + */ + readonly target: URI; + + /** + * A property that is defined for move operations. + */ + readonly source?: URI; +} + +/** + * A service that allows to perform file operations with working copy support. + * Any operation that would leave a stale dirty working copy behind will make + * sure to revert the working copy first. + * + * On top of that events are provided to participate in each state of the + * operation to perform additional work. + */ +export interface IWorkingCopyFileService { + + _serviceBrand: undefined; + + //#region Events + + /** + * An event that is fired before attempting a certain working copy IO operation. + * + * Participants can join this event with a long running operation to make changes + * to the working copy before the operation starts. + */ + readonly onBeforeWorkingCopyFileOperation: Event; + + /** + * An event that is fired when a certain working copy IO operation is about to run. + * + * Participants can join this event with a long running operation to keep some state + * before the operation is started, but working copies should not be changed at this + * point in time. + */ + readonly onWillRunWorkingCopyFileOperation: Event; + + /** + * An event that is fired after a working copy IO operation has failed. + * + * Participants can join this event with a long running operation to clean up as needed. + */ + readonly onDidFailWorkingCopyFileOperation: Event; + + /** + * An event that is fired after a working copy IO operation has been performed. + * + * Participants can join this event with a long running operation to make changes + * after the operation has finished. + */ + readonly onDidRunWorkingCopyFileOperation: Event; + + //#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. + * + * Working copy owners can listen to the `onWillRunWorkingCopyFileOperation` and + * `onDidRunWorkingCopyFileOperation` events to participate. + */ + move(source: URI, target: URI, overwrite?: boolean): Promise; + + /** + * Will copy working copies matching the provided resource and children + * to the target using the associated file service for that resource. + * + * Working copy owners can listen to the `onWillRunWorkingCopyFileOperation` and + * `onDidRunWorkingCopyFileOperation` events to participate. + */ + copy(source: URI, target: URI, overwrite?: boolean): Promise; + + /** + * Will delete working copies matching the provided resource and children + * using the associated file service for that resource. + * + * Working copy owners can listen to the `onWillRunWorkingCopyFileOperation` and + * `onDidRunWorkingCopyFileOperation` events to participate. + */ + delete(resource: URI, options?: { useTrash?: boolean, recursive?: boolean }): Promise; + + //#endregion +} + +export class WorkingCopyFileService extends Disposable implements IWorkingCopyFileService { + + _serviceBrand: undefined; + + //#region Events + + private readonly _onBeforeWorkingCopyFileOperation = this._register(new AsyncEmitter()); + readonly onBeforeWorkingCopyFileOperation = this._onBeforeWorkingCopyFileOperation.event; + + private readonly _onWillRunWorkingCopyFileOperation = this._register(new AsyncEmitter()); + readonly onWillRunWorkingCopyFileOperation = this._onWillRunWorkingCopyFileOperation.event; + + private readonly _onDidFailWorkingCopyFileOperation = this._register(new AsyncEmitter()); + readonly onDidFailWorkingCopyFileOperation = this._onDidFailWorkingCopyFileOperation.event; + + private readonly _onDidRunWorkingCopyFileOperation = this._register(new AsyncEmitter()); + readonly onDidRunWorkingCopyFileOperation = this._onDidRunWorkingCopyFileOperation.event; + + //#endregion + + private correlationIds = 0; + + constructor( + @IFileService private fileService: IFileService, + @IWorkingCopyService private workingCopyService: IWorkingCopyService + ) { + super(); + } + + async move(source: URI, target: URI, overwrite?: boolean): Promise { + return this.moveOrCopy(source, target, true, overwrite); + } + + async copy(source: URI, target: URI, overwrite?: boolean): Promise { + return this.moveOrCopy(source, target, false, overwrite); + } + + private async moveOrCopy(source: URI, target: URI, move: boolean, overwrite?: boolean): Promise { + const event = { correlationId: this.correlationIds++, operation: move ? FileOperation.MOVE : FileOperation.COPY, target, source }; + + // before events + await this._onBeforeWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); + await this._onWillRunWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); + + // 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.getDirtyWorkingCopies(source), ...this.getDirtyWorkingCopies(target)] : this.getDirtyWorkingCopies(target)); + await Promise.all(dirtyWorkingCopies.map(dirtyWorkingCopy => dirtyWorkingCopy.revert({ soft: true }))); + + // 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); + } + } catch (error) { + + // error event + await this._onDidFailWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); + + throw error; + } + + // after event + await this._onDidRunWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); + + return stat; + } + + async delete(resource: URI, options?: { useTrash?: boolean, recursive?: boolean }): Promise { + const event = { correlationId: this.correlationIds++, operation: FileOperation.DELETE, target: resource }; + + // before events + await this._onBeforeWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); + 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.getDirtyWorkingCopies(resource); + await Promise.all(dirtyWorkingCopies.map(dirtyWorkingCopy => dirtyWorkingCopy.revert({ soft: true }))); + + // Now actually delete from disk + try { + await this.fileService.del(resource, options); + } catch (error) { + + // error event + await this._onDidFailWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); + + throw error; + } + + // after event + await this._onDidRunWorkingCopyFileOperation.fireAsync(event, CancellationToken.None); + } + + private getDirtyWorkingCopies(resource: URI): IWorkingCopy[] { + return this.workingCopyService.dirtyWorkingCopies.filter(dirty => { + if (this.fileService.canHandleResource(resource)) { + // only check for parents if the resource can be handled + // by the file system where we then assume a folder like + // path structure + return isEqualOrParent(dirty.resource, resource); + } + + return isEqual(dirty.resource, resource); + }); + } +} + +registerSingleton(IWorkingCopyFileService, WorkingCopyFileService, true); diff --git a/src/vs/workbench/services/workingCopy/test/browser/workingCopyFileService.test.ts b/src/vs/workbench/services/workingCopy/test/browser/workingCopyFileService.test.ts new file mode 100644 index 00000000000..91d93b59e98 --- /dev/null +++ b/src/vs/workbench/services/workingCopy/test/browser/workingCopyFileService.test.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { TextFileEditorModel } from 'vs/workbench/services/textfile/common/textFileEditorModel'; +import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { toResource } from 'vs/base/test/common/utils'; +import { workbenchInstantiationService, TestTextFileService } from 'vs/workbench/test/browser/workbenchTestServices'; +import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; +import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { URI } from 'vs/base/common/uri'; +import { FileOperation } from 'vs/platform/files/common/files'; + +class ServiceAccessor { + constructor( + @ITextFileService public textFileService: TestTextFileService, + @IWorkingCopyFileService public workingCopyFileService: IWorkingCopyFileService, + @IWorkingCopyService public workingCopyService: IWorkingCopyService + ) { + } +} + +suite('WorkingCopyFileService', () => { + + let instantiationService: IInstantiationService; + let model: TextFileEditorModel; + let accessor: ServiceAccessor; + + setup(() => { + instantiationService = workbenchInstantiationService(); + accessor = instantiationService.createInstance(ServiceAccessor); + }); + + 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 model.load(); + model!.textEditorModel!.setValue('foo'); + assert.ok(accessor.workingCopyService.isDirty(model.resource)); + + let eventCounter = 0; + + const listener0 = accessor.workingCopyFileService.onBeforeWorkingCopyFileOperation(e => { + assert.equal(e.target.toString(), model.resource.toString()); + assert.equal(e.operation, FileOperation.DELETE); + eventCounter++; + }); + + const listener1 = accessor.workingCopyFileService.onWillRunWorkingCopyFileOperation(e => { + assert.equal(e.target.toString(), model.resource.toString()); + assert.equal(e.operation, FileOperation.DELETE); + eventCounter++; + }); + + const listener2 = accessor.workingCopyFileService.onDidRunWorkingCopyFileOperation(e => { + assert.equal(e.target.toString(), model.resource.toString()); + assert.equal(e.operation, FileOperation.DELETE); + eventCounter++; + }); + + await accessor.workingCopyFileService.delete(model.resource); + assert.ok(!accessor.workingCopyService.isDirty(model.resource)); + + assert.equal(eventCounter, 3); + + listener0.dispose(); + listener1.dispose(); + listener2.dispose(); + }); + + test('move - dirty file', async function () { + await testMoveOrCopy(toResource.call(this, '/path/file.txt'), toResource.call(this, '/path/file_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); + }); + + test('copy - dirty file', async function () { + await testMoveOrCopy(toResource.call(this, '/path/file.txt'), toResource.call(this, '/path/file_target.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); + }); + + 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; + + const listener0 = accessor.workingCopyFileService.onBeforeWorkingCopyFileOperation(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++; + }); + + 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++; + }); + + 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++; + }); + + 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(); + + listener0.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 dedc04cf8d3..a318ecd50c9 100644 --- a/src/vs/workbench/test/browser/api/mainThreadEditors.test.ts +++ b/src/vs/workbench/test/browser/api/mainThreadEditors.test.ts @@ -40,6 +40,7 @@ import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { ILabelService } from 'vs/platform/label/common/label'; +import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; suite('MainThreadEditors', () => { @@ -79,14 +80,17 @@ suite('MainThreadEditors', () => { services.set(IEditorGroupsService, new TestEditorGroupsService()); services.set(ITextFileService, new class extends mock() { isDirty() { return false; } - create(uri: URI, contents?: string, options?: any) { - createdResources.add(uri); + create(resource: URI) { + createdResources.add(resource); return Promise.resolve(Object.create(null)); } - delete(resource: URI) { - deletedResources.add(resource); - return Promise.resolve(undefined); - } + files = { + onDidSave: Event.None, + onDidRevert: Event.None, + onDidChangeDirty: Event.None + }; + }); + services.set(IWorkingCopyFileService, new class extends mock() { move(source: URI, target: URI) { movedResources.set(source, target); return Promise.resolve(Object.create(null)); @@ -95,11 +99,10 @@ suite('MainThreadEditors', () => { copiedResources.set(source, target); return Promise.resolve(Object.create(null)); } - files = { - onDidSave: Event.None, - onDidRevert: Event.None, - onDidChangeDirty: Event.None - }; + delete(resource: URI) { + deletedResources.add(resource); + return Promise.resolve(undefined); + } }); services.set(ITextModelService, new class extends mock() { createModelReference(resource: URI): Promise> { diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 50efaef1f8a..d8d22a8db68 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -90,6 +90,7 @@ import { createTextBufferFactoryFromStream } from 'vs/editor/common/model/textMo import { IRemotePathService } from 'vs/workbench/services/path/common/remotePathService'; import { Direction } from 'vs/base/browser/ui/grid/grid'; import { IProgressService, IProgressOptions, IProgressWindowOptions, IProgressNotificationOptions, IProgressCompositeOptions, IProgress, IProgressStep, emptyProgress } from 'vs/platform/progress/common/progress'; +import { IWorkingCopyFileService, WorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; export import TestTextResourcePropertiesService = CommonWorkbenchTestServices.TestTextResourcePropertiesService; export import TestContextService = CommonWorkbenchTestServices.TestContextService; @@ -171,6 +172,7 @@ export const TestEnvironmentService = new BrowserWorkbenchEnvironmentService(Obj export function workbenchInstantiationService(overrides?: { textFileService?: (instantiationService: IInstantiationService) => ITextFileService }): ITestInstantiationService { const instantiationService = new TestInstantiationService(new ServiceCollection([ILifecycleService, new TestLifecycleService()])); + instantiationService.stub(IWorkingCopyService, new TestWorkingCopyService()); instantiationService.stub(IEnvironmentService, TestEnvironmentService); const contextKeyService = instantiationService.createInstance(MockContextKeyService); instantiationService.stub(IContextKeyService, contextKeyService); @@ -200,6 +202,7 @@ export function workbenchInstantiationService(overrides?: { textFileService?: (i instantiationService.stub(IKeybindingService, new MockKeybindingService()); instantiationService.stub(IDecorationsService, new TestDecorationsService()); instantiationService.stub(IExtensionService, new TestExtensionService()); + instantiationService.stub(IWorkingCopyFileService, instantiationService.createInstance(WorkingCopyFileService)); instantiationService.stub(ITextFileService, overrides?.textFileService ? overrides.textFileService(instantiationService) : instantiationService.createInstance(TestTextFileService)); instantiationService.stub(IHostService, instantiationService.createInstance(TestHostService)); instantiationService.stub(ITextModelService, instantiationService.createInstance(TextModelResolverService)); @@ -212,7 +215,6 @@ export function workbenchInstantiationService(overrides?: { textFileService?: (i instantiationService.stub(IEditorService, editorService); instantiationService.stub(ICodeEditorService, new TestCodeEditorService()); instantiationService.stub(IViewletService, new TestViewletService()); - instantiationService.stub(IWorkingCopyService, new TestWorkingCopyService()); return instantiationService; } diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 1e943601b66..b418c5b790e 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -83,6 +83,7 @@ import 'vs/workbench/services/userDataSync/common/userDataSyncUtil'; import 'vs/workbench/services/path/common/remotePathService'; import 'vs/workbench/services/remote/common/remoteExplorerService'; import 'vs/workbench/services/workingCopy/common/workingCopyService'; +import 'vs/workbench/services/workingCopy/common/workingCopyFileService'; import 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; import 'vs/workbench/services/views/browser/viewDescriptorService';