From c7af87240cfa035a6b53512d195a9d6a888b657c Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Thu, 20 Jun 2024 14:23:02 +0200 Subject: [PATCH] Serialize saveables to disk for "Save As" (#13833) --- packages/core/src/browser/saveable.ts | 10 ++++++++- .../browser/filesystem-saveable-service.ts | 22 ++++++++++++++----- .../monaco/src/browser/monaco-editor-model.ts | 5 +++++ .../src/browser/view-model/notebook-model.ts | 8 +++++-- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/core/src/browser/saveable.ts b/packages/core/src/browser/saveable.ts index f65a6a29162af..ecb4728a1c955 100644 --- a/packages/core/src/browser/saveable.ts +++ b/packages/core/src/browser/saveable.ts @@ -22,6 +22,7 @@ import { Key } from './keyboard/keys'; import { AbstractDialog } from './dialogs'; import { nls } from '../common/nls'; import { DisposableCollection, isObject } from '../common'; +import { BinaryBuffer } from '../common/buffer'; export type AutoSaveMode = 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange'; @@ -53,6 +54,10 @@ export interface Saveable { * Applies the given snapshot to the dirty state. */ applySnapshot?(snapshot: object): void; + /** + * Serializes the full state of the saveable item to a binary buffer. + */ + serialize?(): Promise; } export interface SaveableSource { @@ -79,6 +84,7 @@ export class DelegatingSaveable implements Saveable { revert?(options?: Saveable.RevertOptions): Promise; createSnapshot?(): Saveable.Snapshot; applySnapshot?(snapshot: object): void; + serialize?(): Promise; protected _delegate?: Saveable; protected toDispose = new DisposableCollection(); @@ -101,6 +107,7 @@ export class DelegatingSaveable implements Saveable { this.revert = delegate.revert?.bind(delegate); this.createSnapshot = delegate.createSnapshot?.bind(delegate); this.applySnapshot = delegate.applySnapshot?.bind(delegate); + this.serialize = delegate.serialize?.bind(delegate); } } @@ -115,7 +122,8 @@ export namespace Saveable { } /** - * A snapshot of a saveable item. Contains the full content of the saveable file in a string serializable format. + * A snapshot of a saveable item. + * Applying a snapshot of a saveable on another (of the same type) using the `applySnapshot` should yield the state of the original saveable. */ export type Snapshot = { value: string } | { read(): string | null }; export namespace Snapshot { diff --git a/packages/filesystem/src/browser/filesystem-saveable-service.ts b/packages/filesystem/src/browser/filesystem-saveable-service.ts index e7aaa51ce389e..804fad348b800 100644 --- a/packages/filesystem/src/browser/filesystem-saveable-service.ts +++ b/packages/filesystem/src/browser/filesystem-saveable-service.ts @@ -21,6 +21,7 @@ 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'; +import { BinaryBuffer } from '@theia/core/lib/common/buffer'; @injectable() export class FilesystemSaveableService extends SaveableService { @@ -39,13 +40,13 @@ export class FilesystemSaveableService extends SaveableService { /** * This method ensures a few things about `widget`: * - `widget.getResourceUri()` actually returns a URI. - * - `widget.saveable.createSnapshot` is defined. + * - `widget.saveable.createSnapshot` or `widget.saveable.serialize` is defined. * - `widget.saveable.revert` is defined. */ override canSaveAs(widget: Widget | undefined): widget is Widget & SaveableSource & Navigatable { return widget !== undefined && Saveable.isSource(widget) - && typeof widget.saveable.createSnapshot === 'function' + && (typeof widget.saveable.createSnapshot === 'function' || typeof widget.saveable.serialize === 'function') && typeof widget.saveable.revert === 'function' && Navigatable.is(widget) && widget.getResourceUri() !== undefined; @@ -96,14 +97,23 @@ export class FilesystemSaveableService extends SaveableService { */ protected async saveSnapshot(sourceWidget: Widget & SaveableSource & Navigatable, target: URI, overwrite: boolean): Promise { const saveable = sourceWidget.saveable; - const snapshot = saveable.createSnapshot!(); - const content = Saveable.Snapshot.read(snapshot) ?? ''; + let buffer: BinaryBuffer; + if (saveable.serialize) { + buffer = await saveable.serialize(); + } else if (saveable.createSnapshot) { + const snapshot = saveable.createSnapshot(); + const content = Saveable.Snapshot.read(snapshot) ?? ''; + buffer = BinaryBuffer.fromString(content); + } else { + throw new Error('Cannot save the widget as the saveable does not provide a snapshot or a serialize method.'); + } + if (await this.fileService.exists(target)) { // Do not fire the `onDidCreate` event as the file already exists. - await this.fileService.write(target, content); + await this.fileService.writeFile(target, buffer); } else { // Ensure to actually call `create` as that fires the `onDidCreate` event. - await this.fileService.create(target, content, { overwrite }); + await this.fileService.createFile(target, buffer, { overwrite }); } await saveable.revert!(); await open(this.openerService, target, { widgetOptions: { ref: sourceWidget, mode: 'tab-replace' } }); diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts index e8f6fa2629ef8..7d05e457d0d02 100644 --- a/packages/monaco/src/browser/monaco-editor-model.ts +++ b/packages/monaco/src/browser/monaco-editor-model.ts @@ -33,6 +33,7 @@ import { IModelService } from '@theia/monaco-editor-core/esm/vs/editor/common/se import { createTextBufferFactoryFromStream } from '@theia/monaco-editor-core/esm/vs/editor/common/model/textModel'; import { editorGeneratedPreferenceProperties } from '@theia/editor/lib/browser/editor-generated-preference-schema'; import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; +import { BinaryBuffer } from '@theia/core/lib/common/buffer'; export { TextDocumentSaveReason @@ -655,6 +656,10 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo this.model.setValue(value); } + async serialize(): Promise { + return BinaryBuffer.fromString(this.model.getValue()); + } + protected trace(loggable: Loggable): void { if (this.logger) { this.logger.debug((log: Log) => diff --git a/packages/notebook/src/browser/view-model/notebook-model.ts b/packages/notebook/src/browser/view-model/notebook-model.ts index b1f7f06a403c6..68e901157286b 100644 --- a/packages/notebook/src/browser/view-model/notebook-model.ts +++ b/packages/notebook/src/browser/view-model/notebook-model.ts @@ -34,6 +34,7 @@ import { inject, injectable, interfaces, postConstruct } from '@theia/core/share 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'; +import { BinaryBuffer } from '@theia/core/lib/common/buffer'; export const NotebookModelFactory = Symbol('NotebookModelFactory'); @@ -176,8 +177,7 @@ export class NotebookModel implements Saveable, Disposable { this.dirtyCells = []; this.dirty = false; - const data = this.getData(); - const serializedNotebook = await this.props.serializer.fromNotebook(data); + const serializedNotebook = await this.serialize(); this.fileService.writeFile(this.uri, serializedNotebook); this.onDidSaveNotebookEmitter.fire(); @@ -189,6 +189,10 @@ export class NotebookModel implements Saveable, Disposable { }; } + serialize(): Promise { + return this.props.serializer.fromNotebook(this.getData()); + } + async applySnapshot(snapshot: Saveable.Snapshot): Promise { const rawData = Saveable.Snapshot.read(snapshot); if (!rawData) {