From d9e58c3995cbb1131027596c4fec35a7001807dd Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Tue, 21 May 2024 00:52:06 +0200 Subject: [PATCH] Refactor auto save mechanism (#13683) --- CHANGELOG.md | 6 + examples/api-tests/src/saveable.spec.js | 2 +- examples/api-tests/src/typescript.spec.js | 9 +- .../browser/common-frontend-contribution.ts | 4 +- .../browser/frontend-application-module.ts | 6 +- packages/core/src/browser/index.ts | 1 + .../core/src/browser/save-resource-service.ts | 60 ---- packages/core/src/browser/saveable-service.ts | 328 ++++++++++++++++++ packages/core/src/browser/saveable.ts | 140 +++----- .../src/browser/shell/application-shell.ts | 23 +- .../default-secondary-window-service.ts | 8 +- packages/editor/src/browser/editor-command.ts | 29 +- .../src/browser/filesystem-frontend-module.ts | 8 +- ...vice.ts => filesystem-saveable-service.ts} | 17 +- .../monaco/src/browser/monaco-editor-model.ts | 22 +- .../src/browser/monaco-text-model-service.ts | 8 - .../monaco/src/browser/monaco-workspace.ts | 6 +- .../src/browser/notebook-frontend-module.ts | 3 +- .../notebook-model-resolver-service.ts | 2 +- .../src/browser/view-model/notebook-model.ts | 60 ++-- .../custom-editors/custom-editor-widget.ts | 13 +- .../custom-editors/custom-editors-main.ts | 51 +-- .../browser/editors-and-documents-main.ts | 6 +- .../workspace-frontend-contribution.ts | 4 +- 24 files changed, 510 insertions(+), 306 deletions(-) delete mode 100644 packages/core/src/browser/save-resource-service.ts create mode 100644 packages/core/src/browser/saveable-service.ts rename packages/filesystem/src/browser/{filesystem-save-resource-service.ts => filesystem-saveable-service.ts} (90%) 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 {