Skip to content

Commit

Permalink
Adds a backup method (#88948)
Browse files Browse the repository at this point in the history
Adds a backup method to the custom editor API proposal. This method allows custom editors to hook in to VS Code's hot exit behavior

If `backup` is not implemented, VS Code will assume that the custom editor cannot be hot exited.

When `backup` is implemented, VS Code will invoke the method after every edit (this is debounced). At this point, this extension should back up the current resource.  The result is a promise indicating if the backup was successful or not

VS Code will only hot exit if all backups were successful.
  • Loading branch information
mjbvz authored Jan 24, 2020
1 parent b60f43d commit f3dbcea
Show file tree
Hide file tree
Showing 6 changed files with 118 additions and 6 deletions.
21 changes: 21 additions & 0 deletions src/vs/vscode.proposed.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1240,6 +1240,27 @@ declare module 'vscode' {
* @return Thenable signaling that the change has completed.
*/
undoEdits(resource: Uri, edits: readonly EditType[]): Thenable<void>;

/**
* Back up `resource` in its current state.
*
* Backups are used for hot exit and to prevent data loss. Your `backup` method should persist the resource in
* its current state, i.e. with the edits applied. Most commonly this means saving the resource to disk in
* the `ExtensionContext.storagePath`. When VS Code reloads and your custom editor is opened for a resource,
* your extension should first check to see if any backups exist for the resource. If there is a backup, your
* extension should load the file contents from there instead of from the resource in the workspace.
*
* `backup` is triggered whenever an edit it made. Calls to `backup` are debounced so that if multiple edits are
* made in quick succession, `backup` is only triggered after the last one. `backup` is not invoked when
* `auto save` is enabled (since auto save already persists resource ).
*
* @param resource The resource to back up.
* @param cancellation Token that signals the current backup since a new backup is coming in. It is up to your
* extension to decided how to respond to cancellation. If for example your extension is backing up a large file
* in an operation that takes time to complete, your extension may decide to finish the ongoing backup rather
* than cancelling it to ensure that VS Code has some valid backup.
*/
backup?(resource: Uri, cancellation: CancellationToken): Thenable<boolean>;
}

export interface WebviewCustomEditorProvider {
Expand Down
6 changes: 5 additions & 1 deletion src/vs/workbench/api/browser/mainThreadWebview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { createCancelablePromise } from 'vs/base/common/async';
import { onUnexpectedError } from 'vs/base/common/errors';
import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
import { Schemas } from 'vs/base/common/network';
Expand Down Expand Up @@ -355,7 +356,10 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma
});

if (capabilitiesSet.has(extHostProtocol.WebviewEditorCapabilities.SupportsHotExit)) {
// TODO: Hook up hot exit / backup logic
model.onBackup(() => {
return createCancelablePromise(token =>
this._proxy.$backup(model.resource.toJSON(), viewType, token));
});
}

return model;
Expand Down
2 changes: 2 additions & 0 deletions src/vs/workbench/api/common/extHost.protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,8 @@ export interface ExtHostWebviewsShape {

$onSave(resource: UriComponents, viewType: string): Promise<void>;
$onSaveAs(resource: UriComponents, viewType: string, targetResource: UriComponents): Promise<void>;

$backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<boolean>;
}

export interface MainThreadUrlsShape extends IDisposable {
Expand Down
12 changes: 12 additions & 0 deletions src/vs/workbench/api/common/extHostWebview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type * as vscode from 'vscode';
import { Cache } from './cache';
import { ExtHostWebviewsShape, IMainContext, MainContext, MainThreadWebviewsShape, WebviewEditorCapabilities, WebviewPanelHandle, WebviewPanelViewStateData } from './extHost.protocol';
import { Disposable as VSCodeDisposable } from './extHostTypes';
import { CancellationToken } from 'vs/base/common/cancellation';

type IconPath = URI | { light: URI, dark: URI };

Expand Down Expand Up @@ -478,6 +479,14 @@ export class ExtHostWebviews implements ExtHostWebviewsShape {
return provider?.editingDelegate?.saveAs(URI.revive(resource), URI.revive(targetResource));
}

async $backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<boolean> {
const provider = this.getEditorProvider(viewType);
if (!provider?.editingDelegate?.backup) {
return false;
}
return provider.editingDelegate.backup(URI.revive(resource), cancellation);
}

private getWebviewPanel(handle: WebviewPanelHandle): ExtHostWebviewEditor | undefined {
return this._webviewPanels.get(handle);
}
Expand All @@ -491,6 +500,9 @@ export class ExtHostWebviews implements ExtHostWebviewsShape {
if (capabilities.editingDelegate) {
declaredCapabilites.push(WebviewEditorCapabilities.Editable);
}
if (capabilities.editingDelegate?.backup) {
declaredCapabilites.push(WebviewEditorCapabilities.SupportsHotExit);
}
return declaredCapabilites;
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/vs/workbench/contrib/customEditor/common/customEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { distinct, find, mergeSort } from 'vs/base/common/arrays';
import { CancelablePromise } from 'vs/base/common/async';
import { Event } from 'vs/base/common/event';
import * as glob from 'vs/base/common/glob';
import { basename } from 'vs/base/common/resources';
Expand Down Expand Up @@ -75,6 +76,8 @@ export interface ICustomEditorModel extends IWorkingCopy {
readonly onWillSave: Event<CustomEditorSaveEvent>;
readonly onWillSaveAs: Event<CustomEditorSaveAsEvent>;

onBackup(f: () => CancelablePromise<boolean>): void;

undo(): void;
redo(): void;
revert(options?: IRevertOptions): Promise<boolean>;
Expand Down
80 changes: 75 additions & 5 deletions src/vs/workbench/contrib/customEditor/common/customEditorModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,50 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { CancelablePromise } from 'vs/base/common/async';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { ICustomEditorModel, CustomEditorEdit, CustomEditorSaveAsEvent, CustomEditorSaveEvent } from 'vs/workbench/contrib/customEditor/common/customEditor';
import { WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { ISaveOptions, IRevertOptions } from 'vs/workbench/common/editor';
import { IRevertOptions, ISaveOptions } from 'vs/workbench/common/editor';
import { CustomEditorEdit, CustomEditorSaveAsEvent, CustomEditorSaveEvent, ICustomEditorModel } from 'vs/workbench/contrib/customEditor/common/customEditor';
import { IWorkingCopyBackup, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { ILabelService } from 'vs/platform/label/common/label';
import { basename } from 'vs/base/common/path';

namespace HotExitState {
export const enum Type {
NotSupported,
Allowed,
NotAllowed,
Pending,
}

export const NotSupported = Object.freeze({ type: Type.NotSupported } as const);
export const Allowed = Object.freeze({ type: Type.Allowed } as const);
export const NotAllowed = Object.freeze({ type: Type.NotAllowed } as const);

export class Pending {
readonly type = Type.Pending;

constructor(
public readonly operation: CancelablePromise<boolean>,
) { }
}

export type State = typeof NotSupported | typeof Allowed | typeof NotAllowed | Pending;
}

export class CustomEditorModel extends Disposable implements ICustomEditorModel {

private _currentEditIndex: number = -1;
private _savePoint: number = -1;
private readonly _edits: Array<CustomEditorEdit> = [];
private _hotExitState: HotExitState.State = HotExitState.NotSupported;

constructor(
public readonly viewType: string,
private readonly _resource: URI,
private readonly labelService: ILabelService
private readonly labelService: ILabelService,
) {
super();
}
Expand Down Expand Up @@ -72,7 +97,20 @@ export class CustomEditorModel extends Disposable implements ICustomEditorModel
protected readonly _onWillSaveAs = this._register(new Emitter<CustomEditorSaveAsEvent>());
readonly onWillSaveAs = this._onWillSaveAs.event;

public pushEdit(edit: CustomEditorEdit, trigger: any): void {
private _onBackup: undefined | (() => CancelablePromise<boolean>);

public onBackup(f: () => CancelablePromise<boolean>) {
if (this._onBackup) {
throw new Error('Backup already implemented');
}
this._onBackup = f;

if (this._hotExitState === HotExitState.NotSupported) {
this._hotExitState = this.isDirty() ? HotExitState.NotAllowed : HotExitState.Allowed;
}
}

public pushEdit(edit: CustomEditorEdit, trigger: any) {
this.spliceEdits(edit);

this._currentEditIndex = this._edits.length - 1;
Expand Down Expand Up @@ -196,4 +234,36 @@ export class CustomEditorModel extends Disposable implements ICustomEditorModel
this.updateDirty();
this.updateContentChanged();
}

public async backup(): Promise<IWorkingCopyBackup> {
if (this._hotExitState === HotExitState.NotSupported) {
throw new Error('Not supported');
}

if (this._hotExitState.type === HotExitState.Type.Pending) {
this._hotExitState.operation.cancel();
}
this._hotExitState = HotExitState.NotAllowed;

const pendingState = new HotExitState.Pending(this._onBackup!());
this._hotExitState = pendingState;

try {
this._hotExitState = await pendingState.operation ? HotExitState.Allowed : HotExitState.NotAllowed;
} catch (e) {
// Make sure state has not changed in the meantime
if (this._hotExitState === pendingState) {
this._hotExitState = HotExitState.NotAllowed;
}
}

if (this._hotExitState === HotExitState.Allowed) {
return {
meta: {
viewType: this.viewType,
}
};
}
throw new Error('Cannot back up in this state');
}
}

0 comments on commit f3dbcea

Please sign in to comment.