提交 cf0fd9a5 编写于 作者: B Benjamin Pasero

editors - save dirty working copy after delay (for #84672)

上级 94ae236f
......@@ -4,16 +4,22 @@
*--------------------------------------------------------------------------------------------*/
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { IFilesConfigurationService, AutoSaveMode } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
import { Disposable, DisposableStore, IDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle';
import { IFilesConfigurationService, AutoSaveMode, IAutoSaveConfiguration } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
import { IHostService } from 'vs/workbench/services/host/browser/host';
import { SaveReason, IEditorIdentifier, IEditorInput, GroupIdentifier } from 'vs/workbench/common/editor';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
import { withNullAsUndefined } from 'vs/base/common/types';
import { IWorkingCopyService, IWorkingCopy, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
export class EditorAutoSave extends Disposable implements IWorkbenchContribution {
// Auto save: after delay
private autoSaveAfterDelay: number | undefined;
private readonly pendingAutoSavesAfterDelay = new Map<IWorkingCopy, IDisposable>();
// Auto save: focus change & window change
private lastActiveEditor: IEditorInput | undefined = undefined;
private lastActiveGroupId: GroupIdentifier | undefined = undefined;
private lastActiveEditorControlDisposable = this._register(new DisposableStore());
......@@ -22,17 +28,22 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution
@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
@IHostService private readonly hostService: IHostService,
@IEditorService private readonly editorService: IEditorService,
@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService
@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,
@IWorkingCopyService private readonly workingCopyService: IWorkingCopyService
) {
super();
// Figure out initial auto save config
this.onAutoSaveConfigurationChange(filesConfigurationService.getAutoSaveConfiguration(), false);
this.registerListeners();
}
private registerListeners(): void {
this._register(this.hostService.onDidChangeFocus(focused => this.onWindowFocusChange(focused)));
this._register(this.editorService.onDidActiveEditorChange(() => this.onDidActiveEditorChange()));
this._register(this.filesConfigurationService.onAutoSaveConfigurationChange(() => this.onAutoSaveConfigurationChange()));
this._register(this.filesConfigurationService.onAutoSaveConfigurationChange(config => this.onAutoSaveConfigurationChange(config, true)));
this._register(this.workingCopyService.onDidChangeDirty(workingCopy => this.onDidWorkingCopyChangeDirty(workingCopy)));
}
private onWindowFocusChange(focused: boolean): void {
......@@ -85,7 +96,13 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution
}
}
private onAutoSaveConfigurationChange(): void {
private onAutoSaveConfigurationChange(config: IAutoSaveConfiguration, fromEvent: boolean): void {
// Update auto save after delay config
this.autoSaveAfterDelay = (typeof config.autoSaveDelay === 'number') && config.autoSaveDelay > 0 ? config.autoSaveDelay : undefined;
// Trigger a save-all when auto save is enabled
if (fromEvent) {
let reason: SaveReason | undefined = undefined;
switch (this.filesConfigurationService.getAutoSaveMode()) {
case AutoSaveMode.ON_FOCUS_CHANGE:
......@@ -100,9 +117,34 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution
break;
}
// Trigger a save-all when auto save is enabled
if (reason) {
this.editorService.saveAll({ reason });
}
}
}
private onDidWorkingCopyChangeDirty(workingCopy: IWorkingCopy): void {
if (typeof this.autoSaveAfterDelay !== 'number') {
return; // auto save after delay must be enabled
}
if (workingCopy.capabilities & WorkingCopyCapabilities.Untitled) {
return; // we never auto save untitled working copies
}
// Clear any running auto save operation
dispose(this.pendingAutoSavesAfterDelay.get(workingCopy));
this.pendingAutoSavesAfterDelay.delete(workingCopy);
// Working copy got dirty - start auto save
if (workingCopy.isDirty()) {
const handle = setTimeout(() => {
if (workingCopy.isDirty()) {
workingCopy.save({ reason: SaveReason.AUTO });
}
}, this.autoSaveAfterDelay);
this.pendingAutoSavesAfterDelay.set(workingCopy, toDisposable(() => clearTimeout(handle)));
}
}
}
/*---------------------------------------------------------------------------------------------
* 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 { Event } from 'vs/base/common/event';
import { toResource } from 'vs/base/test/common/utils';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { workbenchInstantiationService, TestTextFileService, TestFileService, TestFilesConfigurationService, TestEnvironmentService } from 'vs/workbench/test/workbenchTestServices';
import { ITextFileService, IResolvedTextFileEditorModel, ITextFileEditorModel } from 'vs/workbench/services/textfile/common/textfiles';
import { IFileService } from 'vs/platform/files/common/files';
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
import { dispose, IDisposable } from 'vs/base/common/lifecycle';
import { IEditorRegistry, EditorDescriptor, Extensions as EditorExtensions } from 'vs/workbench/browser/editor';
import { Registry } from 'vs/platform/registry/common/platform';
import { TextFileEditor } from 'vs/workbench/contrib/files/browser/editors/textFileEditor';
import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
import { EditorInput } from 'vs/workbench/common/editor';
import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput';
import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager';
import { EditorPart } from 'vs/workbench/browser/parts/editor/editorPart';
import { EditorService } from 'vs/workbench/services/editor/browser/editorService';
import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService';
import { EditorAutoSave } from 'vs/workbench/browser/parts/editor/editorAutoSave';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService';
class ServiceAccessor {
constructor(
@IEditorService public editorService: IEditorService,
@IEditorGroupsService public editorGroupService: IEditorGroupsService,
@ITextFileService public textFileService: TestTextFileService,
@IFileService public fileService: TestFileService,
@IUntitledTextEditorService public untitledTextEditorService: IUntitledTextEditorService,
@IConfigurationService public configurationService: TestConfigurationService
) {
}
}
suite('EditorAutoSave', () => {
let disposables: IDisposable[] = [];
setup(() => {
disposables.push(Registry.as<IEditorRegistry>(EditorExtensions.Editors).registerEditor(
EditorDescriptor.create(
TextFileEditor,
TextFileEditor.ID,
'Text File Editor'
),
[new SyncDescriptor<EditorInput>(FileEditorInput)]
));
});
teardown(() => {
dispose(disposables);
disposables = [];
});
test('editor auto saves after short delay if configured', async function () {
const instantiationService = workbenchInstantiationService();
const configurationService = new TestConfigurationService();
configurationService.setUserConfiguration('files', { autoSave: 'afterDelay', autoSaveDelay: 1 });
instantiationService.stub(IConfigurationService, configurationService);
instantiationService.stub(IFilesConfigurationService, new TestFilesConfigurationService(
<IContextKeyService>instantiationService.createInstance(MockContextKeyService),
configurationService,
TestEnvironmentService
));
const part = instantiationService.createInstance(EditorPart);
part.create(document.createElement('div'));
part.layout(400, 300);
instantiationService.stub(IEditorGroupsService, part);
const editorService: EditorService = instantiationService.createInstance(EditorService);
instantiationService.stub(IEditorService, editorService);
const accessor = instantiationService.createInstance(ServiceAccessor);
const editorAutoSave = instantiationService.createInstance(EditorAutoSave);
const resource = toResource.call(this, '/path/index.txt');
const model = await accessor.textFileService.models.loadOrCreate(resource) as IResolvedTextFileEditorModel;
model.textEditorModel.setValue('Super Good');
assert.ok(model.isDirty());
await awaitModelSaved(model);
assert.ok(!model.isDirty());
part.dispose();
editorAutoSave.dispose();
(<TextFileEditorModelManager>accessor.textFileService.models).clear();
(<TextFileEditorModelManager>accessor.textFileService.models).dispose();
});
function awaitModelSaved(model: ITextFileEditorModel): Promise<void> {
return new Promise(c => {
Event.once(model.onDidChangeDirty)(c);
});
}
});
......@@ -24,13 +24,12 @@ import { RunOnceScheduler, timeout } from 'vs/base/common/async';
import { ITextBufferFactory } from 'vs/editor/common/model';
import { hash } from 'vs/base/common/hash';
import { INotificationService } from 'vs/platform/notification/common/notification';
import { toDisposable, MutableDisposable } from 'vs/base/common/lifecycle';
import { ILogService } from 'vs/platform/log/common/log';
import { isEqual, isEqualOrParent, extname, basename, joinPath } from 'vs/base/common/resources';
import { onUnexpectedError } from 'vs/base/common/errors';
import { Schemas } from 'vs/base/common/network';
import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { IFilesConfigurationService, IAutoSaveConfiguration } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
export interface IBackupMetaData {
mtime: number;
......@@ -92,10 +91,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
private lastResolvedFileStat: IFileStatWithMetadata | undefined;
private autoSaveAfterMillies: number | undefined;
private autoSaveAfterMilliesEnabled: boolean | undefined;
private readonly autoSaveDisposable = this._register(new MutableDisposable());
private readonly saveSequentializer = new SaveSequentializer();
private lastSaveAttemptTime = 0;
......@@ -128,8 +123,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
) {
super(modelService, modeService);
this.updateAutoSaveConfiguration(filesConfigurationService.getAutoSaveConfiguration());
// Make known to working copy service
this._register(this.workingCopyService.registerWorkingCopy(this));
......@@ -138,7 +131,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
private registerListeners(): void {
this._register(this.fileService.onFileChanges(e => this.onFileChanges(e)));
this._register(this.filesConfigurationService.onAutoSaveConfigurationChange(config => this.updateAutoSaveConfiguration(config)));
this._register(this.filesConfigurationService.onFilesAssociationChange(e => this.onFilesAssociationChange()));
this._register(this.onDidStateChange(e => this.onStateChange(e)));
}
......@@ -206,13 +198,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}
}
private updateAutoSaveConfiguration(config: IAutoSaveConfiguration): void {
const autoSaveAfterMilliesEnabled = (typeof config.autoSaveDelay === 'number') && config.autoSaveDelay > 0;
this.autoSaveAfterMilliesEnabled = autoSaveAfterMilliesEnabled;
this.autoSaveAfterMillies = autoSaveAfterMilliesEnabled ? config.autoSaveDelay : undefined;
}
private onFilesAssociationChange(): void {
if (!this.isResolved()) {
return;
......@@ -258,9 +243,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
return false;
}
// Cancel any running auto-save
this.autoSaveDisposable.clear();
// Unset flags
const wasDirty = this.dirty;
const undo = this.setDirty(false);
......@@ -474,13 +456,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
this.createTextEditorModel(value, resource, this.preferredMode);
// We restored a backup so we have to set the model as being dirty
// We also want to trigger auto save if it is enabled to simulate the exact same behaviour
// you would get if manually making the model dirty (fixes https://github.com/Microsoft/vscode/issues/16977)
if (fromBackup) {
this.doMakeDirty();
if (this.autoSaveAfterMilliesEnabled) {
this.doAutoSave(this.versionId);
}
}
// Ensure we are not tracking a stale state
......@@ -536,9 +513,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// The contents changed as a matter of Undo and the version reached matches the saved one
// In this case we clear the dirty flag and emit a SAVED event to indicate this state.
// Note: we currently only do this check when auto-save is turned off because there you see
// a dirty indicator that you want to get rid of when undoing to the saved version.
if (!this.autoSaveAfterMilliesEnabled && this.isResolved() && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) {
if (this.isResolved() && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) {
this.logService.trace('onModelContentChanged() - model content changed back to last saved version', this.resource);
// Clear flags
......@@ -559,15 +534,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
// Mark as dirty
this.doMakeDirty();
// Start auto save process unless we are in conflict resolution mode and unless it is disabled
if (this.autoSaveAfterMilliesEnabled) {
if (!this.inConflictMode) {
this.doAutoSave(this.versionId);
} else {
this.logService.trace('makeDirty() - prevented save because we are in conflict resolution mode', this.resource);
}
}
// Handle content change events
this.contentChangeEventScheduler.schedule();
}
......@@ -593,27 +559,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}
}
private doAutoSave(versionId: number): void {
this.logService.trace(`doAutoSave() - enter for versionId ${versionId}`, this.resource);
// Cancel any currently running auto saves to make this the one that succeeds
this.autoSaveDisposable.clear();
// Create new save timer and store it for disposal as needed
const handle = setTimeout(() => {
// Clear the timeout now that we are running
this.autoSaveDisposable.clear();
// Only trigger save if the version id has not changed meanwhile
if (versionId === this.versionId) {
this.doSave(versionId, { reason: SaveReason.AUTO }); // Very important here to not return the promise because if the timeout promise is canceled it will bubble up the error otherwise - do not change
}
}, this.autoSaveAfterMillies);
this.autoSaveDisposable.value = toDisposable(() => clearTimeout(handle));
}
async save(options: ITextFileSaveOptions = Object.create(null)): Promise<boolean> {
if (!this.isResolved()) {
return false;
......@@ -621,9 +566,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
this.logService.trace('save() - enter', this.resource);
// Cancel any currently running auto saves to make this the one that succeeds
this.autoSaveDisposable.clear();
await this.doSave(this.versionId, options);
return true;
......@@ -676,8 +618,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
}
// Push all edit operations to the undo stack so that the user has a chance to
// Ctrl+Z back to the saved version. We only do this when auto-save is turned off
if (!this.autoSaveAfterMilliesEnabled && this.isResolved()) {
// Ctrl+Z back to the saved version.
if (this.isResolved()) {
this.textEditorModel.pushStackElement();
}
......
......@@ -9,6 +9,7 @@ import { Event, Emitter } from 'vs/base/common/event';
import { URI } from 'vs/base/common/uri';
import { Disposable, IDisposable, toDisposable, DisposableStore, dispose } from 'vs/base/common/lifecycle';
import { TernarySearchTree } from 'vs/base/common/map';
import { ISaveOptions } from 'vs/workbench/common/editor';
export const enum WorkingCopyCapabilities {
......@@ -22,6 +23,11 @@ export const enum WorkingCopyCapabilities {
export interface IWorkingCopy {
readonly resource: URI;
readonly capabilities: WorkingCopyCapabilities;
//#region Dirty Tracking
readonly onDidChangeDirty: Event<void>;
......@@ -31,9 +37,11 @@ export interface IWorkingCopy {
//#endregion
readonly resource: URI;
//#region Save
readonly capabilities: WorkingCopyCapabilities;
save(options?: ISaveOptions): Promise<boolean>;
//#endregion
}
export const IWorkingCopyService = createDecorator<IWorkingCopyService>('workingCopyService');
......
......@@ -9,6 +9,7 @@ import { URI } from 'vs/base/common/uri';
import { Emitter } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import { TestWorkingCopyService } from 'vs/workbench/test/workbenchTestServices';
import type { ISaveOptions } from 'vs/workbench/common/editor';
suite('WorkingCopyService', () => {
......@@ -41,6 +42,10 @@ suite('WorkingCopyService', () => {
return this.dirty;
}
async save(options?: ISaveOptions): Promise<boolean> {
return true;
}
dispose(): void {
this._onDispose.fire();
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册