Skip to content

Commit f3dbcea

Browse files
authored
Adds a backup method (microsoft#88948)
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.
1 parent b60f43d commit f3dbcea

6 files changed

Lines changed: 118 additions & 6 deletions

File tree

src/vs/vscode.proposed.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,27 @@ declare module 'vscode' {
12401240
* @return Thenable signaling that the change has completed.
12411241
*/
12421242
undoEdits(resource: Uri, edits: readonly EditType[]): Thenable<void>;
1243+
1244+
/**
1245+
* Back up `resource` in its current state.
1246+
*
1247+
* Backups are used for hot exit and to prevent data loss. Your `backup` method should persist the resource in
1248+
* its current state, i.e. with the edits applied. Most commonly this means saving the resource to disk in
1249+
* the `ExtensionContext.storagePath`. When VS Code reloads and your custom editor is opened for a resource,
1250+
* your extension should first check to see if any backups exist for the resource. If there is a backup, your
1251+
* extension should load the file contents from there instead of from the resource in the workspace.
1252+
*
1253+
* `backup` is triggered whenever an edit it made. Calls to `backup` are debounced so that if multiple edits are
1254+
* made in quick succession, `backup` is only triggered after the last one. `backup` is not invoked when
1255+
* `auto save` is enabled (since auto save already persists resource ).
1256+
*
1257+
* @param resource The resource to back up.
1258+
* @param cancellation Token that signals the current backup since a new backup is coming in. It is up to your
1259+
* extension to decided how to respond to cancellation. If for example your extension is backing up a large file
1260+
* in an operation that takes time to complete, your extension may decide to finish the ongoing backup rather
1261+
* than cancelling it to ensure that VS Code has some valid backup.
1262+
*/
1263+
backup?(resource: Uri, cancellation: CancellationToken): Thenable<boolean>;
12431264
}
12441265

12451266
export interface WebviewCustomEditorProvider {

src/vs/workbench/api/browser/mainThreadWebview.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

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

357358
if (capabilitiesSet.has(extHostProtocol.WebviewEditorCapabilities.SupportsHotExit)) {
358-
// TODO: Hook up hot exit / backup logic
359+
model.onBackup(() => {
360+
return createCancelablePromise(token =>
361+
this._proxy.$backup(model.resource.toJSON(), viewType, token));
362+
});
359363
}
360364

361365
return model;

src/vs/workbench/api/common/extHost.protocol.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,8 @@ export interface ExtHostWebviewsShape {
612612

613613
$onSave(resource: UriComponents, viewType: string): Promise<void>;
614614
$onSaveAs(resource: UriComponents, viewType: string, targetResource: UriComponents): Promise<void>;
615+
616+
$backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<boolean>;
615617
}
616618

617619
export interface MainThreadUrlsShape extends IDisposable {

src/vs/workbench/api/common/extHostWebview.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type * as vscode from 'vscode';
1818
import { Cache } from './cache';
1919
import { ExtHostWebviewsShape, IMainContext, MainContext, MainThreadWebviewsShape, WebviewEditorCapabilities, WebviewPanelHandle, WebviewPanelViewStateData } from './extHost.protocol';
2020
import { Disposable as VSCodeDisposable } from './extHostTypes';
21+
import { CancellationToken } from 'vs/base/common/cancellation';
2122

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

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

482+
async $backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<boolean> {
483+
const provider = this.getEditorProvider(viewType);
484+
if (!provider?.editingDelegate?.backup) {
485+
return false;
486+
}
487+
return provider.editingDelegate.backup(URI.revive(resource), cancellation);
488+
}
489+
481490
private getWebviewPanel(handle: WebviewPanelHandle): ExtHostWebviewEditor | undefined {
482491
return this._webviewPanels.get(handle);
483492
}
@@ -491,6 +500,9 @@ export class ExtHostWebviews implements ExtHostWebviewsShape {
491500
if (capabilities.editingDelegate) {
492501
declaredCapabilites.push(WebviewEditorCapabilities.Editable);
493502
}
503+
if (capabilities.editingDelegate?.backup) {
504+
declaredCapabilites.push(WebviewEditorCapabilities.SupportsHotExit);
505+
}
494506
return declaredCapabilites;
495507
}
496508
}

src/vs/workbench/contrib/customEditor/common/customEditor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

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

79+
onBackup(f: () => CancelablePromise<boolean>): void;
80+
7881
undo(): void;
7982
redo(): void;
8083
revert(options?: IRevertOptions): Promise<boolean>;

src/vs/workbench/contrib/customEditor/common/customEditorModel.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,50 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { CancelablePromise } from 'vs/base/common/async';
67
import { Emitter, Event } from 'vs/base/common/event';
78
import { Disposable } from 'vs/base/common/lifecycle';
89
import { URI } from 'vs/base/common/uri';
9-
import { ICustomEditorModel, CustomEditorEdit, CustomEditorSaveAsEvent, CustomEditorSaveEvent } from 'vs/workbench/contrib/customEditor/common/customEditor';
10-
import { WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
11-
import { ISaveOptions, IRevertOptions } from 'vs/workbench/common/editor';
10+
import { IRevertOptions, ISaveOptions } from 'vs/workbench/common/editor';
11+
import { CustomEditorEdit, CustomEditorSaveAsEvent, CustomEditorSaveEvent, ICustomEditorModel } from 'vs/workbench/contrib/customEditor/common/customEditor';
12+
import { IWorkingCopyBackup, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
1213
import { ILabelService } from 'vs/platform/label/common/label';
1314
import { basename } from 'vs/base/common/path';
1415

16+
namespace HotExitState {
17+
export const enum Type {
18+
NotSupported,
19+
Allowed,
20+
NotAllowed,
21+
Pending,
22+
}
23+
24+
export const NotSupported = Object.freeze({ type: Type.NotSupported } as const);
25+
export const Allowed = Object.freeze({ type: Type.Allowed } as const);
26+
export const NotAllowed = Object.freeze({ type: Type.NotAllowed } as const);
27+
28+
export class Pending {
29+
readonly type = Type.Pending;
30+
31+
constructor(
32+
public readonly operation: CancelablePromise<boolean>,
33+
) { }
34+
}
35+
36+
export type State = typeof NotSupported | typeof Allowed | typeof NotAllowed | Pending;
37+
}
38+
1539
export class CustomEditorModel extends Disposable implements ICustomEditorModel {
1640

1741
private _currentEditIndex: number = -1;
1842
private _savePoint: number = -1;
1943
private readonly _edits: Array<CustomEditorEdit> = [];
44+
private _hotExitState: HotExitState.State = HotExitState.NotSupported;
2045

2146
constructor(
2247
public readonly viewType: string,
2348
private readonly _resource: URI,
24-
private readonly labelService: ILabelService
49+
private readonly labelService: ILabelService,
2550
) {
2651
super();
2752
}
@@ -72,7 +97,20 @@ export class CustomEditorModel extends Disposable implements ICustomEditorModel
7297
protected readonly _onWillSaveAs = this._register(new Emitter<CustomEditorSaveAsEvent>());
7398
readonly onWillSaveAs = this._onWillSaveAs.event;
7499

75-
public pushEdit(edit: CustomEditorEdit, trigger: any): void {
100+
private _onBackup: undefined | (() => CancelablePromise<boolean>);
101+
102+
public onBackup(f: () => CancelablePromise<boolean>) {
103+
if (this._onBackup) {
104+
throw new Error('Backup already implemented');
105+
}
106+
this._onBackup = f;
107+
108+
if (this._hotExitState === HotExitState.NotSupported) {
109+
this._hotExitState = this.isDirty() ? HotExitState.NotAllowed : HotExitState.Allowed;
110+
}
111+
}
112+
113+
public pushEdit(edit: CustomEditorEdit, trigger: any) {
76114
this.spliceEdits(edit);
77115

78116
this._currentEditIndex = this._edits.length - 1;
@@ -196,4 +234,36 @@ export class CustomEditorModel extends Disposable implements ICustomEditorModel
196234
this.updateDirty();
197235
this.updateContentChanged();
198236
}
237+
238+
public async backup(): Promise<IWorkingCopyBackup> {
239+
if (this._hotExitState === HotExitState.NotSupported) {
240+
throw new Error('Not supported');
241+
}
242+
243+
if (this._hotExitState.type === HotExitState.Type.Pending) {
244+
this._hotExitState.operation.cancel();
245+
}
246+
this._hotExitState = HotExitState.NotAllowed;
247+
248+
const pendingState = new HotExitState.Pending(this._onBackup!());
249+
this._hotExitState = pendingState;
250+
251+
try {
252+
this._hotExitState = await pendingState.operation ? HotExitState.Allowed : HotExitState.NotAllowed;
253+
} catch (e) {
254+
// Make sure state has not changed in the meantime
255+
if (this._hotExitState === pendingState) {
256+
this._hotExitState = HotExitState.NotAllowed;
257+
}
258+
}
259+
260+
if (this._hotExitState === HotExitState.Allowed) {
261+
return {
262+
meta: {
263+
viewType: this.viewType,
264+
}
265+
};
266+
}
267+
throw new Error('Cannot back up in this state');
268+
}
199269
}

0 commit comments

Comments
 (0)