diff --git a/CHANGELOG.md b/CHANGELOG.md
index 342df3ad32707..6f750f0c6c97b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@
[Breaking Changes:](#breaking_changes_not_yet_released) -->
+## 1.50.0
+
+[Breaking Changes:](#breaking_changes_1.50.0)
+
+- [core] Classes implementing the `Saveable` interface no longer need to implement the `autoSave` field. However, a new `onContentChanged` event has been added instead.
+
## v1.49.0 - 04/29/2024
- [application-manager] added logic to generate Extension Info in server application to avoid empty About Dialog [#13590](https://github.com/eclipse-theia/theia/pull/13590) - contributed on behalf of STMicroelectronics
diff --git a/examples/api-tests/src/saveable.spec.js b/examples/api-tests/src/saveable.spec.js
index 5a50637b786ca..091ae06f14ee0 100644
--- a/examples/api-tests/src/saveable.spec.js
+++ b/examples/api-tests/src/saveable.spec.js
@@ -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 () {
diff --git a/examples/api-tests/src/typescript.spec.js b/examples/api-tests/src/typescript.spec.js
index 89619d9138bf7..a2432070f1f77 100644
--- a/examples/api-tests/src/typescript.spec.js
+++ b/examples/api-tests/src/typescript.spec.js
@@ -64,7 +64,7 @@ describe('TypeScript', function () {
const rootUri = workspaceService.tryGetRoots()[0].resource;
const demoFileUri = rootUri.resolveToAbsolute('../api-tests/test-ts-workspace/demo-file.ts');
const definitionFileUri = rootUri.resolveToAbsolute('../api-tests/test-ts-workspace/demo-definitions-file.ts');
- let originalAutoSaveValue = preferences.inspect('files.autoSave').globalValue;
+ let originalAutoSaveValue = preferences.get('files.autoSave');
before(async function () {
await pluginService.didStart;
@@ -73,8 +73,9 @@ describe('TypeScript', function () {
throw new Error(pluginId + ' should be started');
}
await pluginService.activatePlugin(pluginId);
- }).concat(preferences.set('files.autoSave', 'off', PreferenceScope.User)));
- await preferences.set('files.refactoring.autoSave', 'off', PreferenceScope.User);
+ }));
+ await preferences.set('files.autoSave', 'off');
+ await preferences.set('files.refactoring.autoSave', 'off');
});
beforeEach(async function () {
@@ -90,7 +91,7 @@ describe('TypeScript', function () {
});
after(async () => {
- await preferences.set('files.autoSave', originalAutoSaveValue, PreferenceScope.User);
+ await preferences.set('files.autoSave', originalAutoSaveValue);
})
/**
diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts
index dc7d940e005c2..89a0532c05d45 100644
--- a/packages/core/src/browser/common-frontend-contribution.ts
+++ b/packages/core/src/browser/common-frontend-contribution.ts
@@ -62,7 +62,7 @@ import { WindowService } from './window/window-service';
import { FrontendApplicationConfigProvider } from './frontend-application-config-provider';
import { DecorationStyle } from './decoration-style';
import { isPinned, Title, togglePinned, Widget } from './widgets';
-import { SaveResourceService } from './save-resource-service';
+import { SaveableService } from './saveable-service';
import { UserWorkingDirectoryProvider } from './user-working-directory-provider';
import { UNTITLED_SCHEME, UntitledResourceResolver } from '../common';
import { LanguageQuickPickService } from './i18n/language-quick-pick-service';
@@ -385,7 +385,7 @@ export class CommonFrontendContribution implements FrontendApplicationContributi
@inject(OpenerService) protected readonly openerService: OpenerService,
@inject(AboutDialog) protected readonly aboutDialog: AboutDialog,
@inject(AsyncLocalizationProvider) protected readonly localizationProvider: AsyncLocalizationProvider,
- @inject(SaveResourceService) protected readonly saveResourceService: SaveResourceService,
+ @inject(SaveableService) protected readonly saveResourceService: SaveableService,
) { }
@inject(ContextKeyService)
diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts
index b343a8b4125c8..bc21b7277c135 100644
--- a/packages/core/src/browser/frontend-application-module.ts
+++ b/packages/core/src/browser/frontend-application-module.ts
@@ -126,7 +126,7 @@ import { DockPanel, RendererHost } from './widgets';
import { TooltipService, TooltipServiceImpl } from './tooltip-service';
import { BackendRequestService, RequestService, REQUEST_SERVICE_PATH } from '@theia/request';
import { bindFrontendStopwatch, bindBackendStopwatch } from './performance';
-import { SaveResourceService } from './save-resource-service';
+import { SaveableService } from './saveable-service';
import { SecondaryWindowHandler } from './secondary-window-handler';
import { UserWorkingDirectoryProvider } from './user-working-directory-provider';
import { WindowTitleService } from './window/window-title-service';
@@ -449,7 +449,9 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is
bindFrontendStopwatch(bind);
bindBackendStopwatch(bind);
- bind(SaveResourceService).toSelf().inSingletonScope();
+ bind(SaveableService).toSelf().inSingletonScope();
+ bind(FrontendApplicationContribution).toService(SaveableService);
+
bind(UserWorkingDirectoryProvider).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(UserWorkingDirectoryProvider);
diff --git a/packages/core/src/browser/index.ts b/packages/core/src/browser/index.ts
index fe5906c3be729..42277fc2f7833 100644
--- a/packages/core/src/browser/index.ts
+++ b/packages/core/src/browser/index.ts
@@ -46,3 +46,4 @@ export * from './tooltip-service';
export * from './decoration-style';
export * from './styling-service';
export * from './hover-service';
+export * from './saveable-service';
diff --git a/packages/core/src/browser/save-resource-service.ts b/packages/core/src/browser/save-resource-service.ts
deleted file mode 100644
index 82de2244315ab..0000000000000
--- a/packages/core/src/browser/save-resource-service.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/********************************************************************************
- * Copyright (C) 2022 Arm and others.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License v. 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0.
- *
- * This Source Code may also be made available under the following Secondary
- * Licenses when the conditions for such availability set forth in the Eclipse
- * Public License v. 2.0 are satisfied: GNU General Public License, version 2
- * with the GNU Classpath Exception which is available at
- * https://www.gnu.org/software/classpath/license.html.
- *
- * 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 { Navigatable, NavigatableWidget } from './navigatable-types';
-import { Saveable, SaveableSource, SaveOptions } from './saveable';
-import { Widget } from './widgets';
-
-@injectable()
-export class SaveResourceService {
- @inject(MessageService) protected readonly messageService: MessageService;
-
- /**
- * Indicate if the document can be saved ('Save' command should be disable if not).
- */
- canSave(widget?: Widget): widget is Widget & (Saveable | SaveableSource) {
- return Saveable.isDirty(widget) && (this.canSaveNotSaveAs(widget) || this.canSaveAs(widget));
- }
-
- canSaveNotSaveAs(widget?: Widget): widget is Widget & (Saveable | SaveableSource) {
- // By default, we never allow a document to be saved if it is untitled.
- return Boolean(widget && NavigatableWidget.getUri(widget)?.scheme !== UNTITLED_SCHEME);
- }
-
- /**
- * Saves the document
- *
- * No op if the widget is not saveable.
- */
- async save(widget: Widget | undefined, options?: SaveOptions): Promise {
- if (this.canSaveNotSaveAs(widget)) {
- await Saveable.save(widget, options);
- return NavigatableWidget.getUri(widget);
- } else if (this.canSaveAs(widget)) {
- return this.saveAs(widget, options);
- }
- }
-
- canSaveAs(saveable?: Widget): saveable is Widget & SaveableSource & Navigatable {
- return false;
- }
-
- saveAs(sourceWidget: Widget & SaveableSource & Navigatable, options?: SaveOptions): Promise {
- return Promise.reject('Unsupported: The base SaveResourceService does not support saveAs action.');
- }
-}
diff --git a/packages/core/src/browser/saveable-service.ts b/packages/core/src/browser/saveable-service.ts
new file mode 100644
index 0000000000000..12b273019885a
--- /dev/null
+++ b/packages/core/src/browser/saveable-service.ts
@@ -0,0 +1,328 @@
+/********************************************************************************
+ * Copyright (C) 2022 Arm and others.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License v. 2.0 are satisfied: GNU General Public License, version 2
+ * with the GNU Classpath Exception which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
+ ********************************************************************************/
+
+import type { ApplicationShell } from './shell';
+import { injectable } from 'inversify';
+import { UNTITLED_SCHEME, URI, Disposable, DisposableCollection, Emitter, Event } from '../common';
+import { Navigatable, NavigatableWidget } from './navigatable-types';
+import { AutoSaveMode, Saveable, SaveableSource, SaveableWidget, SaveOptions, SaveReason, setDirty, close, PostCreationSaveableWidget, ShouldSaveDialog } from './saveable';
+import { waitForClosed, Widget } from './widgets';
+import { FrontendApplicationContribution } from './frontend-application-contribution';
+import { FrontendApplication } from './frontend-application';
+import throttle = require('lodash.throttle');
+
+@injectable()
+export class SaveableService implements FrontendApplicationContribution {
+
+ protected saveThrottles = new Map();
+ protected saveMode: AutoSaveMode = 'off';
+ protected saveDelay = 1000;
+ protected shell: ApplicationShell;
+
+ protected readonly onDidAutoSaveChangeEmitter = new Emitter();
+ protected readonly onDidAutoSaveDelayChangeEmitter = new Emitter();
+
+ get onDidAutoSaveChange(): Event {
+ return this.onDidAutoSaveChangeEmitter.event;
+ }
+
+ get onDidAutoSaveDelayChange(): Event {
+ return this.onDidAutoSaveDelayChangeEmitter.event;
+ }
+
+ 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 => {
+ this.saveThrottles.get(e)?.dispose();
+ this.saveThrottles.delete(e);
+ });
+ }
+
+ protected updateAutoSaveMode(mode: AutoSaveMode): void {
+ this.saveMode = mode;
+ this.onDidAutoSaveChangeEmitter.fire(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;
+ this.onDidAutoSaveDelayChangeEmitter.fire(delay);
+ }
+
+ registerSaveable(widget: Widget, saveable: Saveable): Disposable {
+ const saveThrottle = new AutoSaveThrottle(
+ saveable,
+ this,
+ () => {
+ if (this.saveMode === 'afterDelay' && this.shouldAutoSave(widget, saveable)) {
+ saveable.save({
+ saveReason: SaveReason.AfterDelay
+ });
+ }
+ },
+ this.addBlurListener(widget, saveable)
+ );
+ this.saveThrottles.set(widget, saveThrottle);
+ this.applySaveableWidget(widget, saveable);
+ 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;
+ }
+ }
+
+ protected applySaveableWidget(widget: Widget, saveable: Saveable): void {
+ if (SaveableWidget.is(widget)) {
+ return;
+ }
+ const saveableWidget = widget as PostCreationSaveableWidget;
+ setDirty(saveableWidget, saveable.dirty);
+ saveable.onDirtyChanged(() => setDirty(saveableWidget, saveable.dirty));
+ const closeWithSaving = this.createCloseWithSaving();
+ const closeWithoutSaving = () => this.closeWithoutSaving(saveableWidget, false);
+ Object.assign(saveableWidget, {
+ closeWithoutSaving,
+ closeWithSaving,
+ close: closeWithSaving,
+ [close]: saveableWidget.close,
+ });
+ }
+
+ protected createCloseWithSaving(): (this: SaveableWidget, options?: SaveableWidget.CloseOptions) => Promise {
+ let closing = false;
+ const doSave = this.closeWithSaving.bind(this);
+ return async function (this: SaveableWidget, options?: SaveableWidget.CloseOptions): Promise {
+ if (closing) {
+ return;
+ }
+ closing = true;
+ try {
+ await doSave(this, options);
+ } finally {
+ closing = false;
+ }
+ };
+ }
+
+ protected async closeWithSaving(widget: PostCreationSaveableWidget, options?: SaveableWidget.CloseOptions): Promise {
+ const result = await this.shouldSaveWidget(widget, options);
+ if (typeof result === 'boolean') {
+ if (result) {
+ await this.save(widget, {
+ saveReason: SaveReason.AfterDelay
+ });
+ if (!Saveable.isDirty(widget)) {
+ await widget.closeWithoutSaving();
+ }
+ } else {
+ await widget.closeWithoutSaving();
+ }
+ }
+ }
+
+ protected async shouldSaveWidget(widget: PostCreationSaveableWidget, options?: SaveableWidget.CloseOptions): Promise {
+ if (!Saveable.isDirty(widget)) {
+ return false;
+ }
+ if (this.autoSave !== 'off') {
+ return true;
+ }
+ const notLastWithDocument = !Saveable.closingWidgetWouldLoseSaveable(widget, Array.from(this.saveThrottles.keys()));
+ if (notLastWithDocument) {
+ return widget.closeWithoutSaving(false).then(() => undefined);
+ }
+ if (options && options.shouldSave) {
+ return options.shouldSave();
+ }
+ return new ShouldSaveDialog(widget).open();
+ }
+
+ protected async closeWithoutSaving(widget: PostCreationSaveableWidget, doRevert: boolean = true): Promise {
+ const saveable = Saveable.get(widget);
+ if (saveable && doRevert && saveable.dirty && saveable.revert) {
+ await saveable.revert();
+ }
+ widget[close]();
+ return waitForClosed(widget);
+ }
+
+ /**
+ * Indicate if the document can be saved ('Save' command should be disable if not).
+ */
+ canSave(widget?: Widget): widget is Widget & (Saveable | SaveableSource) {
+ return Saveable.isDirty(widget) && (this.canSaveNotSaveAs(widget) || this.canSaveAs(widget));
+ }
+
+ canSaveNotSaveAs(widget?: Widget): widget is Widget & (Saveable | SaveableSource) {
+ // By default, we never allow a document to be saved if it is untitled.
+ return Boolean(widget && NavigatableWidget.getUri(widget)?.scheme !== UNTITLED_SCHEME);
+ }
+
+ /**
+ * Saves the document
+ *
+ * No op if the widget is not saveable.
+ */
+ async save(widget: Widget | undefined, options?: SaveOptions): Promise {
+ if (this.canSaveNotSaveAs(widget)) {
+ await Saveable.save(widget, options);
+ return NavigatableWidget.getUri(widget);
+ } else if (this.canSaveAs(widget)) {
+ return this.saveAs(widget, options);
+ }
+ }
+
+ canSaveAs(saveable?: Widget): saveable is Widget & SaveableSource & Navigatable {
+ return false;
+ }
+
+ saveAs(sourceWidget: Widget & SaveableSource & Navigatable, options?: SaveOptions): Promise {
+ return Promise.reject('Unsupported: The base SaveResourceService does not support saveAs action.');
+ }
+}
+
+export class AutoSaveThrottle implements Disposable {
+
+ private _saveable: Saveable;
+ private _callback: () => void;
+ private _saveService: SaveableService;
+ private _disposable: DisposableCollection;
+ private _throttle?: ReturnType;
+
+ constructor(saveable: Saveable, saveService: SaveableService, callback: () => void, ...disposables: Disposable[]) {
+ this._callback = callback;
+ this._saveable = saveable;
+ this._saveService = saveService;
+ this._disposable = new DisposableCollection(
+ ...disposables,
+ saveable.onContentChanged(() => {
+ this.throttledSave();
+ }),
+ saveable.onDirtyChanged(() => {
+ this.throttledSave();
+ }),
+ saveService.onDidAutoSaveChange(() => {
+ this.throttledSave();
+ }),
+ saveService.onDidAutoSaveDelayChange(() => {
+ this.throttledSave(true);
+ })
+ );
+ }
+
+ protected throttledSave(reset = false): void {
+ this._throttle?.cancel();
+ if (reset) {
+ this._throttle = undefined;
+ }
+ if (this._saveService.autoSave === 'afterDelay' && this._saveable.dirty) {
+ if (!this._throttle) {
+ this._throttle = throttle(() => this._callback(), this._saveService.autoSaveDelay, {
+ leading: false,
+ trailing: true
+ });
+ }
+ this._throttle();
+ }
+ }
+
+ dispose(): void {
+ this._disposable.dispose();
+ }
+
+}
diff --git a/packages/core/src/browser/saveable.ts b/packages/core/src/browser/saveable.ts
index bfedeff9a19e6..2f9390ca2b8fd 100644
--- a/packages/core/src/browser/saveable.ts
+++ b/packages/core/src/browser/saveable.ts
@@ -20,14 +20,23 @@ import { Emitter, Event } from '../common/event';
import { MaybePromise } from '../common/types';
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;
- 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;
/**
* Saves dirty changes.
*/
@@ -53,11 +62,15 @@ export interface SaveableSource {
export class DelegatingSaveable implements Saveable {
dirty = false;
protected readonly onDirtyChangedEmitter = new Emitter();
+ protected readonly onContentChangedEmitter = new Emitter();
get onDirtyChanged(): Event {
return this.onDirtyChangedEmitter.event;
}
- autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange' = 'off';
+
+ get onContentChanged(): Event {
+ return this.onContentChangedEmitter.event;
+ }
async save(options?: SaveOptions): Promise {
await this._delegate?.save(options);
@@ -68,16 +81,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();
@@ -131,52 +147,6 @@ export namespace Saveable {
}
}
- async function closeWithoutSaving(this: PostCreationSaveableWidget, doRevert: boolean = true): Promise {
- const saveable = get(this);
- if (saveable && doRevert && saveable.dirty && saveable.revert) {
- await saveable.revert();
- }
- this[close]();
- return waitForClosed(this);
- }
-
- function createCloseWithSaving(
- getOtherSaveables?: () => Array,
- doSave?: (widget: Widget, options?: SaveOptions) => Promise
- ): (this: SaveableWidget, options?: SaveableWidget.CloseOptions) => Promise {
- let closing = false;
- return async function (this: SaveableWidget, options: SaveableWidget.CloseOptions): Promise {
- if (closing) { return; }
- const saveable = get(this);
- if (!saveable) { return; }
- closing = true;
- try {
- const result = await shouldSave(saveable, () => {
- const notLastWithDocument = !closingWidgetWouldLoseSaveable(this, getOtherSaveables?.() ?? []);
- if (notLastWithDocument) {
- return this.closeWithoutSaving(false).then(() => undefined);
- }
- if (options && options.shouldSave) {
- return options.shouldSave();
- }
- return new ShouldSaveDialog(this).open();
- });
- if (typeof result === 'boolean') {
- if (result) {
- await (doSave?.(this) ?? Saveable.save(this));
- if (!isDirty(this)) {
- await this.closeWithoutSaving();
- }
- } else {
- await this.closeWithoutSaving();
- }
- }
- } finally {
- closing = false;
- }
- };
- }
-
export async function confirmSaveBeforeClose(toClose: Iterable, others: Widget[]): Promise {
for (const widget of toClose) {
const saveable = Saveable.get(widget);
@@ -197,49 +167,9 @@ export namespace Saveable {
return true;
}
- /**
- * @param widget the widget that may be closed
- * @param others widgets that will not be closed.
- * @returns `true` if widget is saveable and no widget among the `others` refers to the same saveable. `false` otherwise.
- */
- function closingWidgetWouldLoseSaveable(widget: Widget, others: Widget[]): boolean {
- const saveable = get(widget);
- return !!saveable && !others.some(otherWidget => otherWidget !== widget && get(otherWidget) === saveable);
- }
-
- export function apply(
- widget: Widget,
- getOtherSaveables?: () => Array,
- doSave?: (widget: Widget, options?: SaveOptions) => Promise,
- ): SaveableWidget | undefined {
- if (SaveableWidget.is(widget)) {
- return widget;
- }
+ export function closingWidgetWouldLoseSaveable(widget: Widget, others: Widget[]): boolean {
const saveable = Saveable.get(widget);
- if (!saveable) {
- return undefined;
- }
- const saveableWidget = widget as SaveableWidget;
- setDirty(saveableWidget, saveable.dirty);
- saveable.onDirtyChanged(() => setDirty(saveableWidget, saveable.dirty));
- const closeWithSaving = createCloseWithSaving(getOtherSaveables, doSave);
- return Object.assign(saveableWidget, {
- closeWithoutSaving,
- closeWithSaving,
- close: closeWithSaving,
- [close]: saveableWidget.close,
- });
- }
- export async function shouldSave(saveable: Saveable, cb: () => MaybePromise): Promise {
- if (!saveable.dirty) {
- return false;
- }
-
- if (saveable.autoSave !== 'off') {
- return true;
- }
-
- return cb();
+ return !!saveable && !others.some(otherWidget => otherWidget !== widget && Saveable.get(otherWidget) === saveable);
}
}
@@ -302,11 +232,27 @@ export const enum FormatType {
DIRTY
};
+export enum SaveReason {
+ Manual = 1,
+ AfterDelay = 2,
+ FocusChange = 3
+}
+
+export namespace SaveReason {
+ export function isManual(reason?: number): reason is typeof SaveReason.Manual {
+ return reason === SaveReason.Manual;
+ }
+}
+
export interface SaveOptions {
/**
* Formatting type to apply when saving.
*/
readonly formatType?: FormatType;
+ /**
+ * The reason for saving the resource.
+ */
+ readonly saveReason?: SaveReason;
}
/**
diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts
index 424c0bf43dcb5..c752bffdf1827 100644
--- a/packages/core/src/browser/shell/application-shell.ts
+++ b/packages/core/src/browser/shell/application-shell.ts
@@ -24,7 +24,7 @@ import { Message } from '@phosphor/messaging';
import { IDragEvent } from '@phosphor/dragdrop';
import { RecursivePartial, Event as CommonEvent, DisposableCollection, Disposable, environment, isObject } from '../../common';
import { animationFrame } from '../browser';
-import { Saveable, SaveableWidget, SaveOptions, SaveableSource } from '../saveable';
+import { Saveable, SaveableWidget, SaveOptions } from '../saveable';
import { StatusBarImpl, StatusBarEntry, StatusBarAlignment } from '../status-bar/status-bar';
import { TheiaDockPanel, BOTTOM_AREA_ID, MAIN_AREA_ID } from './theia-dock-panel';
import { SidePanelHandler, SidePanel, SidePanelHandlerFactory } from './side-panel-handler';
@@ -38,7 +38,7 @@ import { waitForRevealed, waitForClosed, PINNED_CLASS } from '../widgets';
import { CorePreferences } from '../core-preferences';
import { BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer';
import { Deferred } from '../../common/promise-util';
-import { SaveResourceService } from '../save-resource-service';
+import { SaveableService } from '../saveable-service';
import { nls } from '../../common/nls';
import { SecondaryWindowHandler } from '../secondary-window-handler';
import URI from '../../common/uri';
@@ -272,7 +272,7 @@ export class ApplicationShell extends Widget {
@inject(FrontendApplicationStateService) protected readonly applicationStateService: FrontendApplicationStateService,
@inject(ApplicationShellOptions) @optional() options: RecursivePartial = {},
@inject(CorePreferences) protected readonly corePreferences: CorePreferences,
- @inject(SaveResourceService) protected readonly saveResourceService: SaveResourceService,
+ @inject(SaveableService) protected readonly saveableService: SaveableService,
@inject(SecondaryWindowHandler) protected readonly secondaryWindowHandler: SecondaryWindowHandler,
@inject(WindowService) protected readonly windowService: WindowService
) {
@@ -1231,13 +1231,6 @@ export class ApplicationShell extends Widget {
}
this.tracker.add(widget);
this.checkActivation(widget);
- Saveable.apply(
- widget,
- () => this.widgets.filter((maybeSaveable): maybeSaveable is Widget & SaveableSource => !!Saveable.get(maybeSaveable)),
- async (toSave, options) => {
- await this.saveResourceService.save(toSave, options);
- },
- );
if (ApplicationShell.TrackableWidgetProvider.is(widget)) {
for (const toTrack of widget.getTrackableWidgets()) {
this.track(toTrack);
@@ -2043,21 +2036,21 @@ export class ApplicationShell extends Widget {
* Test whether the current widget is dirty.
*/
canSave(): boolean {
- return this.saveResourceService.canSave(this.currentWidget);
+ return this.saveableService.canSave(this.currentWidget);
}
/**
* Save the current widget if it is dirty.
*/
async save(options?: SaveOptions): Promise {
- await this.saveResourceService.save(this.currentWidget, options);
+ await this.saveableService.save(this.currentWidget, options);
}
/**
* Test whether there is a dirty widget.
*/
canSaveAll(): boolean {
- return this.tracker.widgets.some(widget => this.saveResourceService.canSave(widget));
+ return this.tracker.widgets.some(widget => this.saveableService.canSave(widget));
}
/**
@@ -2065,8 +2058,8 @@ export class ApplicationShell extends Widget {
*/
async saveAll(options?: SaveOptions): Promise {
for (const widget of this.widgets) {
- if (this.saveResourceService.canSaveNotSaveAs(widget)) {
- await this.saveResourceService.save(widget, options);
+ if (this.saveableService.canSaveNotSaveAs(widget)) {
+ await this.saveableService.save(widget, options);
}
}
}
diff --git a/packages/core/src/browser/window/default-secondary-window-service.ts b/packages/core/src/browser/window/default-secondary-window-service.ts
index 6bcd00b116489..4e415476f5887 100644
--- a/packages/core/src/browser/window/default-secondary-window-service.ts
+++ b/packages/core/src/browser/window/default-secondary-window-service.ts
@@ -21,6 +21,7 @@ import { ApplicationShell } from '../shell';
import { Saveable } from '../saveable';
import { PreferenceService } from '../preferences';
import { environment } from '../../common';
+import { SaveableService } from '../saveable-service';
@injectable()
export class DefaultSecondaryWindowService implements SecondaryWindowService {
@@ -43,6 +44,9 @@ export class DefaultSecondaryWindowService implements SecondaryWindowService {
@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;
+ @inject(SaveableService)
+ protected readonly saveResourceService: SaveableService;
+
@postConstruct()
init(): void {
// Set up messaging with secondary windows
@@ -93,7 +97,7 @@ export class DefaultSecondaryWindowService implements SecondaryWindowService {
newWindow.addEventListener('DOMContentLoaded', () => {
newWindow.addEventListener('beforeunload', evt => {
const saveable = Saveable.get(widget);
- const wouldLoseState = !!saveable && saveable.dirty && saveable.autoSave === 'off';
+ const wouldLoseState = !!saveable && saveable.dirty && this.saveResourceService.autoSave === 'off';
if (wouldLoseState) {
evt.returnValue = '';
evt.preventDefault();
@@ -104,7 +108,7 @@ export class DefaultSecondaryWindowService implements SecondaryWindowService {
newWindow.addEventListener('unload', () => {
const saveable = Saveable.get(widget);
shell.closeWidget(widget.id, {
- save: !!saveable && saveable.dirty && saveable.autoSave !== 'off'
+ save: !!saveable && saveable.dirty && this.saveResourceService.autoSave !== 'off'
});
const extIndex = this.secondaryWindows.indexOf(newWindow);
diff --git a/packages/editor/src/browser/editor-command.ts b/packages/editor/src/browser/editor-command.ts
index 0a721159659a1..80b13412b6ef3 100644
--- a/packages/editor/src/browser/editor-command.ts
+++ b/packages/editor/src/browser/editor-command.ts
@@ -15,15 +15,12 @@
// *****************************************************************************
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
-import { CommandContribution, CommandRegistry, Command } from '@theia/core/lib/common';
-import { CommonCommands, PreferenceService, LabelProvider, ApplicationShell, QuickInputService, QuickPickValue } from '@theia/core/lib/browser';
+import { CommonCommands, PreferenceService, LabelProvider, ApplicationShell, QuickInputService, QuickPickValue, SaveableService } from '@theia/core/lib/browser';
import { EditorManager } from './editor-manager';
-import { EditorPreferences } from './editor-preferences';
-import { ResourceProvider, MessageService } from '@theia/core';
+import { CommandContribution, CommandRegistry, Command, ResourceProvider, MessageService, nls } from '@theia/core';
import { LanguageService } from '@theia/core/lib/browser/language-service';
import { SUPPORTED_ENCODINGS } from '@theia/core/lib/browser/supported-encodings';
import { EncodingMode } from './editor';
-import { nls } from '@theia/core/lib/common/nls';
import { EditorLanguageQuickPickService } from './editor-language-quick-pick-service';
export namespace EditorCommands {
@@ -209,7 +206,8 @@ export namespace EditorCommands {
@injectable()
export class EditorCommandContribution implements CommandContribution {
- public static readonly AUTOSAVE_PREFERENCE: string = 'files.autoSave';
+ static readonly AUTOSAVE_PREFERENCE: string = 'files.autoSave';
+ static readonly AUTOSAVE_DELAY_PREFERENCE: string = 'files.autoSaveDelay';
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
@@ -217,13 +215,14 @@ export class EditorCommandContribution implements CommandContribution {
@inject(PreferenceService)
protected readonly preferencesService: PreferenceService;
- @inject(EditorPreferences)
- protected readonly editorPreferences: EditorPreferences;
+ @inject(SaveableService)
+ protected readonly saveResourceService: SaveableService;
@inject(QuickInputService) @optional()
protected readonly quickInputService: QuickInputService;
- @inject(MessageService) protected readonly messageService: MessageService;
+ @inject(MessageService)
+ protected readonly messageService: MessageService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@@ -242,9 +241,15 @@ export class EditorCommandContribution implements CommandContribution {
@postConstruct()
protected init(): void {
- this.editorPreferences.onPreferenceChanged(e => {
- if (e.preferenceName === 'files.autoSave' && e.newValue !== 'off') {
- this.shell.saveAll();
+ this.preferencesService.ready.then(() => {
+ this.saveResourceService.autoSave = this.preferencesService.get(EditorCommandContribution.AUTOSAVE_PREFERENCE) ?? 'off';
+ this.saveResourceService.autoSaveDelay = this.preferencesService.get(EditorCommandContribution.AUTOSAVE_DELAY_PREFERENCE) ?? 1000;
+ });
+ this.preferencesService.onPreferenceChanged(e => {
+ if (e.preferenceName === EditorCommandContribution.AUTOSAVE_PREFERENCE) {
+ this.saveResourceService.autoSave = this.preferencesService.get(EditorCommandContribution.AUTOSAVE_PREFERENCE) ?? 'off';
+ } else if (e.preferenceName === EditorCommandContribution.AUTOSAVE_DELAY_PREFERENCE) {
+ this.saveResourceService.autoSaveDelay = this.preferencesService.get(EditorCommandContribution.AUTOSAVE_DELAY_PREFERENCE) ?? 1000;
}
});
}
diff --git a/packages/filesystem/src/browser/filesystem-frontend-module.ts b/packages/filesystem/src/browser/filesystem-frontend-module.ts
index c15fad6790a11..3f960edff1e77 100644
--- a/packages/filesystem/src/browser/filesystem-frontend-module.ts
+++ b/packages/filesystem/src/browser/filesystem-frontend-module.ts
@@ -31,8 +31,8 @@ import { RemoteFileServiceContribution } from './remote-file-service-contributio
import { FileSystemWatcherErrorHandler } from './filesystem-watcher-error-handler';
import { FilepathBreadcrumbsContribution } from './breadcrumbs/filepath-breadcrumbs-contribution';
import { BreadcrumbsFileTreeWidget, createFileTreeBreadcrumbsWidget } from './breadcrumbs/filepath-breadcrumbs-container';
-import { FilesystemSaveResourceService } from './filesystem-save-resource-service';
-import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service';
+import { FilesystemSaveableService } from './filesystem-saveable-service';
+import { SaveableService } from '@theia/core/lib/browser/saveable-service';
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bindFileSystemPreferences(bind);
@@ -65,8 +65,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(FilepathBreadcrumbsContribution).toSelf().inSingletonScope();
bind(BreadcrumbsContribution).toService(FilepathBreadcrumbsContribution);
- bind(FilesystemSaveResourceService).toSelf().inSingletonScope();
- rebind(SaveResourceService).toService(FilesystemSaveResourceService);
+ bind(FilesystemSaveableService).toSelf().inSingletonScope();
+ rebind(SaveableService).toService(FilesystemSaveableService);
bind(FileTreeDecoratorAdapter).toSelf().inSingletonScope();
});
diff --git a/packages/filesystem/src/browser/filesystem-save-resource-service.ts b/packages/filesystem/src/browser/filesystem-saveable-service.ts
similarity index 90%
rename from packages/filesystem/src/browser/filesystem-save-resource-service.ts
rename to packages/filesystem/src/browser/filesystem-saveable-service.ts
index f992350d5fa78..39fe1e3eb3f98 100644
--- a/packages/filesystem/src/browser/filesystem-save-resource-service.ts
+++ b/packages/filesystem/src/browser/filesystem-saveable-service.ts
@@ -14,20 +14,25 @@
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
-import { environment, nls } from '@theia/core';
+import { environment, MessageService, nls } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Navigatable, Saveable, SaveableSource, SaveOptions, Widget, open, OpenerService, ConfirmDialog, FormatType, CommonCommands } from '@theia/core/lib/browser';
-import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service';
+import { SaveableService } from '@theia/core/lib/browser/saveable-service';
import URI from '@theia/core/lib/common/uri';
import { FileService } from './file-service';
import { FileDialogService } from './file-dialog';
@injectable()
-export class FilesystemSaveResourceService extends SaveResourceService {
+export class FilesystemSaveableService extends SaveableService {
- @inject(FileService) protected readonly fileService: FileService;
- @inject(FileDialogService) protected readonly fileDialogService: FileDialogService;
- @inject(OpenerService) protected readonly openerService: OpenerService;
+ @inject(MessageService)
+ protected readonly messageService: MessageService;
+ @inject(FileService)
+ protected readonly fileService: FileService;
+ @inject(FileDialogService)
+ protected readonly fileDialogService: FileDialogService;
+ @inject(OpenerService)
+ protected readonly openerService: OpenerService;
/**
* This method ensures a few things about `widget`:
diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts
index 32a4731254cab..eef64d64aef89 100644
--- a/packages/monaco/src/browser/monaco-editor-model.ts
+++ b/packages/monaco/src/browser/monaco-editor-model.ts
@@ -52,8 +52,6 @@ export interface MonacoModelContentChangedEvent {
export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDocument {
- autoSave: EditorPreferences['files.autoSave'] = 'afterDelay';
- autoSaveDelay = 500;
suppressOpenEditorWhenDirty = false;
lineNumbersMinChars = 3;
@@ -70,6 +68,10 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
protected readonly onDidChangeContentEmitter = new Emitter();
readonly onDidChangeContent = this.onDidChangeContentEmitter.event;
+ get onContentChanged(): Event {
+ return (listener, thisArgs, disposables) => this.onDidChangeContent(() => listener(), thisArgs, disposables);
+ }
+
protected readonly onDidSaveModelEmitter = new Emitter();
readonly onDidSaveModel = this.onDidSaveModelEmitter.event;
@@ -364,7 +366,7 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
}
save(options?: SaveOptions): Promise {
- return this.scheduleSave(TextDocumentSaveReason.Manual, undefined, undefined, options);
+ return this.scheduleSave(options?.saveReason ?? TextDocumentSaveReason.Manual, undefined, undefined, options);
}
protected pendingOperation = Promise.resolve();
@@ -452,23 +454,9 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
}
this.cancelSync();
this.setDirty(true);
- this.doAutoSave();
this.trace(log => log('MonacoEditorModel.markAsDirty - exit'));
}
- protected doAutoSave(): void {
- if (this.autoSave !== 'off' && this.resource.uri.scheme !== UNTITLED_SCHEME) {
- const token = this.cancelSave();
- this.toDisposeOnAutoSave.dispose();
- const handle = window.setTimeout(() => {
- this.scheduleSave(TextDocumentSaveReason.AfterDelay, token);
- }, this.autoSaveDelay);
- this.toDisposeOnAutoSave.push(Disposable.create(() =>
- window.clearTimeout(handle))
- );
- }
- }
-
protected saveCancellationTokenSource = new CancellationTokenSource();
protected cancelSave(): CancellationToken {
this.trace(log => log('MonacoEditorModel.cancelSave'));
diff --git a/packages/monaco/src/browser/monaco-text-model-service.ts b/packages/monaco/src/browser/monaco-text-model-service.ts
index 0dc538054bbe0..250d2e28d4780 100644
--- a/packages/monaco/src/browser/monaco-text-model-service.ts
+++ b/packages/monaco/src/browser/monaco-text-model-service.ts
@@ -156,16 +156,8 @@ export class MonacoTextModelService implements ITextModelService {
protected updateModel(model: MonacoEditorModel, change?: EditorPreferenceChange): void {
if (!change) {
- model.autoSave = this.editorPreferences.get('files.autoSave', undefined, model.uri);
- model.autoSaveDelay = this.editorPreferences.get('files.autoSaveDelay', undefined, model.uri);
model.textEditorModel.updateOptions(this.getModelOptions(model));
} else if (change.affects(model.uri, model.languageId)) {
- if (change.preferenceName === 'files.autoSave') {
- model.autoSave = this.editorPreferences.get('files.autoSave', undefined, model.uri);
- }
- if (change.preferenceName === 'files.autoSaveDelay') {
- model.autoSaveDelay = this.editorPreferences.get('files.autoSaveDelay', undefined, model.uri);
- }
const modelOption = this.toModelOption(change.preferenceName);
if (modelOption) {
model.textEditorModel.updateOptions(this.getModelOptions(model));
diff --git a/packages/monaco/src/browser/monaco-workspace.ts b/packages/monaco/src/browser/monaco-workspace.ts
index 394c1b52a368e..6debea8d9d55d 100644
--- a/packages/monaco/src/browser/monaco-workspace.ts
+++ b/packages/monaco/src/browser/monaco-workspace.ts
@@ -42,6 +42,7 @@ import { SnippetParser } from '@theia/monaco-editor-core/esm/vs/editor/contrib/s
import { TextEdit } from '@theia/monaco-editor-core/esm/vs/editor/common/languages';
import { SnippetController2 } from '@theia/monaco-editor-core/esm/vs/editor/contrib/snippet/browser/snippetController2';
import { isObject, MaybePromise, nls } from '@theia/core/lib/common';
+import { SaveableService } from '@theia/core/lib/browser';
export namespace WorkspaceFileEdit {
export function is(arg: Edit): arg is monaco.languages.IWorkspaceFileEdit {
@@ -124,6 +125,9 @@ export class MonacoWorkspace {
@inject(ProblemManager)
protected readonly problems: ProblemManager;
+ @inject(SaveableService)
+ protected readonly saveService: SaveableService;
+
@postConstruct()
protected init(): void {
this.resolveReady();
@@ -192,7 +196,7 @@ export class MonacoWorkspace {
// acquired by the editor, thus losing the changes that made it dirty.
this.textModelService.createModelReference(model.textEditorModel.uri).then(ref => {
(
- model.autoSave !== 'off' ? new Promise(resolve => model.onDidSaveModel(resolve)) :
+ this.saveService.autoSave !== 'off' ? new Promise(resolve => model.onDidSaveModel(resolve)) :
this.editorManager.open(new URI(model.uri), { mode: 'open' })
).then(
() => ref.dispose()
diff --git a/packages/notebook/src/browser/notebook-frontend-module.ts b/packages/notebook/src/browser/notebook-frontend-module.ts
index ca0fd71b6ee4f..c79f729c4e143 100644
--- a/packages/notebook/src/browser/notebook-frontend-module.ts
+++ b/packages/notebook/src/browser/notebook-frontend-module.ts
@@ -27,7 +27,7 @@ import { NotebookEditorWidgetFactory } from './notebook-editor-widget-factory';
import { NotebookCellResourceResolver, NotebookOutputResourceResolver } from './notebook-cell-resource-resolver';
import { NotebookModelResolverService } from './service/notebook-model-resolver-service';
import { NotebookCellActionContribution } from './contributions/notebook-cell-actions-contribution';
-import { createNotebookModelContainer, NotebookModel, NotebookModelFactory, NotebookModelProps } from './view-model/notebook-model';
+import { createNotebookModelContainer, NotebookModel, NotebookModelFactory, NotebookModelProps, NotebookModelResolverServiceProxy } from './view-model/notebook-model';
import { createNotebookCellModelContainer, NotebookCellModel, NotebookCellModelFactory, NotebookCellModelProps } from './view-model/notebook-cell-model';
import { createNotebookEditorWidgetContainer, NotebookEditorWidgetContainerFactory, NotebookEditorProps, NotebookEditorWidget } from './notebook-editor-widget';
import { NotebookActionsContribution } from './contributions/notebook-actions-contribution';
@@ -71,6 +71,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(NotebookCellResourceResolver).toSelf().inSingletonScope();
bind(ResourceResolver).toService(NotebookCellResourceResolver);
bind(NotebookModelResolverService).toSelf().inSingletonScope();
+ bind(NotebookModelResolverServiceProxy).toService(NotebookModelResolverService);
bind(NotebookOutputResourceResolver).toSelf().inSingletonScope();
bind(ResourceResolver).toService(NotebookOutputResourceResolver);
diff --git a/packages/notebook/src/browser/service/notebook-model-resolver-service.ts b/packages/notebook/src/browser/service/notebook-model-resolver-service.ts
index ccfee12e5a40c..949fc0b779621 100644
--- a/packages/notebook/src/browser/service/notebook-model-resolver-service.ts
+++ b/packages/notebook/src/browser/service/notebook-model-resolver-service.ts
@@ -108,7 +108,7 @@ export class NotebookModelResolverService {
return this.resolve(resource, viewType);
}
- protected async resolveExistingNotebookData(resource: Resource, viewType: string): Promise {
+ async resolveExistingNotebookData(resource: Resource, viewType: string): Promise {
if (resource.uri.scheme === 'untitled') {
return {
cells: [],
diff --git a/packages/notebook/src/browser/view-model/notebook-model.ts b/packages/notebook/src/browser/view-model/notebook-model.ts
index 5c12186b4c403..9fa632f3d33e4 100644
--- a/packages/notebook/src/browser/view-model/notebook-model.ts
+++ b/packages/notebook/src/browser/view-model/notebook-model.ts
@@ -33,6 +33,7 @@ import { NotebookCellModel, NotebookCellModelFactory } from './notebook-cell-mod
import { inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
import { UndoRedoService } from '@theia/editor/lib/browser/undo-redo-service';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
+import type { NotebookModelResolverService } from '../service/notebook-model-resolver-service';
export const NotebookModelFactory = Symbol('NotebookModelFactory');
@@ -45,6 +46,8 @@ export function createNotebookModelContainer(parent: interfaces.Container, props
return child;
}
+export const NotebookModelResolverServiceProxy = Symbol('NotebookModelResolverServiceProxy');
+
const NotebookModelProps = Symbol('NotebookModelProps');
export interface NotebookModelProps {
data: NotebookData;
@@ -68,6 +71,9 @@ export class NotebookModel implements Saveable, Disposable {
protected readonly onDidChangeContentEmitter = new QueueableEmitter();
readonly onDidChangeContent = this.onDidChangeContentEmitter.event;
+ protected readonly onContentChangedEmitter = new Emitter();
+ readonly onContentChanged = this.onContentChangedEmitter.event;
+
protected readonly onDidChangeSelectedCellEmitter = new Emitter();
readonly onDidChangeSelectedCell = this.onDidChangeSelectedCellEmitter.event;
@@ -89,15 +95,20 @@ export class NotebookModel implements Saveable, Disposable {
@inject(NotebookCellModelFactory)
protected cellModelFactory: NotebookCellModelFactory;
- readonly autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange';
+
+ @inject(NotebookModelResolverServiceProxy)
+ protected modelResolverService: NotebookModelResolverService;
protected nextHandle: number = 0;
protected _dirty = false;
set dirty(dirty: boolean) {
+ const oldState = this._dirty;
this._dirty = dirty;
- this.onDirtyChangedEmitter.fire();
+ if (oldState !== dirty) {
+ this.onDirtyChangedEmitter.fire();
+ }
}
get dirty(): boolean {
@@ -160,24 +171,16 @@ export class NotebookModel implements Saveable, Disposable {
this.dirtyCells = [];
this.dirty = false;
- const serializedNotebook = await this.props.serializer.fromNotebook({
- cells: this.cells.map(cell => cell.getData()),
- metadata: this.metadata
- });
+ const data = this.getData();
+ const serializedNotebook = await this.props.serializer.fromNotebook(data);
this.fileService.writeFile(this.uri, serializedNotebook);
this.onDidSaveNotebookEmitter.fire();
}
createSnapshot(): Saveable.Snapshot {
- const model = this;
return {
- read(): string {
- return JSON.stringify({
- cells: model.cells.map(cell => cell.getData()),
- metadata: model.metadata
- });
- }
+ read: () => JSON.stringify(this.getData())
};
}
@@ -191,6 +194,15 @@ export class NotebookModel implements Saveable, Disposable {
}
async revert(options?: Saveable.RevertOptions): Promise {
+ if (!options?.soft) {
+ // Load the data from the file again
+ try {
+ const data = await this.modelResolverService.resolveExistingNotebookData(this.props.resource, this.props.viewType);
+ this.setData(data, false);
+ } catch (err) {
+ console.error('Failed to revert notebook', err);
+ }
+ }
this.dirty = false;
}
@@ -205,21 +217,25 @@ export class NotebookModel implements Saveable, Disposable {
this.dirtyCells.splice(this.dirtyCells.indexOf(cell), 1);
}
- const oldDirtyState = this._dirty;
- this._dirty = this.dirtyCells.length > 0;
- if (this.dirty !== oldDirtyState) {
- this.onDirtyChangedEmitter.fire();
- }
+ this.dirty = this.dirtyCells.length > 0;
}
- setData(data: NotebookData): void {
+ setData(data: NotebookData, markDirty = true): void {
// Replace all cells in the model
+ this.dirtyCells = [];
this.replaceCells(0, this.cells.length, data.cells, false);
this.metadata = data.metadata;
- this.dirty = false;
+ this.dirty = markDirty;
this.onDidChangeContentEmitter.fire();
}
+ getData(): NotebookData {
+ return {
+ cells: this.cells.map(cell => cell.getData()),
+ metadata: this.metadata
+ };
+ }
+
undo(): void {
// TODO we probably need to check if a monaco editor is focused and if so, not undo
this.undoRedoService.undo(this.uri);
@@ -262,7 +278,7 @@ export class NotebookModel implements Saveable, Disposable {
end: edit.editType === CellEditType.Replace ? edit.index + edit.count : cellIndex,
originalIndex: index
};
- }).filter(edit => !!edit);
+ });
for (const { edit, cellIndex } of editsWithDetails) {
const cell = this.cells[cellIndex];
@@ -319,7 +335,7 @@ export class NotebookModel implements Saveable, Disposable {
}
this.onDidChangeContentEmitter.fire();
-
+ this.onContentChangedEmitter.fire();
}
protected replaceCells(start: number, deleteCount: number, newCells: CellData[], computeUndoRedo: boolean): void {
diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts
index 4f2e183a9063a..7c626f3b69357 100644
--- a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts
+++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts
@@ -18,7 +18,7 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'
import URI from '@theia/core/lib/common/uri';
import { FileOperation } from '@theia/filesystem/lib/common/files';
import { ApplicationShell, NavigatableWidget, Saveable, SaveableSource, SaveOptions } from '@theia/core/lib/browser';
-import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service';
+import { SaveableService } from '@theia/core/lib/browser/saveable-service';
import { Reference } from '@theia/core/lib/common/reference';
import { WebviewWidget } from '../webview/webview';
import { UndoRedoService } from '@theia/editor/lib/browser/undo-redo-service';
@@ -38,13 +38,6 @@ export class CustomEditorWidget extends WebviewWidget implements SaveableSource,
set modelRef(modelRef: Reference) {
this._modelRef = modelRef;
this.doUpdateContent();
- Saveable.apply(
- this,
- () => this.shell.widgets.filter(widget => !!Saveable.get(widget)),
- async (widget, options) => {
- await this.saveService.save(widget, options);
- },
- );
}
get saveable(): Saveable {
return this._modelRef.object;
@@ -56,8 +49,8 @@ export class CustomEditorWidget extends WebviewWidget implements SaveableSource,
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
- @inject(SaveResourceService)
- protected readonly saveService: SaveResourceService;
+ @inject(SaveableService)
+ protected readonly saveService: SaveableService;
@postConstruct()
protected override init(): void {
diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts
index a7951da9167ca..4368a500f1e69 100644
--- a/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts
+++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts
@@ -25,7 +25,7 @@ import { RPCProtocol } from '../../../common/rpc-protocol';
import { HostedPluginSupport } from '../../../hosted/browser/hosted-plugin';
import { PluginCustomEditorRegistry } from './plugin-custom-editor-registry';
import { CustomEditorWidget } from './custom-editor-widget';
-import { Emitter, UNTITLED_SCHEME } from '@theia/core';
+import { Emitter } from '@theia/core';
import { UriComponents } from '../../../common/uri-components';
import { URI } from '@theia/core/shared/vscode-uri';
import TheiaURI from '@theia/core/lib/common/uri';
@@ -189,7 +189,7 @@ export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable {
return this.customEditorService.models.add(resource, viewType, model);
}
case CustomEditorModelType.Custom: {
- const model = MainCustomEditorModel.create(this.proxy, viewType, resource, this.undoRedoService, this.fileService, this.editorPreferences, cancellationToken);
+ const model = MainCustomEditorModel.create(this.proxy, viewType, resource, this.undoRedoService, this.fileService, cancellationToken);
return this.customEditorService.models.add(resource, viewType, model);
}
}
@@ -297,8 +297,8 @@ export class MainCustomEditorModel implements CustomEditorModel {
private readonly onDirtyChangedEmitter = new Emitter();
readonly onDirtyChanged = this.onDirtyChangedEmitter.event;
- autoSave: 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange';
- autoSaveDelay: number;
+ private readonly onContentChangedEmitter = new Emitter();
+ readonly onContentChanged = this.onContentChangedEmitter.event;
static async create(
proxy: CustomEditorsExt,
@@ -306,11 +306,10 @@ export class MainCustomEditorModel implements CustomEditorModel {
resource: TheiaURI,
undoRedoService: UndoRedoService,
fileService: FileService,
- editorPreferences: EditorPreferences,
cancellation: CancellationToken,
): Promise {
const { editable } = await proxy.$createCustomDocument(resource.toComponents(), viewType, {}, cancellation);
- return new MainCustomEditorModel(proxy, viewType, resource, editable, undoRedoService, fileService, editorPreferences);
+ return new MainCustomEditorModel(proxy, viewType, resource, editable, undoRedoService, fileService);
}
constructor(
@@ -319,22 +318,8 @@ export class MainCustomEditorModel implements CustomEditorModel {
private readonly editorResource: TheiaURI,
private readonly editable: boolean,
private readonly undoRedoService: UndoRedoService,
- private readonly fileService: FileService,
- private readonly editorPreferences: EditorPreferences
+ private readonly fileService: FileService
) {
- this.autoSave = this.editorPreferences.get('files.autoSave', undefined, editorResource.toString());
- this.autoSaveDelay = this.editorPreferences.get('files.autoSaveDelay', undefined, editorResource.toString());
-
- this.toDispose.push(
- this.editorPreferences.onPreferenceChanged(event => {
- if (event.preferenceName === 'files.autoSave') {
- this.autoSave = this.editorPreferences.get('files.autoSave', undefined, editorResource.toString());
- }
- if (event.preferenceName === 'files.autoSaveDelay') {
- this.autoSaveDelay = this.editorPreferences.get('files.autoSaveDelay', undefined, editorResource.toString());
- }
- })
- );
this.toDispose.push(this.onDirtyChangedEmitter);
}
@@ -505,13 +490,7 @@ export class MainCustomEditorModel implements CustomEditorModel {
if (this.dirty !== wasDirty) {
this.onDirtyChangedEmitter.fire();
}
-
- if (this.autoSave !== 'off' && this.dirty && this.resource.scheme !== UNTITLED_SCHEME) {
- const handle = window.setTimeout(() => {
- this.save();
- window.clearTimeout(handle);
- }, this.autoSaveDelay);
- }
+ this.onContentChangedEmitter.fire();
}
}
@@ -521,6 +500,8 @@ export class CustomTextEditorModel implements CustomEditorModel {
private readonly toDispose = new DisposableCollection();
private readonly onDirtyChangedEmitter = new Emitter();
readonly onDirtyChanged = this.onDirtyChangedEmitter.event;
+ private readonly onContentChangedEmitter = new Emitter();
+ readonly onContentChanged = this.onContentChangedEmitter.event;
static async create(
viewType: string,
@@ -544,15 +525,13 @@ export class CustomTextEditorModel implements CustomEditorModel {
this.onDirtyChangedEmitter.fire();
})
);
+ this.toDispose.push(
+ this.editorTextModel.onContentChanged(e => {
+ this.onContentChangedEmitter.fire();
+ })
+ );
this.toDispose.push(this.onDirtyChangedEmitter);
- }
-
- get autoSave(): 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange' {
- return this.editorTextModel.autoSave;
- }
-
- get autoSaveDelay(): number {
- return this.editorTextModel.autoSaveDelay;
+ this.toDispose.push(this.onContentChangedEmitter);
}
dispose(): void {
diff --git a/packages/plugin-ext/src/main/browser/editors-and-documents-main.ts b/packages/plugin-ext/src/main/browser/editors-and-documents-main.ts
index fc4fb43d05afc..0078b76ba4c39 100644
--- a/packages/plugin-ext/src/main/browser/editors-and-documents-main.ts
+++ b/packages/plugin-ext/src/main/browser/editors-and-documents-main.ts
@@ -32,7 +32,7 @@ import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { TextEditorMain } from './text-editor-main';
import { DisposableCollection, Emitter, URI } from '@theia/core';
import { EditorManager, EditorWidget } from '@theia/editor/lib/browser';
-import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service';
+import { SaveableService } from '@theia/core/lib/browser/saveable-service';
export class EditorsAndDocumentsMain implements Disposable {
@@ -43,7 +43,7 @@ export class EditorsAndDocumentsMain implements Disposable {
private readonly modelService: EditorModelService;
private readonly editorManager: EditorManager;
- private readonly saveResourceService: SaveResourceService;
+ private readonly saveResourceService: SaveableService;
private readonly onTextEditorAddEmitter = new Emitter();
private readonly onTextEditorRemoveEmitter = new Emitter();
@@ -64,7 +64,7 @@ export class EditorsAndDocumentsMain implements Disposable {
this.editorManager = container.get(EditorManager);
this.modelService = container.get(EditorModelService);
- this.saveResourceService = container.get(SaveResourceService);
+ this.saveResourceService = container.get(SaveableService);
this.stateComputer = new EditorAndDocumentStateComputer(d => this.onDelta(d), this.editorManager, this.modelService);
this.toDispose.push(this.stateComputer);
diff --git a/packages/workspace/src/browser/workspace-frontend-contribution.ts b/packages/workspace/src/browser/workspace-frontend-contribution.ts
index d8c28f14dd7be..21ae2698d297d 100644
--- a/packages/workspace/src/browser/workspace-frontend-contribution.ts
+++ b/packages/workspace/src/browser/workspace-frontend-contribution.ts
@@ -37,7 +37,7 @@ import { nls } from '@theia/core/lib/common/nls';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { UntitledWorkspaceExitDialog } from './untitled-workspace-exit-dialog';
-import { FilesystemSaveResourceService } from '@theia/filesystem/lib/browser/filesystem-save-resource-service';
+import { FilesystemSaveableService } from '@theia/filesystem/lib/browser/filesystem-saveable-service';
import { StopReason } from '@theia/core/lib/common/frontend-application-state';
export enum WorkspaceStates {
@@ -72,7 +72,7 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi
@inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService;
@inject(EncodingRegistry) protected readonly encodingRegistry: EncodingRegistry;
@inject(PreferenceConfigurations) protected readonly preferenceConfigurations: PreferenceConfigurations;
- @inject(FilesystemSaveResourceService) protected readonly saveService: FilesystemSaveResourceService;
+ @inject(FilesystemSaveableService) protected readonly saveService: FilesystemSaveableService;
@inject(WorkspaceFileService) protected readonly workspaceFileService: WorkspaceFileService;
configure(): void {