Skip to content

Commit cf0fd9a

Browse files
committed
editors - save dirty working copy after delay (for #84672)
1 parent 94ae236 commit cf0fd9a

File tree

5 files changed

+195
-85
lines changed

5 files changed

+195
-85
lines changed

src/vs/workbench/browser/parts/editor/editorAutoSave.ts

+62-20
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
7-
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
8-
import { IFilesConfigurationService, AutoSaveMode } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
7+
import { Disposable, DisposableStore, IDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle';
8+
import { IFilesConfigurationService, AutoSaveMode, IAutoSaveConfiguration } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
99
import { IHostService } from 'vs/workbench/services/host/browser/host';
1010
import { SaveReason, IEditorIdentifier, IEditorInput, GroupIdentifier } from 'vs/workbench/common/editor';
1111
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
1212
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
1313
import { withNullAsUndefined } from 'vs/base/common/types';
14+
import { IWorkingCopyService, IWorkingCopy, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
1415

1516
export class EditorAutoSave extends Disposable implements IWorkbenchContribution {
1617

18+
// Auto save: after delay
19+
private autoSaveAfterDelay: number | undefined;
20+
private readonly pendingAutoSavesAfterDelay = new Map<IWorkingCopy, IDisposable>();
21+
22+
// Auto save: focus change & window change
1723
private lastActiveEditor: IEditorInput | undefined = undefined;
1824
private lastActiveGroupId: GroupIdentifier | undefined = undefined;
1925
private lastActiveEditorControlDisposable = this._register(new DisposableStore());
@@ -22,17 +28,22 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution
2228
@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
2329
@IHostService private readonly hostService: IHostService,
2430
@IEditorService private readonly editorService: IEditorService,
25-
@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService
31+
@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,
32+
@IWorkingCopyService private readonly workingCopyService: IWorkingCopyService
2633
) {
2734
super();
2835

36+
// Figure out initial auto save config
37+
this.onAutoSaveConfigurationChange(filesConfigurationService.getAutoSaveConfiguration(), false);
38+
2939
this.registerListeners();
3040
}
3141

3242
private registerListeners(): void {
3343
this._register(this.hostService.onDidChangeFocus(focused => this.onWindowFocusChange(focused)));
3444
this._register(this.editorService.onDidActiveEditorChange(() => this.onDidActiveEditorChange()));
35-
this._register(this.filesConfigurationService.onAutoSaveConfigurationChange(() => this.onAutoSaveConfigurationChange()));
45+
this._register(this.filesConfigurationService.onAutoSaveConfigurationChange(config => this.onAutoSaveConfigurationChange(config, true)));
46+
this._register(this.workingCopyService.onDidChangeDirty(workingCopy => this.onDidWorkingCopyChangeDirty(workingCopy)));
3647
}
3748

3849
private onWindowFocusChange(focused: boolean): void {
@@ -85,24 +96,55 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution
8596
}
8697
}
8798

88-
private onAutoSaveConfigurationChange(): void {
89-
let reason: SaveReason | undefined = undefined;
90-
switch (this.filesConfigurationService.getAutoSaveMode()) {
91-
case AutoSaveMode.ON_FOCUS_CHANGE:
92-
reason = SaveReason.FOCUS_CHANGE;
93-
break;
94-
case AutoSaveMode.ON_WINDOW_CHANGE:
95-
reason = SaveReason.WINDOW_CHANGE;
96-
break;
97-
case AutoSaveMode.AFTER_SHORT_DELAY:
98-
case AutoSaveMode.AFTER_LONG_DELAY:
99-
reason = SaveReason.AUTO;
100-
break;
101-
}
99+
private onAutoSaveConfigurationChange(config: IAutoSaveConfiguration, fromEvent: boolean): void {
100+
101+
// Update auto save after delay config
102+
this.autoSaveAfterDelay = (typeof config.autoSaveDelay === 'number') && config.autoSaveDelay > 0 ? config.autoSaveDelay : undefined;
102103

103104
// Trigger a save-all when auto save is enabled
104-
if (reason) {
105-
this.editorService.saveAll({ reason });
105+
if (fromEvent) {
106+
let reason: SaveReason | undefined = undefined;
107+
switch (this.filesConfigurationService.getAutoSaveMode()) {
108+
case AutoSaveMode.ON_FOCUS_CHANGE:
109+
reason = SaveReason.FOCUS_CHANGE;
110+
break;
111+
case AutoSaveMode.ON_WINDOW_CHANGE:
112+
reason = SaveReason.WINDOW_CHANGE;
113+
break;
114+
case AutoSaveMode.AFTER_SHORT_DELAY:
115+
case AutoSaveMode.AFTER_LONG_DELAY:
116+
reason = SaveReason.AUTO;
117+
break;
118+
}
119+
120+
if (reason) {
121+
this.editorService.saveAll({ reason });
122+
}
123+
}
124+
}
125+
126+
private onDidWorkingCopyChangeDirty(workingCopy: IWorkingCopy): void {
127+
if (typeof this.autoSaveAfterDelay !== 'number') {
128+
return; // auto save after delay must be enabled
129+
}
130+
131+
if (workingCopy.capabilities & WorkingCopyCapabilities.Untitled) {
132+
return; // we never auto save untitled working copies
133+
}
134+
135+
// Clear any running auto save operation
136+
dispose(this.pendingAutoSavesAfterDelay.get(workingCopy));
137+
this.pendingAutoSavesAfterDelay.delete(workingCopy);
138+
139+
// Working copy got dirty - start auto save
140+
if (workingCopy.isDirty()) {
141+
const handle = setTimeout(() => {
142+
if (workingCopy.isDirty()) {
143+
workingCopy.save({ reason: SaveReason.AUTO });
144+
}
145+
}, this.autoSaveAfterDelay);
146+
147+
this.pendingAutoSavesAfterDelay.set(workingCopy, toDisposable(() => clearTimeout(handle)));
106148
}
107149
}
108150
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as assert from 'assert';
7+
import { Event } from 'vs/base/common/event';
8+
import { toResource } from 'vs/base/test/common/utils';
9+
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
10+
import { workbenchInstantiationService, TestTextFileService, TestFileService, TestFilesConfigurationService, TestEnvironmentService } from 'vs/workbench/test/workbenchTestServices';
11+
import { ITextFileService, IResolvedTextFileEditorModel, ITextFileEditorModel } from 'vs/workbench/services/textfile/common/textfiles';
12+
import { IFileService } from 'vs/platform/files/common/files';
13+
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
14+
import { dispose, IDisposable } from 'vs/base/common/lifecycle';
15+
import { IEditorRegistry, EditorDescriptor, Extensions as EditorExtensions } from 'vs/workbench/browser/editor';
16+
import { Registry } from 'vs/platform/registry/common/platform';
17+
import { TextFileEditor } from 'vs/workbench/contrib/files/browser/editors/textFileEditor';
18+
import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
19+
import { EditorInput } from 'vs/workbench/common/editor';
20+
import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput';
21+
import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager';
22+
import { EditorPart } from 'vs/workbench/browser/parts/editor/editorPart';
23+
import { EditorService } from 'vs/workbench/services/editor/browser/editorService';
24+
import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService';
25+
import { EditorAutoSave } from 'vs/workbench/browser/parts/editor/editorAutoSave';
26+
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
27+
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
28+
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
29+
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
30+
import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService';
31+
32+
class ServiceAccessor {
33+
constructor(
34+
@IEditorService public editorService: IEditorService,
35+
@IEditorGroupsService public editorGroupService: IEditorGroupsService,
36+
@ITextFileService public textFileService: TestTextFileService,
37+
@IFileService public fileService: TestFileService,
38+
@IUntitledTextEditorService public untitledTextEditorService: IUntitledTextEditorService,
39+
@IConfigurationService public configurationService: TestConfigurationService
40+
) {
41+
}
42+
}
43+
44+
suite('EditorAutoSave', () => {
45+
46+
let disposables: IDisposable[] = [];
47+
48+
setup(() => {
49+
disposables.push(Registry.as<IEditorRegistry>(EditorExtensions.Editors).registerEditor(
50+
EditorDescriptor.create(
51+
TextFileEditor,
52+
TextFileEditor.ID,
53+
'Text File Editor'
54+
),
55+
[new SyncDescriptor<EditorInput>(FileEditorInput)]
56+
));
57+
});
58+
59+
teardown(() => {
60+
dispose(disposables);
61+
disposables = [];
62+
});
63+
64+
test('editor auto saves after short delay if configured', async function () {
65+
const instantiationService = workbenchInstantiationService();
66+
67+
const configurationService = new TestConfigurationService();
68+
configurationService.setUserConfiguration('files', { autoSave: 'afterDelay', autoSaveDelay: 1 });
69+
instantiationService.stub(IConfigurationService, configurationService);
70+
71+
instantiationService.stub(IFilesConfigurationService, new TestFilesConfigurationService(
72+
<IContextKeyService>instantiationService.createInstance(MockContextKeyService),
73+
configurationService,
74+
TestEnvironmentService
75+
));
76+
77+
const part = instantiationService.createInstance(EditorPart);
78+
part.create(document.createElement('div'));
79+
part.layout(400, 300);
80+
81+
instantiationService.stub(IEditorGroupsService, part);
82+
83+
const editorService: EditorService = instantiationService.createInstance(EditorService);
84+
instantiationService.stub(IEditorService, editorService);
85+
86+
const accessor = instantiationService.createInstance(ServiceAccessor);
87+
88+
const editorAutoSave = instantiationService.createInstance(EditorAutoSave);
89+
90+
const resource = toResource.call(this, '/path/index.txt');
91+
92+
const model = await accessor.textFileService.models.loadOrCreate(resource) as IResolvedTextFileEditorModel;
93+
94+
model.textEditorModel.setValue('Super Good');
95+
96+
assert.ok(model.isDirty());
97+
98+
await awaitModelSaved(model);
99+
100+
assert.ok(!model.isDirty());
101+
102+
part.dispose();
103+
editorAutoSave.dispose();
104+
(<TextFileEditorModelManager>accessor.textFileService.models).clear();
105+
(<TextFileEditorModelManager>accessor.textFileService.models).dispose();
106+
});
107+
108+
function awaitModelSaved(model: ITextFileEditorModel): Promise<void> {
109+
return new Promise(c => {
110+
Event.once(model.onDidChangeDirty)(c);
111+
});
112+
}
113+
});

src/vs/workbench/services/textfile/common/textFileEditorModel.ts

+4-62
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,12 @@ import { RunOnceScheduler, timeout } from 'vs/base/common/async';
2424
import { ITextBufferFactory } from 'vs/editor/common/model';
2525
import { hash } from 'vs/base/common/hash';
2626
import { INotificationService } from 'vs/platform/notification/common/notification';
27-
import { toDisposable, MutableDisposable } from 'vs/base/common/lifecycle';
2827
import { ILogService } from 'vs/platform/log/common/log';
2928
import { isEqual, isEqualOrParent, extname, basename, joinPath } from 'vs/base/common/resources';
3029
import { onUnexpectedError } from 'vs/base/common/errors';
3130
import { Schemas } from 'vs/base/common/network';
3231
import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
33-
import { IFilesConfigurationService, IAutoSaveConfiguration } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
32+
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
3433

3534
export interface IBackupMetaData {
3635
mtime: number;
@@ -92,10 +91,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
9291

9392
private lastResolvedFileStat: IFileStatWithMetadata | undefined;
9493

95-
private autoSaveAfterMillies: number | undefined;
96-
private autoSaveAfterMilliesEnabled: boolean | undefined;
97-
private readonly autoSaveDisposable = this._register(new MutableDisposable());
98-
9994
private readonly saveSequentializer = new SaveSequentializer();
10095
private lastSaveAttemptTime = 0;
10196

@@ -128,8 +123,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
128123
) {
129124
super(modelService, modeService);
130125

131-
this.updateAutoSaveConfiguration(filesConfigurationService.getAutoSaveConfiguration());
132-
133126
// Make known to working copy service
134127
this._register(this.workingCopyService.registerWorkingCopy(this));
135128

@@ -138,7 +131,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
138131

139132
private registerListeners(): void {
140133
this._register(this.fileService.onFileChanges(e => this.onFileChanges(e)));
141-
this._register(this.filesConfigurationService.onAutoSaveConfigurationChange(config => this.updateAutoSaveConfiguration(config)));
142134
this._register(this.filesConfigurationService.onFilesAssociationChange(e => this.onFilesAssociationChange()));
143135
this._register(this.onDidStateChange(e => this.onStateChange(e)));
144136
}
@@ -206,13 +198,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
206198
}
207199
}
208200

209-
private updateAutoSaveConfiguration(config: IAutoSaveConfiguration): void {
210-
const autoSaveAfterMilliesEnabled = (typeof config.autoSaveDelay === 'number') && config.autoSaveDelay > 0;
211-
212-
this.autoSaveAfterMilliesEnabled = autoSaveAfterMilliesEnabled;
213-
this.autoSaveAfterMillies = autoSaveAfterMilliesEnabled ? config.autoSaveDelay : undefined;
214-
}
215-
216201
private onFilesAssociationChange(): void {
217202
if (!this.isResolved()) {
218203
return;
@@ -258,9 +243,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
258243
return false;
259244
}
260245

261-
// Cancel any running auto-save
262-
this.autoSaveDisposable.clear();
263-
264246
// Unset flags
265247
const wasDirty = this.dirty;
266248
const undo = this.setDirty(false);
@@ -474,13 +456,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
474456
this.createTextEditorModel(value, resource, this.preferredMode);
475457

476458
// We restored a backup so we have to set the model as being dirty
477-
// We also want to trigger auto save if it is enabled to simulate the exact same behaviour
478-
// you would get if manually making the model dirty (fixes https://github.com/Microsoft/vscode/issues/16977)
479459
if (fromBackup) {
480460
this.doMakeDirty();
481-
if (this.autoSaveAfterMilliesEnabled) {
482-
this.doAutoSave(this.versionId);
483-
}
484461
}
485462

486463
// Ensure we are not tracking a stale state
@@ -536,9 +513,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
536513

537514
// The contents changed as a matter of Undo and the version reached matches the saved one
538515
// In this case we clear the dirty flag and emit a SAVED event to indicate this state.
539-
// Note: we currently only do this check when auto-save is turned off because there you see
540-
// a dirty indicator that you want to get rid of when undoing to the saved version.
541-
if (!this.autoSaveAfterMilliesEnabled && this.isResolved() && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) {
516+
if (this.isResolved() && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) {
542517
this.logService.trace('onModelContentChanged() - model content changed back to last saved version', this.resource);
543518

544519
// Clear flags
@@ -559,15 +534,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
559534
// Mark as dirty
560535
this.doMakeDirty();
561536

562-
// Start auto save process unless we are in conflict resolution mode and unless it is disabled
563-
if (this.autoSaveAfterMilliesEnabled) {
564-
if (!this.inConflictMode) {
565-
this.doAutoSave(this.versionId);
566-
} else {
567-
this.logService.trace('makeDirty() - prevented save because we are in conflict resolution mode', this.resource);
568-
}
569-
}
570-
571537
// Handle content change events
572538
this.contentChangeEventScheduler.schedule();
573539
}
@@ -593,37 +559,13 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
593559
}
594560
}
595561

596-
private doAutoSave(versionId: number): void {
597-
this.logService.trace(`doAutoSave() - enter for versionId ${versionId}`, this.resource);
598-
599-
// Cancel any currently running auto saves to make this the one that succeeds
600-
this.autoSaveDisposable.clear();
601-
602-
// Create new save timer and store it for disposal as needed
603-
const handle = setTimeout(() => {
604-
605-
// Clear the timeout now that we are running
606-
this.autoSaveDisposable.clear();
607-
608-
// Only trigger save if the version id has not changed meanwhile
609-
if (versionId === this.versionId) {
610-
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
611-
}
612-
}, this.autoSaveAfterMillies);
613-
614-
this.autoSaveDisposable.value = toDisposable(() => clearTimeout(handle));
615-
}
616-
617562
async save(options: ITextFileSaveOptions = Object.create(null)): Promise<boolean> {
618563
if (!this.isResolved()) {
619564
return false;
620565
}
621566

622567
this.logService.trace('save() - enter', this.resource);
623568

624-
// Cancel any currently running auto saves to make this the one that succeeds
625-
this.autoSaveDisposable.clear();
626-
627569
await this.doSave(this.versionId, options);
628570

629571
return true;
@@ -676,8 +618,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
676618
}
677619

678620
// Push all edit operations to the undo stack so that the user has a chance to
679-
// Ctrl+Z back to the saved version. We only do this when auto-save is turned off
680-
if (!this.autoSaveAfterMilliesEnabled && this.isResolved()) {
621+
// Ctrl+Z back to the saved version.
622+
if (this.isResolved()) {
681623
this.textEditorModel.pushStackElement();
682624
}
683625

0 commit comments

Comments
 (0)