file working copy - implement save participant support

This commit is contained in:
Benjamin Pasero 2021-08-09 16:53:07 +02:00
parent c0f739ea25
commit 28d7c49801
No known key found for this signature in database
GPG key ID: E6380CC4C8219E65
9 changed files with 178 additions and 31 deletions

View file

@ -167,8 +167,8 @@ export class FileWorkingCopyManager<S extends IStoredFileWorkingCopyModel, U ext
this.workingCopyTypeId,
this.storedWorkingCopyModelFactory,
fileService, lifecycleService, labelService, logService, workingCopyFileService,
workingCopyBackupService, uriIdentityService, textFileService, filesConfigurationService,
workingCopyService, notificationService, workingCopyEditorService, editorService, elevatedFileService
workingCopyBackupService, uriIdentityService, filesConfigurationService, workingCopyService,
notificationService, workingCopyEditorService, editorService, elevatedFileService
));
// Untitled file working copies manager

View file

@ -146,15 +146,18 @@ export abstract class ResourceWorkingCopy extends Disposable implements IResourc
//#region Abstract
abstract typeId: string;
abstract name: string;
abstract capabilities: WorkingCopyCapabilities;
abstract onDidChangeDirty: Event<void>;
abstract onDidChangeContent: Event<void>;
abstract isDirty(): boolean;
abstract backup(token: CancellationToken): Promise<IWorkingCopyBackup>;
abstract save(options?: ISaveOptions): Promise<boolean>;
abstract revert(options?: IRevertOptions): Promise<void>;
abstract typeId: string;
//#endregion
}

View file

@ -14,7 +14,7 @@ import { IWorkingCopyBackup, IWorkingCopyBackupMeta, WorkingCopyCapabilities } f
import { raceCancellation, TaskSequentializer, timeout } from 'vs/base/common/async';
import { ILogService } from 'vs/platform/log/common/log';
import { assertIsDefined } from 'vs/base/common/types';
import { ITextFileEditorModel, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
import { VSBufferReadableStream } from 'vs/base/common/buffer';
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
import { IWorkingCopyBackupService, IResolvedWorkingCopyBackup } from 'vs/workbench/services/workingCopy/common/workingCopyBackup';
@ -294,7 +294,7 @@ export class StoredFileWorkingCopy<M extends IStoredFileWorkingCopyModel> extend
private readonly modelFactory: IStoredFileWorkingCopyModelFactory<M>,
@IFileService fileService: IFileService,
@ILogService private readonly logService: ILogService,
@ITextFileService private readonly textFileService: ITextFileService,
@IWorkingCopyFileService private readonly workingCopyFileService: IWorkingCopyFileService,
@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
@IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService,
@IWorkingCopyService workingCopyService: IWorkingCopyService,
@ -657,7 +657,7 @@ export class StoredFileWorkingCopy<M extends IStoredFileWorkingCopyModel> extend
private onModelContentChanged(model: M, isUndoingOrRedoing: boolean): void {
this.trace(`[stored file working copy] onModelContentChanged() - enter`);
// In any case increment the version id because it tracks the textual content state of the model at all times
// In any case increment the version id because it tracks the content state of the model at all times
this.versionId++;
this.trace(`[stored file working copy] onModelContentChanged() - new versionId ${this.versionId}`);
@ -835,7 +835,7 @@ export class StoredFileWorkingCopy<M extends IStoredFileWorkingCopyModel> extend
// In addition we update our version right after in case it changed
// because of a working copy change
// Save participants can also be skipped through API.
if (this.isResolved() && !options.skipSaveParticipants && this.isTextFileModel(this.model)) {
if (this.isResolved() && !options.skipSaveParticipants && this.workingCopyFileService.hasSaveParticipants) {
try {
// Measure the time it took from the last undo/redo operation to this save. If this
@ -860,7 +860,7 @@ export class StoredFileWorkingCopy<M extends IStoredFileWorkingCopyModel> extend
// Run save participants unless save was cancelled meanwhile
if (!saveCancellation.token.isCancellationRequested) {
await this.textFileService.files.runSaveParticipants(this.model, { reason: options.reason ?? SaveReason.EXPLICIT }, saveCancellation.token);
await this.workingCopyFileService.runSaveParticipants(this, { reason: options.reason ?? SaveReason.EXPLICIT }, saveCancellation.token);
}
} catch (error) {
this.logService.error(`[stored file working copy] runSaveParticipants(${versionId}) - resulted in an error: ${error.toString()}`, this.resource.toString(true), this.typeId);
@ -983,12 +983,8 @@ export class StoredFileWorkingCopy<M extends IStoredFileWorkingCopyModel> extend
this.inConflictMode = true;
}
// Delegate to save error handler
if (this.isTextFileModel(this.model)) {
this.textFileService.files.saveErrorHandler.onSaveError(error, this.model);
} else {
this.doHandleSaveError(error);
}
// Show save error to user for handling
this.doHandleSaveError(error);
// Emit as event
this._onDidSaveError.fire();
@ -1199,14 +1195,4 @@ export class StoredFileWorkingCopy<M extends IStoredFileWorkingCopyModel> extend
}
//#endregion
//#region Remainders of text file model world (TODO@bpasero callers have to be handled in a generic way)
private isTextFileModel(model: unknown): model is ITextFileEditorModel {
const textFileModel = this.textFileService.files.get(this.resource);
return !!(textFileModel && this.model && (textFileModel as unknown) === (this.model as unknown));
}
//#endregion
}

View file

@ -25,7 +25,6 @@ import { INotificationService } from 'vs/platform/notification/common/notificati
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IElevatedFileService } from 'vs/workbench/services/files/common/elevatedFileService';
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { IWorkingCopyEditorService } from 'vs/workbench/services/workingCopy/common/workingCopyEditorService';
import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
@ -153,7 +152,6 @@ export class StoredFileWorkingCopyManager<M extends IStoredFileWorkingCopyModel>
@IWorkingCopyFileService private readonly workingCopyFileService: IWorkingCopyFileService,
@IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService,
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,
@ITextFileService private readonly textFileService: ITextFileService,
@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
@IWorkingCopyService private readonly workingCopyService: IWorkingCopyService,
@INotificationService private readonly notificationService: INotificationService,
@ -441,7 +439,7 @@ export class StoredFileWorkingCopyManager<M extends IStoredFileWorkingCopyModel>
resource,
this.labelService.getUriBasenameLabel(resource),
this.modelFactory,
this.fileService, this.logService, this.textFileService, this.filesConfigurationService,
this.fileService, this.logService, this.workingCopyFileService, this.filesConfigurationService,
this.workingCopyBackupService, this.workingCopyService, this.notificationService, this.workingCopyEditorService,
this.editorService, this.elevatedFileService
);

View file

@ -0,0 +1,73 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { localize } from 'vs/nls';
import { raceCancellation } from 'vs/base/common/async';
import { CancellationTokenSource, CancellationToken } from 'vs/base/common/cancellation';
import { ILogService } from 'vs/platform/log/common/log';
import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress';
import { SaveReason } from 'vs/workbench/common/editor';
import { IDisposable, Disposable, toDisposable } from 'vs/base/common/lifecycle';
import { insert } from 'vs/base/common/arrays';
import { IStoredFileWorkingCopySaveParticipant } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
import { IStoredFileWorkingCopy, IStoredFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopy';
export class StoredFileWorkingCopySaveParticipant extends Disposable {
private readonly saveParticipants: IStoredFileWorkingCopySaveParticipant[] = [];
get length(): number { return this.saveParticipants.length; }
constructor(
@IProgressService private readonly progressService: IProgressService,
@ILogService private readonly logService: ILogService
) {
super();
}
addSaveParticipant(participant: IStoredFileWorkingCopySaveParticipant): IDisposable {
const remove = insert(this.saveParticipants, participant);
return toDisposable(() => remove());
}
participate(workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>, context: { reason: SaveReason; }, token: CancellationToken): Promise<void> {
const cts = new CancellationTokenSource(token);
return this.progressService.withProgress({
title: localize('saveParticipants', "Saving '{0}'", workingCopy.name),
location: ProgressLocation.Notification,
cancellable: true,
delay: workingCopy.isDirty() ? 3000 : 5000
}, async progress => {
// undoStop before participation
workingCopy.model?.pushStackElement();
for (const saveParticipant of this.saveParticipants) {
if (cts.token.isCancellationRequested || workingCopy.isDisposed()) {
break;
}
try {
const promise = saveParticipant.participate(workingCopy, context, progress, cts.token);
await raceCancellation(promise, cts.token);
} catch (err) {
this.logService.warn(err);
}
}
// undoStop after participation
workingCopy.model?.pushStackElement();
}, () => {
// user cancel
cts.dispose(true);
});
}
override dispose(): void {
this.saveParticipants.splice(0, this.saveParticipants.length);
}
}

View file

@ -17,6 +17,10 @@ import { IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCo
import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity';
import { WorkingCopyFileOperationParticipant } from 'vs/workbench/services/workingCopy/common/workingCopyFileOperationParticipant';
import { VSBuffer, VSBufferReadable, VSBufferReadableStream } from 'vs/base/common/buffer';
import { SaveReason } from 'vs/workbench/common/editor';
import { IProgress, IProgressStep } from 'vs/platform/progress/common/progress';
import { StoredFileWorkingCopySaveParticipant } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopySaveParticipant';
import { IStoredFileWorkingCopy, IStoredFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopy';
export const IWorkingCopyFileService = createDecorator<IWorkingCopyFileService>('workingCopyFileService');
@ -80,6 +84,20 @@ export interface IWorkingCopyFileOperationParticipant {
): Promise<void>;
}
export interface IStoredFileWorkingCopySaveParticipant {
/**
* Participate in a save operation of file stored working copies.
* Allows to make changes before content is being saved to disk.
*/
participate(
workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>,
context: { reason: SaveReason },
progress: IProgress<IProgressStep>,
token: CancellationToken
): Promise<void>;
}
export interface ICreateOperation {
resource: URI;
overwrite?: boolean;
@ -158,6 +176,26 @@ export interface IWorkingCopyFileService {
//#endregion
//#region Stored File Working Copy save participants
/**
* Whether save participants are present for stored file working copies.
*/
get hasSaveParticipants(): boolean;
/**
* Adds a participant for save operations on stored file working copies.
*/
addSaveParticipant(participant: IStoredFileWorkingCopySaveParticipant): IDisposable;
/**
* Runs all available save participants for stored file working copies.
*/
runSaveParticipants(workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>, context: { reason: SaveReason; }, token: CancellationToken): Promise<void>;
//#endregion
//#region File operations
/**
@ -444,6 +482,22 @@ export class WorkingCopyFileService extends Disposable implements IWorkingCopyFi
//#endregion
//#region Save participants (stored file working copies only)
private readonly saveParticipants = this._register(this.instantiationService.createInstance(StoredFileWorkingCopySaveParticipant));
get hasSaveParticipants(): boolean { return this.saveParticipants.length > 0; }
addSaveParticipant(participant: IStoredFileWorkingCopySaveParticipant): IDisposable {
return this.saveParticipants.addSaveParticipant(participant);
}
runSaveParticipants(workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>, context: { reason: SaveReason; }, token: CancellationToken): Promise<void> {
return this.saveParticipants.participate(workingCopy, context, token);
}
//#endregion
//#region Path related

View file

@ -98,7 +98,7 @@ suite('StoredFileWorkingCopy', function () {
let workingCopy: StoredFileWorkingCopy<TestStoredFileWorkingCopyModel>;
function createWorkingCopy(uri: URI = resource) {
return new StoredFileWorkingCopy<TestStoredFileWorkingCopyModel>('testStoredFileWorkingCopyType', uri, basename(uri), factory, accessor.fileService, accessor.logService, accessor.textFileService, accessor.filesConfigurationService, accessor.workingCopyBackupService, accessor.workingCopyService, accessor.notificationService, accessor.workingCopyEditorService, accessor.editorService, accessor.elevatedFileService);
return new StoredFileWorkingCopy<TestStoredFileWorkingCopyModel>('testStoredFileWorkingCopyType', uri, basename(uri), factory, accessor.fileService, accessor.logService, accessor.workingCopyFileService, accessor.filesConfigurationService, accessor.workingCopyBackupService, accessor.workingCopyService, accessor.notificationService, accessor.workingCopyEditorService, accessor.editorService, accessor.elevatedFileService);
}
setup(() => {
@ -601,6 +601,35 @@ suite('StoredFileWorkingCopy', function () {
assert.ok(error);
});
test('save participant', async () => {
await workingCopy.resolve();
assert.strictEqual(accessor.workingCopyFileService.hasSaveParticipants, false);
let participationCounter = 0;
const disposable = accessor.workingCopyFileService.addSaveParticipant({
participate: async (wc) => {
if (workingCopy === wc) {
participationCounter++;
}
}
});
assert.strictEqual(accessor.workingCopyFileService.hasSaveParticipants, true);
await workingCopy.save({ force: true });
assert.strictEqual(participationCounter, 1);
await workingCopy.save({ force: true, skipSaveParticipants: true });
assert.strictEqual(participationCounter, 1);
disposable.dispose();
assert.strictEqual(accessor.workingCopyFileService.hasSaveParticipants, false);
await workingCopy.save({ force: true });
assert.strictEqual(participationCounter, 1);
});
test('revert', async () => {
await workingCopy.resolve();
workingCopy.model?.updateContents('hello revert');

View file

@ -32,7 +32,7 @@ suite('StoredFileWorkingCopyManager', () => {
new TestStoredFileWorkingCopyModelFactory(),
accessor.fileService, accessor.lifecycleService, accessor.labelService, accessor.logService,
accessor.workingCopyFileService, accessor.workingCopyBackupService, accessor.uriIdentityService,
accessor.textFileService, accessor.filesConfigurationService, accessor.workingCopyService, accessor.notificationService,
accessor.filesConfigurationService, accessor.workingCopyService, accessor.notificationService,
accessor.workingCopyEditorService, accessor.editorService, accessor.elevatedFileService
);
});

View file

@ -16,10 +16,10 @@ import { isLinux, isMacintosh } from 'vs/base/common/platform';
import { InMemoryStorageService, WillSaveStateReason } from 'vs/platform/storage/common/storage';
import { IWorkingCopy, IWorkingCopyBackup, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy';
import { NullExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { IWorkingCopyFileService, IWorkingCopyFileOperationParticipant, WorkingCopyFileEvent, IDeleteOperation, ICopyOperation, IMoveOperation, IFileOperationUndoRedoInfo, ICreateFileOperation, ICreateOperation } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
import { IWorkingCopyFileService, IWorkingCopyFileOperationParticipant, WorkingCopyFileEvent, IDeleteOperation, ICopyOperation, IMoveOperation, IFileOperationUndoRedoInfo, ICreateFileOperation, ICreateOperation, IStoredFileWorkingCopySaveParticipant } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
import { IDisposable, Disposable } from 'vs/base/common/lifecycle';
import { IFileStatWithMetadata } from 'vs/platform/files/common/files';
import { ISaveOptions, IRevertOptions } from 'vs/workbench/common/editor';
import { ISaveOptions, IRevertOptions, SaveReason } from 'vs/workbench/common/editor';
import { CancellationToken } from 'vs/base/common/cancellation';
import product from 'vs/platform/product/common/product';
import { IActivity, IActivityService } from 'vs/workbench/services/activity/common/activity';
@ -190,6 +190,10 @@ export class TestWorkingCopyFileService implements IWorkingCopyFileService {
addFileOperationParticipant(participant: IWorkingCopyFileOperationParticipant): IDisposable { return Disposable.None; }
readonly hasSaveParticipants = false;
addSaveParticipant(participant: IStoredFileWorkingCopySaveParticipant): IDisposable { return Disposable.None; }
async runSaveParticipants(workingCopy: IWorkingCopy, context: { reason: SaveReason; }, token: CancellationToken): Promise<void> { }
async delete(operations: IDeleteOperation[], token: CancellationToken, undoInfo?: IFileOperationUndoRedoInfo): Promise<void> { }
registerWorkingCopyProvider(provider: (resourceOrFolder: URI) => IWorkingCopy[]): IDisposable { return Disposable.None; }