Skip to content

Commit e7da65a

Browse files
committed
Refactor auto save mechanism
1 parent b20a751 commit e7da65a

File tree

14 files changed

+326
-115
lines changed

14 files changed

+326
-115
lines changed

packages/core/src/browser/frontend-application-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,8 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is
450450
bindBackendStopwatch(bind);
451451

452452
bind(SaveResourceService).toSelf().inSingletonScope();
453+
bind(FrontendApplicationContribution).toService(SaveResourceService);
454+
453455
bind(UserWorkingDirectoryProvider).toSelf().inSingletonScope();
454456
bind(FrontendApplicationContribution).toService(UserWorkingDirectoryProvider);
455457

packages/core/src/browser/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,4 @@ export * from './tooltip-service';
4646
export * from './decoration-style';
4747
export * from './styling-service';
4848
export * from './hover-service';
49+
export * from './save-resource-service';

packages/core/src/browser/save-resource-service.ts

Lines changed: 206 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,153 @@
1414
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
1515
********************************************************************************/
1616

17-
import { inject, injectable } from 'inversify';
18-
import { MessageService, UNTITLED_SCHEME, URI } from '../common';
17+
import type { ApplicationShell } from './shell';
18+
import { injectable } from 'inversify';
19+
import { UNTITLED_SCHEME, URI, Disposable, DisposableCollection } from '../common';
1920
import { Navigatable, NavigatableWidget } from './navigatable-types';
20-
import { Saveable, SaveableSource, SaveOptions } from './saveable';
21+
import { AutoSaveMode, Saveable, SaveableSource, SaveOptions, SaveReason } from './saveable';
2122
import { Widget } from './widgets';
23+
import { FrontendApplicationContribution } from './frontend-application-contribution';
24+
import { FrontendApplication } from './frontend-application';
25+
import throttle = require('lodash.throttle');
2226

2327
@injectable()
24-
export class SaveResourceService {
25-
@inject(MessageService) protected readonly messageService: MessageService;
28+
export class SaveResourceService implements FrontendApplicationContribution {
29+
30+
protected saveThrottles = new Map<Saveable, AutoSaveThrottle>();
31+
protected saveMode: AutoSaveMode = 'off';
32+
protected saveDelay = 1000;
33+
protected shell: ApplicationShell;
34+
35+
get autoSave(): AutoSaveMode {
36+
return this.saveMode;
37+
}
38+
39+
set autoSave(value: AutoSaveMode) {
40+
this.updateAutoSaveMode(value);
41+
}
42+
43+
get autoSaveDelay(): number {
44+
return this.saveDelay;
45+
}
46+
47+
set autoSaveDelay(value: number) {
48+
this.updateAutoSaveDelay(value);
49+
}
50+
51+
onDidInitializeLayout(app: FrontendApplication): void {
52+
this.shell = app.shell;
53+
// Register restored editors first
54+
for (const widget of this.shell.widgets) {
55+
const saveable = Saveable.get(widget);
56+
if (saveable) {
57+
this.registerSaveable(widget, saveable);
58+
}
59+
}
60+
this.shell.onDidAddWidget(e => {
61+
const saveable = Saveable.get(e);
62+
if (saveable) {
63+
this.registerSaveable(e, saveable);
64+
}
65+
});
66+
this.shell.onDidChangeCurrentWidget(e => {
67+
if (this.saveMode === 'onFocusChange') {
68+
const widget = e.oldValue;
69+
const saveable = Saveable.get(widget);
70+
if (saveable && widget && this.shouldAutoSave(widget, saveable)) {
71+
saveable.save({
72+
saveReason: SaveReason.FocusChange
73+
});
74+
}
75+
}
76+
});
77+
this.shell.onDidRemoveWidget(e => {
78+
const saveable = Saveable.get(e);
79+
if (saveable) {
80+
this.saveThrottles.get(saveable)?.dispose();
81+
this.saveThrottles.delete(saveable);
82+
}
83+
});
84+
}
85+
86+
protected updateAutoSaveMode(mode: AutoSaveMode): void {
87+
this.saveMode = mode;
88+
for (const saveThrottle of this.saveThrottles.values()) {
89+
saveThrottle.autoSave = mode;
90+
}
91+
if (mode === 'onFocusChange') {
92+
// If the new mode is onFocusChange, we need to save all dirty documents that are not focused
93+
const widgets = this.shell.widgets;
94+
for (const widget of widgets) {
95+
const saveable = Saveable.get(widget);
96+
if (saveable && widget !== this.shell.currentWidget && this.shouldAutoSave(widget, saveable)) {
97+
saveable.save({
98+
saveReason: SaveReason.FocusChange
99+
});
100+
}
101+
}
102+
}
103+
}
104+
105+
protected updateAutoSaveDelay(delay: number): void {
106+
this.saveDelay = delay;
107+
for (const saveThrottle of this.saveThrottles.values()) {
108+
saveThrottle.autoSaveDelay = delay;
109+
}
110+
}
111+
112+
registerSaveable(widget: Widget, saveable: Saveable): Disposable {
113+
const saveThrottle = new AutoSaveThrottle(
114+
saveable,
115+
() => {
116+
if (this.saveMode === 'afterDelay' && this.shouldAutoSave(widget, saveable)) {
117+
saveable.save({
118+
saveReason: SaveReason.AfterDelay
119+
});
120+
}
121+
},
122+
this.addBlurListener(widget, saveable)
123+
);
124+
saveThrottle.autoSave = this.saveMode;
125+
saveThrottle.autoSaveDelay = this.saveDelay;
126+
this.saveThrottles.set(saveable, saveThrottle);
127+
return saveThrottle;
128+
}
129+
130+
protected addBlurListener(widget: Widget, saveable: Saveable): Disposable {
131+
const document = widget.node.ownerDocument;
132+
const listener = (() => {
133+
if (this.saveMode === 'onWindowChange' && !this.windowHasFocus(document) && this.shouldAutoSave(widget, saveable)) {
134+
saveable.save({
135+
saveReason: SaveReason.FocusChange
136+
});
137+
}
138+
}).bind(this);
139+
document.addEventListener('blur', listener);
140+
return Disposable.create(() => {
141+
document.removeEventListener('blur', listener);
142+
});
143+
}
144+
145+
protected windowHasFocus(document: Document): boolean {
146+
if (document.visibilityState === 'hidden') {
147+
return false;
148+
} else if (document.hasFocus()) {
149+
return true;
150+
}
151+
// TODO: Add support for iframes
152+
return false;
153+
}
154+
155+
protected shouldAutoSave(widget: Widget, saveable: Saveable): boolean {
156+
const uri = NavigatableWidget.getUri(widget);
157+
if (uri?.scheme === UNTITLED_SCHEME) {
158+
// Never auto-save untitled documents
159+
return false;
160+
} else {
161+
return saveable.dirty;
162+
}
163+
}
26164

27165
/**
28166
* Indicate if the document can be saved ('Save' command should be disable if not).
@@ -58,3 +196,66 @@ export class SaveResourceService {
58196
return Promise.reject('Unsupported: The base SaveResourceService does not support saveAs action.');
59197
}
60198
}
199+
200+
export class AutoSaveThrottle implements Disposable {
201+
202+
private _saveable: Saveable;
203+
private _cb: () => void;
204+
private _disposable: DisposableCollection;
205+
private _throttle?: ReturnType<typeof throttle>;
206+
private _mode: AutoSaveMode = 'off';
207+
private _autoSaveDelay = 1000;
208+
209+
get autoSave(): AutoSaveMode {
210+
return this._mode;
211+
}
212+
213+
set autoSave(value: AutoSaveMode) {
214+
this._mode = value;
215+
this.throttledSave();
216+
}
217+
218+
get autoSaveDelay(): number {
219+
return this._autoSaveDelay;
220+
}
221+
222+
set autoSaveDelay(value: number) {
223+
this._autoSaveDelay = value;
224+
// Explicitly delete the throttle to recreate it with the new delay
225+
this._throttle?.cancel();
226+
this._throttle = undefined;
227+
this.throttledSave();
228+
}
229+
230+
constructor(saveable: Saveable, cb: () => void, ...disposables: Disposable[]) {
231+
this._cb = cb;
232+
this._saveable = saveable;
233+
this._disposable = new DisposableCollection(
234+
...disposables,
235+
saveable.onContentChanged(() => {
236+
this.throttledSave();
237+
}),
238+
saveable.onDirtyChanged(() => {
239+
this.throttledSave();
240+
})
241+
);
242+
}
243+
244+
protected throttledSave(): void {
245+
this._throttle?.cancel();
246+
if (this._mode === 'afterDelay' && this._saveable.dirty) {
247+
if (!this._throttle) {
248+
this._throttle = throttle(() => this._cb(), this._autoSaveDelay, {
249+
leading: false,
250+
trailing: true
251+
});
252+
}
253+
this._throttle();
254+
}
255+
}
256+
257+
dispose(): void {
258+
this._disposable.dispose();
259+
}
260+
261+
}

packages/core/src/browser/saveable.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,22 @@ import { Key } from './keyboard/keys';
2222
import { AbstractDialog } from './dialogs';
2323
import { waitForClosed } from './widgets';
2424
import { nls } from '../common/nls';
25-
import { Disposable, isObject } from '../common';
25+
import { DisposableCollection, isObject } from '../common';
26+
27+
export type AutoSaveMode = 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange';
2628

2729
export interface Saveable {
2830
readonly dirty: boolean;
31+
/**
32+
* This event is fired when the content of the `dirty` variable changes.
33+
*/
2934
readonly onDirtyChanged: Event<void>;
30-
readonly autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange';
35+
/**
36+
* This event is fired when the content of the saveable changes.
37+
* While `onDirtyChanged` is fired to notify the UI that the widget is dirty,
38+
* `onContentChanged` is used for the auto save throttling.
39+
*/
40+
readonly onContentChanged: Event<void>;
3141
/**
3242
* Saves dirty changes.
3343
*/
@@ -53,11 +63,15 @@ export interface SaveableSource {
5363
export class DelegatingSaveable implements Saveable {
5464
dirty = false;
5565
protected readonly onDirtyChangedEmitter = new Emitter<void>();
66+
protected readonly onContentChangedEmitter = new Emitter<void>();
5667

5768
get onDirtyChanged(): Event<void> {
5869
return this.onDirtyChangedEmitter.event;
5970
}
60-
autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange' = 'off';
71+
72+
get onContentChanged(): Event<void> {
73+
return this.onContentChangedEmitter.event;
74+
}
6175

6276
async save(options?: SaveOptions): Promise<void> {
6377
await this._delegate?.save(options);
@@ -68,16 +82,19 @@ export class DelegatingSaveable implements Saveable {
6882
applySnapshot?(snapshot: object): void;
6983

7084
protected _delegate?: Saveable;
71-
protected toDispose?: Disposable;
85+
protected toDispose = new DisposableCollection();
7286

7387
set delegate(delegate: Saveable) {
74-
this.toDispose?.dispose();
88+
this.toDispose.dispose();
89+
this.toDispose = new DisposableCollection();
7590
this._delegate = delegate;
76-
this.toDispose = delegate.onDirtyChanged(() => {
91+
this.toDispose.push(delegate.onDirtyChanged(() => {
7792
this.dirty = delegate.dirty;
7893
this.onDirtyChangedEmitter.fire();
79-
});
80-
this.autoSave = delegate.autoSave;
94+
}));
95+
this.toDispose.push(delegate.onContentChanged(() => {
96+
this.onContentChangedEmitter.fire();
97+
}));
8198
if (this.dirty !== delegate.dirty) {
8299
this.dirty = delegate.dirty;
83100
this.onDirtyChangedEmitter.fire();
@@ -142,6 +159,7 @@ export namespace Saveable {
142159

143160
function createCloseWithSaving(
144161
getOtherSaveables?: () => Array<Widget | SaveableWidget>,
162+
isAutoSaveEnabled?: () => boolean,
145163
doSave?: (widget: Widget, options?: SaveOptions) => Promise<void>
146164
): (this: SaveableWidget, options?: SaveableWidget.CloseOptions) => Promise<void> {
147165
let closing = false;
@@ -152,6 +170,9 @@ export namespace Saveable {
152170
closing = true;
153171
try {
154172
const result = await shouldSave(saveable, () => {
173+
if (isAutoSaveEnabled?.()) {
174+
return true;
175+
}
155176
const notLastWithDocument = !closingWidgetWouldLoseSaveable(this, getOtherSaveables?.() ?? []);
156177
if (notLastWithDocument) {
157178
return this.closeWithoutSaving(false).then(() => undefined);
@@ -209,6 +230,7 @@ export namespace Saveable {
209230

210231
export function apply(
211232
widget: Widget,
233+
isAutoSaveEnabled?: () => boolean,
212234
getOtherSaveables?: () => Array<Widget | SaveableWidget>,
213235
doSave?: (widget: Widget, options?: SaveOptions) => Promise<void>,
214236
): SaveableWidget | undefined {
@@ -222,7 +244,7 @@ export namespace Saveable {
222244
const saveableWidget = widget as SaveableWidget;
223245
setDirty(saveableWidget, saveable.dirty);
224246
saveable.onDirtyChanged(() => setDirty(saveableWidget, saveable.dirty));
225-
const closeWithSaving = createCloseWithSaving(getOtherSaveables, doSave);
247+
const closeWithSaving = createCloseWithSaving(getOtherSaveables, isAutoSaveEnabled, doSave);
226248
return Object.assign(saveableWidget, {
227249
closeWithoutSaving,
228250
closeWithSaving,
@@ -235,10 +257,6 @@ export namespace Saveable {
235257
return false;
236258
}
237259

238-
if (saveable.autoSave !== 'off') {
239-
return true;
240-
}
241-
242260
return cb();
243261
}
244262
}
@@ -302,11 +320,28 @@ export const enum FormatType {
302320
DIRTY
303321
};
304322

323+
export namespace SaveReason {
324+
325+
export const Manual = 1;
326+
export const AfterDelay = 2;
327+
export const FocusChange = 3;
328+
329+
export function isManual(reason?: number): reason is typeof Manual {
330+
return reason === Manual;
331+
}
332+
}
333+
334+
export type SaveReason = 1 | 2 | 3;
335+
305336
export interface SaveOptions {
306337
/**
307338
* Formatting type to apply when saving.
308339
*/
309340
readonly formatType?: FormatType;
341+
/**
342+
* The reason for saving the resource.
343+
*/
344+
readonly saveReason?: SaveReason;
310345
}
311346

312347
/**

0 commit comments

Comments
 (0)