Skip to content

Commit

Permalink
Support "Save As" for custom editors
Browse files Browse the repository at this point in the history
  • Loading branch information
msujew committed Feb 18, 2025
1 parent 1f57562 commit 57df82f
Show file tree
Hide file tree
Showing 6 changed files with 60 additions and 53 deletions.
12 changes: 11 additions & 1 deletion packages/core/src/browser/saveable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { MaybePromise } from '../common/types';
import { Key } from './keyboard/keys';
import { AbstractDialog } from './dialogs';
import { nls } from '../common/nls';
import { Disposable, DisposableCollection, isObject } from '../common';
import { Disposable, DisposableCollection, isObject, URI } from '../common';
import { BinaryBuffer } from '../common/buffer';

export type AutoSaveMode = 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange';
Expand All @@ -44,6 +44,10 @@ export interface Saveable {
* Saves dirty changes.
*/
save(options?: SaveOptions): MaybePromise<void>;
/**
* Performs the save operation with a new file name.
*/
saveAs?(options: SaveAsOptions): MaybePromise<void>;
/**
* Reverts dirty changes.
*/
Expand Down Expand Up @@ -87,6 +91,7 @@ export class DelegatingSaveable implements Saveable {
createSnapshot?(): Saveable.Snapshot;
applySnapshot?(snapshot: object): void;
serialize?(): Promise<BinaryBuffer>;
saveAs?(options: SaveAsOptions): MaybePromise<void>;

protected _delegate?: Saveable;
protected toDispose = new DisposableCollection();
Expand All @@ -110,6 +115,7 @@ export class DelegatingSaveable implements Saveable {
this.createSnapshot = delegate.createSnapshot?.bind(delegate);
this.applySnapshot = delegate.applySnapshot?.bind(delegate);
this.serialize = delegate.serialize?.bind(delegate);
this.saveAs = delegate.saveAs?.bind(delegate);
}

}
Expand Down Expand Up @@ -341,6 +347,10 @@ export interface SaveOptions {
readonly saveReason?: SaveReason;
}

export interface SaveAsOptions extends SaveOptions {
readonly target: URI;
}

/**
* The class name added to the dirty widget's title.
*/
Expand Down
41 changes: 24 additions & 17 deletions packages/filesystem/src/browser/filesystem-saveable-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class FilesystemSaveableService extends SaveableService {
override canSaveAs(widget: Widget | undefined): widget is Widget & SaveableSource & Navigatable {
return widget !== undefined
&& Saveable.isSource(widget)
&& (typeof widget.saveable.createSnapshot === 'function' || typeof widget.saveable.serialize === 'function')
&& (typeof widget.saveable.createSnapshot === 'function' || typeof widget.saveable.serialize === 'function' || typeof widget.saveable.saveAs === 'function')
&& typeof widget.saveable.revert === 'function'
&& Navigatable.is(widget)
&& widget.getResourceUri() !== undefined;
Expand Down Expand Up @@ -97,23 +97,30 @@ export class FilesystemSaveableService extends SaveableService {
*/
protected async saveSnapshot(sourceWidget: Widget & SaveableSource & Navigatable, target: URI, overwrite: boolean): Promise<void> {
const saveable = sourceWidget.saveable;
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);
if (saveable.saveAs) {
// Some widgets have their own "Save As" implementation, such as the custom plugin editors
await saveable.saveAs({
target
});
} 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.writeFile(target, buffer);
} else {
// Ensure to actually call `create` as that fires the `onDidCreate` event.
await this.fileService.createFile(target, buffer, { overwrite });
// Most other editors simply allow us to serialize the content and write it to the target file.
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.writeFile(target, buffer);
} else {
// Ensure to actually call `create` as that fires the `onDidCreate` event.
await this.fileService.createFile(target, buffer, { overwrite });
}
}
await saveable.revert!();
await open(this.openerService, target, { widgetOptions: { ref: sourceWidget, mode: 'tab-replace' } });
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-ext/src/common/plugin-api-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1900,8 +1900,8 @@ export interface CustomEditorsExt {
$redo(resource: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void>;
$revert(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void>;
$disposeEdits(resourceComponents: UriComponents, viewType: string, editIds: number[]): void;
$onSave(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void>;
$onSaveAs(resource: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise<void>;
$save(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void>;
$saveAs(resource: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise<void>;
// $backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<string>;
$onMoveCustomEditor(handle: string, newResource: UriComponents, viewType: string): Promise<void>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,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, DelegatingSaveable, NavigatableWidget, Saveable, SaveableSource, SaveOptions } from '@theia/core/lib/browser';
import { ApplicationShell, DelegatingSaveable, NavigatableWidget, Saveable, SaveableSource } from '@theia/core/lib/browser';
import { SaveableService } from '@theia/core/lib/browser/saveable-service';
import { Reference } from '@theia/core/lib/common/reference';
import { WebviewWidget } from '../webview/webview';
Expand Down Expand Up @@ -74,18 +74,6 @@ export class CustomEditorWidget extends WebviewWidget implements CustomEditorWid
this._modelRef.object?.redo();
}

async save(options?: SaveOptions): Promise<void> {
await this._modelRef.object?.saveCustomEditor(options);
}

async saveAs(source: URI, target: URI, options?: SaveOptions): Promise<void> {
if (this._modelRef.object) {
const result = await this._modelRef.object.saveCustomEditorAs(source, target, options);
this.doMove(target);
return result;
}
}

getResourceUri(): URI | undefined {
return this.resource;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { UndoRedoService } from '@theia/editor/lib/browser/undo-redo-service';
import { WebviewsMainImpl } from '../webviews-main';
import { WidgetManager } from '@theia/core/lib/browser/widget-manager';
import { ApplicationShell, LabelProvider, Saveable, SaveOptions } from '@theia/core/lib/browser';
import { ApplicationShell, LabelProvider, Saveable, SaveAsOptions, SaveOptions } from '@theia/core/lib/browser';
import { WebviewPanelOptions } from '@theia/plugin';
import { EditorPreferences } from '@theia/editor/lib/browser';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';

const enum CustomEditorModelType {
Custom,
Expand Down Expand Up @@ -186,7 +187,7 @@ export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable {

switch (modelType) {
case CustomEditorModelType.Text: {
const model = CustomTextEditorModel.create(viewType, resource, this.textModelService, this.fileService);
const model = CustomTextEditorModel.create(viewType, resource, this.textModelService);
return this.customEditorService.models.add(resource, viewType, model);
}
case CustomEditorModelType.Custom: {
Expand Down Expand Up @@ -224,7 +225,7 @@ export interface CustomEditorModel extends Saveable, Disposable {

revert(options?: Saveable.RevertOptions): Promise<void>;
saveCustomEditor(options?: SaveOptions): Promise<void>;
saveCustomEditorAs(resource: TheiaURI, targetResource: TheiaURI, options?: SaveOptions): Promise<void>;
saveCustomEditorAs?(resource: TheiaURI, targetResource: TheiaURI, options?: SaveOptions): Promise<void>;

undo(): void;
redo(): void;
Expand Down Expand Up @@ -347,7 +348,7 @@ export class MainCustomEditorModel implements CustomEditorModel {
}

const cancelable = new CancellationTokenSource();
const savePromise = this.proxy.$onSave(this.resource, this.viewType, cancelable.token);
const savePromise = this.proxy.$save(this.resource, this.viewType, cancelable.token);
this.ongoingSave?.cancel();
this.ongoingSave = cancelable;

Expand All @@ -367,10 +368,14 @@ export class MainCustomEditorModel implements CustomEditorModel {
}
}

async saveAs(options: SaveAsOptions): Promise<void> {
await this.saveCustomEditorAs(new TheiaURI(this.resource), options.target, options);
}

async saveCustomEditorAs(resource: TheiaURI, targetResource: TheiaURI, options?: SaveOptions): Promise<void> {
if (this.editable) {
const source = new CancellationTokenSource();
await this.proxy.$onSaveAs(this.resource, this.viewType, targetResource.toComponents(), source.token);
await this.proxy.$saveAs(this.resource, this.viewType, targetResource.toComponents(), source.token);
this.change(() => {
this.savePoint = this.currentEditIndex;
});
Expand Down Expand Up @@ -450,19 +455,17 @@ export class CustomTextEditorModel implements CustomEditorModel {
static async create(
viewType: string,
resource: TheiaURI,
editorModelService: EditorModelService,
fileService: FileService,
editorModelService: EditorModelService
): Promise<CustomTextEditorModel> {
const model = await editorModelService.createModelReference(resource);
model.object.suppressOpenEditorWhenDirty = true;
return new CustomTextEditorModel(viewType, resource, model, fileService);
return new CustomTextEditorModel(viewType, resource, model);
}

constructor(
readonly viewType: string,
readonly editorResource: TheiaURI,
private readonly model: Reference<MonacoEditorModel>,
private readonly fileService: FileService,
private readonly model: Reference<MonacoEditorModel>
) {
this.toDispose.push(
this.editorTextModel.onDirtyChanged(e => {
Expand Down Expand Up @@ -507,13 +510,12 @@ export class CustomTextEditorModel implements CustomEditorModel {
return this.saveCustomEditor(options);
}

saveCustomEditor(options?: SaveOptions): Promise<void> {
return this.editorTextModel.save(options);
serialize(): Promise<BinaryBuffer> {
return this.editorTextModel.serialize();
}

async saveCustomEditorAs(resource: TheiaURI, targetResource: TheiaURI, options?: SaveOptions): Promise<void> {
await this.saveCustomEditor(options);
await this.fileService.copy(resource, targetResource, { overwrite: false });
saveCustomEditor(options?: SaveOptions): Promise<void> {
return this.editorTextModel.save(options);
}

undo(): void {
Expand Down
10 changes: 5 additions & 5 deletions packages/plugin-ext/src/plugin/custom-editors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,12 @@ export class CustomEditorsExtImpl implements CustomEditorsExt {

async $undo(resourceComponents: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
return entry.undo(editId, isDirty);
await entry.undo(editId, isDirty);
}

async $redo(resourceComponents: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
return entry.redo(editId, isDirty);
await entry.redo(editId, isDirty);
}

async $revert(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void> {
Expand All @@ -198,16 +198,16 @@ export class CustomEditorsExtImpl implements CustomEditorsExt {
await provider.revertCustomDocument(entry.document, cancellation);
}

async $onSave(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void> {
async $save(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
const provider = this.getCustomEditorProvider(viewType);
await provider.saveCustomDocument(entry.document, cancellation);
}

async $onSaveAs(resourceComponents: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise<void> {
async $saveAs(resourceComponents: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise<void> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
const provider = this.getCustomEditorProvider(viewType);
return provider.saveCustomDocumentAs(entry.document, URI.revive(targetResource), cancellation);
await provider.saveCustomDocumentAs(entry.document, URI.revive(targetResource), cancellation);
}

private getCustomEditorProvider(viewType: string): theia.CustomEditorProvider {
Expand Down

0 comments on commit 57df82f

Please sign in to comment.