diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index ec72e2adb43..33b1b497c8d 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -1240,6 +1240,27 @@ declare module 'vscode' { * @return Thenable signaling that the change has completed. */ undoEdits(resource: Uri, edits: readonly EditType[]): Thenable; + + /** + * 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; } export interface WebviewCustomEditorProvider { diff --git a/src/vs/workbench/api/browser/mainThreadWebview.ts b/src/vs/workbench/api/browser/mainThreadWebview.ts index 86057be7284..5726232e475 100644 --- a/src/vs/workbench/api/browser/mainThreadWebview.ts +++ b/src/vs/workbench/api/browser/mainThreadWebview.ts @@ -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'; @@ -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; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index bd26f4a7aea..1a107144755 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -612,6 +612,8 @@ export interface ExtHostWebviewsShape { $onSave(resource: UriComponents, viewType: string): Promise; $onSaveAs(resource: UriComponents, viewType: string, targetResource: UriComponents): Promise; + + $backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise; } export interface MainThreadUrlsShape extends IDisposable { diff --git a/src/vs/workbench/api/common/extHostWebview.ts b/src/vs/workbench/api/common/extHostWebview.ts index aa41a27eadf..338232d2a77 100644 --- a/src/vs/workbench/api/common/extHostWebview.ts +++ b/src/vs/workbench/api/common/extHostWebview.ts @@ -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 }; @@ -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 { + 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); } @@ -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; } } diff --git a/src/vs/workbench/contrib/customEditor/common/customEditor.ts b/src/vs/workbench/contrib/customEditor/common/customEditor.ts index 1a5c109ea26..1b1aa52381b 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditor.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditor.ts @@ -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'; @@ -75,6 +76,8 @@ export interface ICustomEditorModel extends IWorkingCopy { readonly onWillSave: Event; readonly onWillSaveAs: Event; + onBackup(f: () => CancelablePromise): void; + undo(): void; redo(): void; revert(options?: IRevertOptions): Promise; diff --git a/src/vs/workbench/contrib/customEditor/common/customEditorModel.ts b/src/vs/workbench/contrib/customEditor/common/customEditorModel.ts index ff103cb3fdc..6448fbf2a63 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditorModel.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditorModel.ts @@ -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, + ) { } + } + + 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 = []; + private _hotExitState: HotExitState.State = HotExitState.NotSupported; constructor( public readonly viewType: string, private readonly _resource: URI, - private readonly labelService: ILabelService + private readonly labelService: ILabelService, ) { super(); } @@ -72,7 +97,20 @@ export class CustomEditorModel extends Disposable implements ICustomEditorModel protected readonly _onWillSaveAs = this._register(new Emitter()); readonly onWillSaveAs = this._onWillSaveAs.event; - public pushEdit(edit: CustomEditorEdit, trigger: any): void { + private _onBackup: undefined | (() => CancelablePromise); + + public onBackup(f: () => CancelablePromise) { + 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; @@ -196,4 +234,36 @@ export class CustomEditorModel extends Disposable implements ICustomEditorModel this.updateDirty(); this.updateContentChanged(); } + + public async backup(): Promise { + 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'); + } }