Skip to content

Commit

Permalink
Refactor auto save mechanism
Browse files Browse the repository at this point in the history
  • Loading branch information
msujew committed May 5, 2024
1 parent b20a751 commit 55e4531
Show file tree
Hide file tree
Showing 14 changed files with 326 additions and 115 deletions.
2 changes: 2 additions & 0 deletions packages/core/src/browser/frontend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,8 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is
bindBackendStopwatch(bind);

bind(SaveResourceService).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(SaveResourceService);

bind(UserWorkingDirectoryProvider).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(UserWorkingDirectoryProvider);

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ export * from './tooltip-service';
export * from './decoration-style';
export * from './styling-service';
export * from './hover-service';
export * from './save-resource-service';
211 changes: 206 additions & 5 deletions packages/core/src/browser/save-resource-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,153 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/

import { inject, injectable } from 'inversify';
import { MessageService, UNTITLED_SCHEME, URI } from '../common';
import type { ApplicationShell } from './shell';
import { injectable } from 'inversify';
import { UNTITLED_SCHEME, URI, Disposable, DisposableCollection } from '../common';
import { Navigatable, NavigatableWidget } from './navigatable-types';
import { Saveable, SaveableSource, SaveOptions } from './saveable';
import { AutoSaveMode, Saveable, SaveableSource, SaveOptions, SaveReason } from './saveable';
import { Widget } from './widgets';
import { FrontendApplicationContribution } from './frontend-application-contribution';
import { FrontendApplication } from './frontend-application';
import throttle = require('lodash.throttle');

@injectable()
export class SaveResourceService {
@inject(MessageService) protected readonly messageService: MessageService;
export class SaveResourceService implements FrontendApplicationContribution {

protected saveThrottles = new Map<Saveable, AutoSaveThrottle>();
protected saveMode: AutoSaveMode = 'off';
protected saveDelay = 1000;
protected shell: ApplicationShell;

get autoSave(): AutoSaveMode {
return this.saveMode;
}

set autoSave(value: AutoSaveMode) {
this.updateAutoSaveMode(value);
}

get autoSaveDelay(): number {
return this.saveDelay;
}

set autoSaveDelay(value: number) {
this.updateAutoSaveDelay(value);
}

onDidInitializeLayout(app: FrontendApplication): void {
this.shell = app.shell;
// Register restored editors first
for (const widget of this.shell.widgets) {
const saveable = Saveable.get(widget);
if (saveable) {
this.registerSaveable(widget, saveable);
}
}
this.shell.onDidAddWidget(e => {
const saveable = Saveable.get(e);
if (saveable) {
this.registerSaveable(e, saveable);
}
});
this.shell.onDidChangeCurrentWidget(e => {
if (this.saveMode === 'onFocusChange') {
const widget = e.oldValue;
const saveable = Saveable.get(widget);
if (saveable && widget && this.shouldAutoSave(widget, saveable)) {
saveable.save({
saveReason: SaveReason.FocusChange
});
}
}
});
this.shell.onDidRemoveWidget(e => {
const saveable = Saveable.get(e);
if (saveable) {
this.saveThrottles.get(saveable)?.dispose();
this.saveThrottles.delete(saveable);
}
});
}

protected updateAutoSaveMode(mode: AutoSaveMode): void {
this.saveMode = mode;
for (const saveThrottle of this.saveThrottles.values()) {
saveThrottle.autoSave = mode;
}
if (mode === 'onFocusChange') {
// If the new mode is onFocusChange, we need to save all dirty documents that are not focused
const widgets = this.shell.widgets;
for (const widget of widgets) {
const saveable = Saveable.get(widget);
if (saveable && widget !== this.shell.currentWidget && this.shouldAutoSave(widget, saveable)) {
saveable.save({
saveReason: SaveReason.FocusChange
});
}
}
}
}

protected updateAutoSaveDelay(delay: number): void {
this.saveDelay = delay;
for (const saveThrottle of this.saveThrottles.values()) {
saveThrottle.autoSaveDelay = delay;
}
}

registerSaveable(widget: Widget, saveable: Saveable): Disposable {
const saveThrottle = new AutoSaveThrottle(
saveable,
() => {
if (this.saveMode === 'afterDelay' && this.shouldAutoSave(widget, saveable)) {
saveable.save({
saveReason: SaveReason.AfterDelay
});
}
},
this.addBlurListener(widget, saveable)
);
saveThrottle.autoSave = this.saveMode;
saveThrottle.autoSaveDelay = this.saveDelay;
this.saveThrottles.set(saveable, saveThrottle);
return saveThrottle;
}

protected addBlurListener(widget: Widget, saveable: Saveable): Disposable {
const document = widget.node.ownerDocument;
const listener = (() => {
if (this.saveMode === 'onWindowChange' && !this.windowHasFocus(document) && this.shouldAutoSave(widget, saveable)) {
saveable.save({
saveReason: SaveReason.FocusChange
});
}
}).bind(this);
document.addEventListener('blur', listener);
return Disposable.create(() => {
document.removeEventListener('blur', listener);
});
}

protected windowHasFocus(document: Document): boolean {
if (document.visibilityState === 'hidden') {
return false;
} else if (document.hasFocus()) {
return true;
}
// TODO: Add support for iframes
return false;
}

protected shouldAutoSave(widget: Widget, saveable: Saveable): boolean {
const uri = NavigatableWidget.getUri(widget);
if (uri?.scheme === UNTITLED_SCHEME) {
// Never auto-save untitled documents
return false;
} else {
return saveable.dirty;
}
}

/**
* Indicate if the document can be saved ('Save' command should be disable if not).
Expand Down Expand Up @@ -58,3 +196,66 @@ export class SaveResourceService {
return Promise.reject('Unsupported: The base SaveResourceService does not support saveAs action.');
}
}

export class AutoSaveThrottle implements Disposable {

private _saveable: Saveable;
private _cb: () => void;
private _disposable: DisposableCollection;
private _throttle?: ReturnType<typeof throttle>;
private _mode: AutoSaveMode = 'off';
private _autoSaveDelay = 1000;

get autoSave(): AutoSaveMode {
return this._mode;
}

set autoSave(value: AutoSaveMode) {
this._mode = value;
this.throttledSave();
}

get autoSaveDelay(): number {
return this._autoSaveDelay;
}

set autoSaveDelay(value: number) {
this._autoSaveDelay = value;
// Explicitly delete the throttle to recreate it with the new delay
this._throttle?.cancel();
this._throttle = undefined;
this.throttledSave();
}

constructor(saveable: Saveable, cb: () => void, ...disposables: Disposable[]) {
this._cb = cb;
this._saveable = saveable;
this._disposable = new DisposableCollection(
...disposables,
saveable.onContentChanged(() => {
this.throttledSave();
}),
saveable.onDirtyChanged(() => {
this.throttledSave();
})
);
}

protected throttledSave(): void {
this._throttle?.cancel();
if (this._mode === 'afterDelay' && this._saveable.dirty) {
if (!this._throttle) {
this._throttle = throttle(() => this._cb(), this._autoSaveDelay, {
leading: false,
trailing: true
});
}
this._throttle();
}
}

dispose(): void {
this._disposable.dispose();
}

}
61 changes: 48 additions & 13 deletions packages/core/src/browser/saveable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,22 @@ import { Key } from './keyboard/keys';
import { AbstractDialog } from './dialogs';
import { waitForClosed } from './widgets';
import { nls } from '../common/nls';
import { Disposable, isObject } from '../common';
import { DisposableCollection, isObject } from '../common';

export type AutoSaveMode = 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange';

export interface Saveable {
readonly dirty: boolean;
/**
* This event is fired when the content of the `dirty` variable changes.
*/
readonly onDirtyChanged: Event<void>;
readonly autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange';
/**
* This event is fired when the content of the saveable changes.
* While `onDirtyChanged` is fired to notify the UI that the widget is dirty,
* `onContentChanged` is used for the auto save throttling.
*/
readonly onContentChanged: Event<void>;
/**
* Saves dirty changes.
*/
Expand All @@ -53,11 +63,15 @@ export interface SaveableSource {
export class DelegatingSaveable implements Saveable {
dirty = false;
protected readonly onDirtyChangedEmitter = new Emitter<void>();
protected readonly onContentChangedEmitter = new Emitter<void>();

get onDirtyChanged(): Event<void> {
return this.onDirtyChangedEmitter.event;
}
autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange' = 'off';

get onContentChanged(): Event<void> {
return this.onContentChangedEmitter.event;
}

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

protected _delegate?: Saveable;
protected toDispose?: Disposable;
protected toDispose = new DisposableCollection();

set delegate(delegate: Saveable) {
this.toDispose?.dispose();
this.toDispose.dispose();
this.toDispose = new DisposableCollection();
this._delegate = delegate;
this.toDispose = delegate.onDirtyChanged(() => {
this.toDispose.push(delegate.onDirtyChanged(() => {
this.dirty = delegate.dirty;
this.onDirtyChangedEmitter.fire();
});
this.autoSave = delegate.autoSave;
}));
this.toDispose.push(delegate.onContentChanged(() => {
this.onContentChangedEmitter.fire();
}));
if (this.dirty !== delegate.dirty) {
this.dirty = delegate.dirty;
this.onDirtyChangedEmitter.fire();
Expand Down Expand Up @@ -142,6 +159,7 @@ export namespace Saveable {

function createCloseWithSaving(
getOtherSaveables?: () => Array<Widget | SaveableWidget>,
isAutoSaveEnabled?: () => boolean,
doSave?: (widget: Widget, options?: SaveOptions) => Promise<void>
): (this: SaveableWidget, options?: SaveableWidget.CloseOptions) => Promise<void> {
let closing = false;
Expand All @@ -152,6 +170,9 @@ export namespace Saveable {
closing = true;
try {
const result = await shouldSave(saveable, () => {
if (isAutoSaveEnabled?.()) {
return true;
}
const notLastWithDocument = !closingWidgetWouldLoseSaveable(this, getOtherSaveables?.() ?? []);
if (notLastWithDocument) {
return this.closeWithoutSaving(false).then(() => undefined);
Expand Down Expand Up @@ -209,6 +230,7 @@ export namespace Saveable {

export function apply(
widget: Widget,
isAutoSaveEnabled?: () => boolean,
getOtherSaveables?: () => Array<Widget | SaveableWidget>,
doSave?: (widget: Widget, options?: SaveOptions) => Promise<void>,
): SaveableWidget | undefined {
Expand All @@ -222,7 +244,7 @@ export namespace Saveable {
const saveableWidget = widget as SaveableWidget;
setDirty(saveableWidget, saveable.dirty);
saveable.onDirtyChanged(() => setDirty(saveableWidget, saveable.dirty));
const closeWithSaving = createCloseWithSaving(getOtherSaveables, doSave);
const closeWithSaving = createCloseWithSaving(getOtherSaveables, isAutoSaveEnabled, doSave);
return Object.assign(saveableWidget, {
closeWithoutSaving,
closeWithSaving,
Expand All @@ -235,10 +257,6 @@ export namespace Saveable {
return false;
}

if (saveable.autoSave !== 'off') {
return true;
}

return cb();
}
}
Expand Down Expand Up @@ -302,11 +320,28 @@ export const enum FormatType {
DIRTY
};

export namespace SaveReason {

export const Manual = 1;
export const AfterDelay = 2;
export const FocusChange = 3;

export function isManual(reason?: number): reason is typeof Manual {
return reason === Manual;
}
}

export type SaveReason = 1 | 2 | 3;

export interface SaveOptions {
/**
* Formatting type to apply when saving.
*/
readonly formatType?: FormatType;
/**
* The reason for saving the resource.
*/
readonly saveReason?: SaveReason;
}

/**
Expand Down
Loading

0 comments on commit 55e4531

Please sign in to comment.