Skip to content

Refactor auto save mechanism #13683

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/api-tests/src/saveable.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,13 @@ describe('Saveable', function () {

afterEach(async () => {
toTearDown.dispose();
await preferences.set('files.autoSave', autoSave, undefined, rootUri.toString());
// @ts-ignore
editor = undefined;
// @ts-ignore
widget = undefined;
await editorManager.closeAll({ save: false });
await fileService.delete(fileUri.parent, { fromUserGesture: false, useTrash: false, recursive: true });
await preferences.set('files.autoSave', autoSave, undefined, rootUri.toString());
});

it('normal save', async function () {
Expand Down
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
Loading