Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Support "Save As" for custom editors #14972

Merged
merged 1 commit into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -329,7 +330,7 @@ export class MainCustomEditorModel implements CustomEditorModel {
}

const cancellationSource = new CancellationTokenSource();
this.proxy.$revert(this.resource, this.viewType, cancellationSource.token);
await this.proxy.$revert(this.resource, this.viewType, cancellationSource.token);
this.change(() => {
this.isDirtyFromContentChange = false;
this.currentEditIndex = this.savePoint;
Expand All @@ -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
Loading