New custom editor API proposal

For #77131

Fixes #93963
Fixes #94515
Fixes #94517
Fixes #94527
Fixes #94509
Fixes #94514
Fixes #93996
Fixes #93913

This removes explicit edits from the API and reshapes the API to more closely match VS Code's internal API. The change also tries to better express the lifecycle of backups
This commit is contained in:
Matt Bierner 2020-04-08 17:53:28 -07:00
parent 1ed9dcda26
commit d4ce7148dd
10 changed files with 255 additions and 402 deletions

View file

@ -28,7 +28,7 @@ export class PreviewManager implements vscode.CustomEditorProvider {
) { }
public async openCustomDocument(uri: vscode.Uri) {
return new vscode.CustomDocument(uri);
return { uri, dispose: () => { } };
}
public async resolveCustomEditor(

View file

@ -151,7 +151,7 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview
}
public async openCustomDocument(uri: vscode.Uri) {
return new vscode.CustomDocument(uri);
return { uri, dispose: () => { } };
}
public async resolveCustomTextEditor(

View file

@ -1185,98 +1185,121 @@ declare module 'vscode' {
//#region Custom editor https://github.com/microsoft/vscode/issues/77131
/**
* Implements the editing functionality of a custom editor.
* Represents a custom document used by a [`CustomEditorProvider`](#CustomEditorProvider).
*
* This delegate is how custom editors hook into standard VS Code operations such as save and undo. The delegate
* is also how custom editors notify VS Code that an edit has taken place.
*
* @param EditType Type of edits used for the documents this delegate handles.
* Custom documents are only used within a given `CustomEditorProvider`. The lifecycle of a `CustomDocument` is
* managed by VS Code. When no more references remain to a `CustomDocument`, it is disposed of.
*/
interface CustomEditorEditingDelegate<EditType = unknown> {
interface CustomDocument {
/**
* The associated uri for this document.
*/
readonly uri: Uri;
/**
* Dispose of the custom document.
*
* This is invoked by VS Code when there are no more references to a given `CustomDocument` (for example when
* all editors associated with the document have been closed.)
*/
dispose(): void;
}
/**
* Event triggered by extensions to signal to VS Code that an edit has occurred on an [`EditableCustomDocument`](#EditableCustomDocument).
*/
interface CustomDocumentEditEvent {
/**
* Undo the edit operation.
*
* This is invoked by VS Code when the user triggers an undo.
*/
undo(): Thenable<void>;
/**
* Redo the edit operation.
*
* This is invoked by VS Code when the user triggers a redo.
*/
redo(): Thenable<void>;
/**
* Display name describing the edit.
*
* This is shown in the UI to users.
*/
readonly label?: string;
}
/**
* A backup for an [`EditableCustomDocument`](#EditableCustomDocument).
*/
interface CustomDocumentBackup {
/**
* Unique identifier for the backup.
*
* This id is passed back to your extension in `openCustomDocument` when opening a custom editor from a backup.
*/
readonly backupId: string;
/**
* Dispose of the current backup.
*
* This is called by VS Code when it is clear the current backup, such as when a new backup is made or when the
* file is saveed.
*/
dispose(): void;
}
/**
* Represents an editable custom document used by a [`CustomEditorProvider`](#CustomEditorProvider).
*
* `EditableCustomDocument` is how custom editors hook into standard VS Code operations such as save and undo. The
* document is also how custom editors notify VS Code that an edit has taken place.
*/
interface EditableCustomDocument extends CustomDocument {
/**
* Save the resource for a custom editor.
*
* This method is invoked by VS Code when the user saves a custom editor. This can happen when the user
* triggers save while the custom editor is active, by commands such as `save all`, or by auto save if enabled.
*
* To implement `save`, the delegate must persist the custom editor. This usually means writing the
* To implement `save`, the implementer must persist the custom editor. This usually means writing the
* file data for the custom document to disk. After `save` completes, any associated editor instances will
* no longer be marked as dirty.
*
* @param document Document to save.
* @param cancellation Token that signals the save is no longer required (for example, if another save was triggered).
*
* @return Thenable signaling that saving has completed.
*/
save(document: CustomDocument<EditType>, cancellation: CancellationToken): Thenable<void>;
save(cancellation: CancellationToken): Thenable<void>;
/**
* Save the resource for a custom editor to a different location.
*
* This method is invoked by VS Code when the user triggers `save as` on a custom editor.
*
* To implement `saveAs`, the delegate must persist the custom editor to `targetResource`. The
* To implement `saveAs`, the implementer must persist the custom editor to `targetResource`. The
* existing editor will remain open after `saveAs` completes.
*
* @param document Document to save.
* @param targetResource Location to save to.
* @param cancellation Token that signals the save is no longer required.
*
* @return Thenable signaling that saving has completed.
*/
saveAs(document: CustomDocument<EditType>, targetResource: Uri, cancellation: CancellationToken): Thenable<void>;
saveAs(targetResource: Uri, cancellation: CancellationToken): Thenable<void>;
/**
* Signal that an edit has occurred inside a custom editor.
*
* This event must be fired by your extension whenever an edit happens in a custom editor. An edit can be
* anything from changing some text, to cropping an image, to reordering a list. Your extension is free to
* anything from changing some text, to cropping an image, to reordering a list. Your extension is free to
* define what an edit is and what data is stored on each edit.
*
* VS Code uses edits to determine if a custom editor is dirty or not. VS Code also passes the edit objects back
* to your extension when triggers undo, redo, or revert (using the `undoEdits`, `applyEdits`, and `revert`
* methods of `CustomEditorEditingDelegate`)
* Firing this will cause VS Code to mark the editors as being dirty. This also allows the user to then undo and
* redo the edit in the custom editor.
*/
readonly onDidEdit: Event<CustomDocumentEditEvent<EditType>>;
/**
* Apply a list of edits to a custom editor.
*
* This method is invoked by VS Code when the user triggers `redo` in a custom editor.
*
* To implement `applyEdits`, the delegate must make sure all editor instances (webviews) for `document`
* are updated to render the document's new state (that is, every webview must be updated to show the document
* after applying `edits` to it).
*
* Note that `applyEdits` not invoked when `onDidEdit` is fired by your extension because `onDidEdit` implies
* that your extension has also updated its editor instances (webviews) to reflect the edit that just occurred.
*
* @param document Document to apply edits to.
* @param redoneEdits Array of edits that were redone. Sorted from oldest to most recent. Use [`document.appliedEdits`](#CustomDocument.appliedEdits)
* to get the full set of edits applied to the file (when `applyEdits` is called `appliedEdits` will already include
* the newly applied edit at the end).
*
* @return Thenable signaling that the change has completed.
*/
applyEdits(document: CustomDocument<EditType>, redoneEdits: ReadonlyArray<EditType>): Thenable<void>;
/**
* Undo a list of edits to a custom editor.
*
* This method is invoked by VS Code when the user triggers `undo` in a custom editor.
*
* To implement `undoEdits`, the delegate must make sure all editor instances (webviews) for `document`
* are updated to render the document's new state (that is, every webview must be updated to show the document
* after undoing `edits` from it).
*
* @param document Document to undo edits from.
* @param undoneEdits Array of undone edits. Sorted from most recent to oldest. Use [`document.appliedEdits`](#CustomDocument.appliedEdits)
* to get the full set of edits applied to the file (when `undoEdits` is called, `appliedEdits` will already include
* have the undone edits removed).
*
* @return Thenable signaling that the change has completed.
*/
undoEdits(document: CustomDocument<EditType>, undoneEdits: ReadonlyArray<EditType>): Thenable<void>;
readonly onDidEdit: Event<CustomDocumentEditEvent>;
/**
* Revert a custom editor to its last saved state.
@ -1284,21 +1307,18 @@ declare module 'vscode' {
* This method is invoked by VS Code when the user triggers `File: Revert File` in a custom editor. (Note that
* this is only used using VS Code's `File: Revert File` command and not on a `git revert` of the file).
*
* To implement `revert`, the delegate must make sure all editor instances (webviews) for `document`
* To implement `revert`, the implementer must make sure all editor instances (webviews) for `document`
* are displaying the document in the same state is saved in. This usually means reloading the file from the
* workspace.
*
* During `revert`, your extension should also clear any backups for the custom editor. Backups are only needed
* when there is a difference between an editor's state in VS Code and its save state on disk.
*
* @param document Document to revert.
* @param revert Object with added or removed edits to get back to the saved state. Use [`document.appliedEdits`](#CustomDocument.appliedEdits)
* to get the full set of edits applied to the file (when `revet` is called, `appliedEdits` will already have
* removed any edits undone by the revert and added any edits applied by the revert).
* @param cancellation Token that signals the revert is no longer required.
*
* @return Thenable signaling that the change has completed.
*/
revert(document: CustomDocument<EditType>, revert: CustomDocumentRevert<EditType>): Thenable<void>;
revert(cancellation: CancellationToken): Thenable<void>;
/**
* Back up the resource in its current state.
@ -1313,129 +1333,25 @@ declare module 'vscode' {
* 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 document Document to backup.
* @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(document: CustomDocument<EditType>, cancellation: CancellationToken): Thenable<void>;
backup(cancellation: CancellationToken): Thenable<CustomDocumentBackup>;
}
/**
* Event triggered by extensions to signal to VS Code that an edit has occurred on a `CustomDocument`.
*
* @param EditType Type of edits used for the document.
* Additional information about the opening custom document.
*/
interface CustomDocumentEditEvent<EditType = unknown> {
interface OpenCustomDocumentContext {
/**
* Document the edit is for.
*/
readonly document: CustomDocument<EditType>;
/**
* Object that describes the edit.
* The id of the backup to restore the document from or `undefined` if there is no backup.
*
* Edit objects are controlled entirely by your extension. Your extension should store whatever information it
* needs to on the edit to understand what type of edit was made, how to render that edit, and how to save that
* edit to disk.
*
* Edit objects are passed back to your extension in `CustomEditorEditingDelegate.undoEdits`,
* `CustomEditorEditingDelegate.applyEdits`, and `CustomEditorEditingDelegate.revert`. They can also be accessed
* using [`CustomDocument.appliedEdits`](#CustomDocument.appliedEdits) and [`CustomDocument.savedEdits`](#CustomDocument.savedEdits).
* If this is provided, your extension should restore the editor from the backup instead of reading the file
* the user's workspace.
*/
readonly edit: EditType;
/**
* Display name describing the edit.
*/
readonly label?: string;
}
/**
* Delta for edits undone/redone while reverting for a `CustomDocument`.
*
* @param EditType Type of edits used for the document being reverted.
*/
interface CustomDocumentRevert<EditType = unknown> {
/**
* List of edits that were undone to get the document back to its on disk state.
*/
readonly undoneEdits: ReadonlyArray<EditType>;
/**
* List of edits that were reapplied to get the document back to its on disk state.
*/
readonly appliedEdits: ReadonlyArray<EditType>;
}
/**
* Represents a custom document used by a [`CustomEditorProvider`](#CustomEditorProvider).
*
* Custom documents are only used within a given `CustomEditorProvider`. The lifecycle of a `CustomDocument` is
* managed by VS Code. When no more references remain to a `CustomDocument`, it is disposed of.
*
* @param EditType Type of edits used in this document.
*/
class CustomDocument<EditType = unknown> {
/**
* @param uri The associated resource for this document.
*/
constructor(uri: Uri);
/**
* The associated uri for this document.
*/
readonly uri: Uri;
/**
* Is this document representing an untitled file which has never been saved yet.
*/
readonly isUntitled: boolean;
/**
* The version number of this document (it will strictly increase after each
* change, including undo/redo).
*/
readonly version: number;
/**
* `true` if there are unpersisted changes.
*/
readonly isDirty: boolean;
/**
* List of edits from document open to the document's current state.
*
* `appliedEdits` returns a copy of the edit stack at the current point in time. Your extension should always
* use `CustomDocument.appliedEdits` to check the edit stack instead of holding onto a reference to `appliedEdits`.
*/
readonly appliedEdits: ReadonlyArray<EditType>;
/**
* List of edits from document open to the document's last saved point.
*
* The save point will be behind `appliedEdits` if the user saves and then continues editing,
* or in front of the last entry in `appliedEdits` if the user saves and then hits undo.
*
* `savedEdits` returns a copy of the edit stack at the current point in time. Your extension should always
* use `CustomDocument.savedEdits` to check the edit stack instead of holding onto a reference to `savedEdits`.
*/
readonly savedEdits: ReadonlyArray<EditType>;
/**
* `true` if the document has been closed. A closed document isn't synchronized anymore
* and won't be reused when the same resource is opened again.
*/
readonly isClosed: boolean;
/**
* Event fired when there are no more references to the `CustomDocument`.
*
* This happens when all custom editors for the document have been closed. Once a `CustomDocument` is disposed,
* it will not be reused when the same resource is opened again.
*/
readonly onDidDispose: Event<void>;
readonly backupId?: string;
}
/**
@ -1447,9 +1363,9 @@ declare module 'vscode' {
* You should use this type of custom editor when dealing with binary files or more complex scenarios. For simple
* text based documents, use [`CustomTextEditorProvider`](#CustomTextEditorProvider) instead.
*
* @param EditType Type of edits used by the editors of this provider.
* @param DocumentType Type of the custom document returned by this provider.
*/
export interface CustomEditorProvider<EditType = unknown> {
export interface CustomEditorProvider<DocumentType extends CustomDocument = CustomDocument> {
/**
* Create a new document for a given resource.
@ -1460,11 +1376,12 @@ declare module 'vscode' {
* this point will trigger another call to `openCustomDocument`.
*
* @param uri Uri of the document to open.
* @param openContext Additional information about the opening custom document.
* @param token A cancellation token that indicates the result is no longer needed.
*
* @return The custom document.
*/
openCustomDocument(uri: Uri, token: CancellationToken): Thenable<CustomDocument<EditType>> | CustomDocument<EditType>;
openCustomDocument(uri: Uri, openContext: OpenCustomDocumentContext, token: CancellationToken): Thenable<DocumentType> | DocumentType;
/**
* Resolve a custom editor for a given resource.
@ -1481,14 +1398,7 @@ declare module 'vscode' {
*
* @return Optional thenable indicating that the custom editor has been resolved.
*/
resolveCustomEditor(document: CustomDocument<EditType>, webviewPanel: WebviewPanel, token: CancellationToken): Thenable<void> | void;
/**
* Defines the editing capability of the provider.
*
* When not provided, editors for this provider are considered readonly.
*/
readonly editingDelegate?: CustomEditorEditingDelegate<EditType>;
resolveCustomEditor(document: DocumentType, webviewPanel: WebviewPanel, token: CancellationToken): Thenable<void> | void;
}
namespace window {

View file

@ -35,7 +35,7 @@ import { CustomTextEditorModel } from 'vs/workbench/contrib/customEditor/common/
import { WebviewExtensionDescription, WebviewIcons } from 'vs/workbench/contrib/webview/browser/webview';
import { WebviewInput } from 'vs/workbench/contrib/webview/browser/webviewEditorInput';
import { ICreateWebViewShowOptions, IWebviewWorkbenchService, WebviewInputOptions } from 'vs/workbench/contrib/webview/browser/webviewWorkbenchService';
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
import { IBackupFileService, IResolvedBackup } from 'vs/workbench/services/backup/common/backup';
import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
@ -133,6 +133,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@IWebviewWorkbenchService private readonly _webviewWorkbenchService: IWebviewWorkbenchService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IBackupFileService private readonly _backupService: IBackupFileService,
) {
super();
@ -410,7 +411,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma
: MainThreadCustomEditorModel.create(this._instantiationService, this._proxy, viewType, resource, () => {
return Array.from(this._webviewInputs)
.filter(editor => editor instanceof CustomEditorInput && isEqual(editor.resource, resource)) as CustomEditorInput[];
}, cancellation);
}, cancellation, this._backupService);
return this._customEditorService.models.add(resource, viewType, model);
}
@ -577,7 +578,7 @@ namespace HotExitState {
readonly type = Type.Pending;
constructor(
public readonly operation: CancelablePromise<void>,
public readonly operation: CancelablePromise<string>,
) { }
}
@ -600,46 +601,42 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod
resource: URI,
getEditors: () => CustomEditorInput[],
cancellation: CancellationToken,
backupFileService: IBackupFileService,
) {
const { editable } = await proxy.$createWebviewCustomEditorDocument(resource, viewType, cancellation);
const model = instantiationService.createInstance(MainThreadCustomEditorModel, proxy, viewType, resource, editable, getEditors);
await model.init();
return model;
const backup = await backupFileService.resolve<CustomDocumentBackupData>(MainThreadCustomEditorModel.toWorkingCopyResource(viewType, resource));
const { editable } = await proxy.$createCustomDocument(resource, viewType, backup?.meta?.backupId, cancellation);
return instantiationService.createInstance(MainThreadCustomEditorModel, proxy, viewType, resource, backup, editable, getEditors);
}
constructor(
private readonly _proxy: extHostProtocol.ExtHostWebviewsShape,
private readonly _viewType: string,
private readonly _editorResource: URI,
backup: IResolvedBackup<CustomDocumentBackupData> | undefined,
private readonly _editable: boolean,
private readonly _getEditors: () => CustomEditorInput[],
@IWorkingCopyService workingCopyService: IWorkingCopyService,
@ILabelService private readonly _labelService: ILabelService,
@IFileService private readonly _fileService: IFileService,
@IUndoRedoService private readonly _undoService: IUndoRedoService,
@IBackupFileService private readonly _backupFileService: IBackupFileService,
) {
super();
if (_editable) {
this._register(workingCopyService.registerWorkingCopy(this));
}
this._fromBackup = !!backup;
}
get editorResource() {
return this._editorResource;
}
async init(): Promise<void> {
const backup = await this._backupFileService.resolve<CustomDocumentBackupData>(this.resource);
this._fromBackup = !!backup;
}
dispose() {
if (this._editable) {
this._undoService.removeElements(this._editorResource);
}
this._proxy.$disposeWebviewCustomEditorDocument(this._editorResource, this._viewType);
this._proxy.$disposeCustomDocument(this._editorResource, this._viewType);
super.dispose();
}
@ -647,11 +644,15 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod
public get resource() {
// Make sure each custom editor has a unique resource for backup and edits
return MainThreadCustomEditorModel.toWorkingCopyResource(this._viewType, this._editorResource);
}
private static toWorkingCopyResource(viewType: string, resource: URI) {
return URI.from({
scheme: Schemas.vscodeCustomEditor,
authority: this._viewType,
path: this._editorResource.path,
query: JSON.stringify(this._editorResource.toJSON()),
authority: viewType,
path: resource.path,
query: JSON.stringify(resource.toJSON()),
});
}
@ -719,15 +720,7 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod
this.change(() => {
--this._currentEditIndex;
});
await this._proxy.$undo(this._editorResource, this.viewType, undoneEdit, this.getEditState());
}
private getEditState(): extHostProtocol.CustomDocumentEditState {
return {
allEdits: this._edits,
currentIndex: this._currentEditIndex,
saveIndex: this._savePoint,
};
await this._proxy.$undo(this._editorResource, this.viewType, undoneEdit, this.isDirty());
}
private async redo(): Promise<void> {
@ -744,7 +737,7 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod
this.change(() => {
++this._currentEditIndex;
});
await this._proxy.$redo(this._editorResource, this.viewType, redoneEdit, this.getEditState());
await this._proxy.$redo(this._editorResource, this.viewType, redoneEdit, this.isDirty());
}
private spliceEdits(editToInsert?: number) {
@ -779,16 +772,7 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod
return;
}
let editsToUndo: number[] = [];
let editsToRedo: number[] = [];
if (this._currentEditIndex >= this._savePoint) {
editsToUndo = this._edits.slice(this._savePoint, this._currentEditIndex).reverse();
} else if (this._currentEditIndex < this._savePoint) {
editsToRedo = this._edits.slice(this._currentEditIndex, this._savePoint);
}
this._proxy.$revert(this._editorResource, this.viewType, { undoneEdits: editsToUndo, redoneEdits: editsToRedo }, this.getEditState());
this._proxy.$revert(this._editorResource, this.viewType, CancellationToken.None);
this.change(() => {
this._currentEditIndex = this._savePoint;
this.spliceEdits();
@ -838,6 +822,7 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod
meta: {
viewType: this.viewType,
editorResource: this._editorResource,
backupId: '',
extension: primaryEditor.extension ? {
id: primaryEditor.extension.id.value,
location: primaryEditor.extension.location,
@ -864,10 +849,11 @@ class MainThreadCustomEditorModel extends Disposable implements ICustomEditorMod
this._hotExitState = pendingState;
try {
await pendingState.operation;
const backupId = await pendingState.operation;
// Make sure state has not changed in the meantime
if (this._hotExitState === pendingState) {
this._hotExitState = HotExitState.Allowed;
backupData.meta!.backupId = backupId;
}
} catch (e) {
// Make sure state has not changed in the meantime

View file

@ -1039,7 +1039,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
TimelineItem: extHostTypes.TimelineItem,
CellKind: extHostTypes.CellKind,
CellOutputKind: extHostTypes.CellOutputKind,
CustomDocument: extHostTypes.CustomDocument,
};
};
}

View file

@ -624,12 +624,6 @@ export interface WebviewPanelViewStateData {
};
}
export interface CustomDocumentEditState {
readonly allEdits: readonly number[];
readonly currentIndex: number;
readonly saveIndex: number;
}
export interface ExtHostWebviewsShape {
$onMessage(handle: WebviewPanelHandle, message: any): void;
$onMissingCsp(handle: WebviewPanelHandle, extensionId: string): void;
@ -639,18 +633,18 @@ export interface ExtHostWebviewsShape {
$deserializeWebviewPanel(newWebviewHandle: WebviewPanelHandle, viewType: string, title: string, state: any, position: EditorViewColumn, options: modes.IWebviewOptions & modes.IWebviewPanelOptions): Promise<void>;
$resolveWebviewEditor(resource: UriComponents, newWebviewHandle: WebviewPanelHandle, viewType: string, title: string, position: EditorViewColumn, options: modes.IWebviewOptions & modes.IWebviewPanelOptions, cancellation: CancellationToken): Promise<void>;
$createWebviewCustomEditorDocument(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<{ editable: boolean }>;
$disposeWebviewCustomEditorDocument(resource: UriComponents, viewType: string): Promise<void>;
$createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken): Promise<{ editable: boolean }>;
$disposeCustomDocument(resource: UriComponents, viewType: string): Promise<void>;
$undo(resource: UriComponents, viewType: string, editId: number, state: CustomDocumentEditState): Promise<void>;
$redo(resource: UriComponents, viewType: string, editId: number, state: CustomDocumentEditState): Promise<void>;
$revert(resource: UriComponents, viewType: string, changes: { undoneEdits: number[], redoneEdits: number[] }, state: CustomDocumentEditState): Promise<void>;
$undo(resource: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void>;
$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>;
$backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void>;
$backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<string>;
$onMoveCustomEditor(handle: WebviewPanelHandle, newResource: UriComponents, viewType: string): Promise<void>;
}

View file

@ -6,18 +6,15 @@
import { coalesce, equals } from 'vs/base/common/arrays';
import { escapeCodicons } from 'vs/base/common/codicons';
import { illegalArgument } from 'vs/base/common/errors';
import { Emitter } from 'vs/base/common/event';
import { IRelativePattern } from 'vs/base/common/glob';
import { isMarkdownString } from 'vs/base/common/htmlContent';
import { startsWith } from 'vs/base/common/strings';
import { isStringArray } from 'vs/base/common/types';
import { URI } from 'vs/base/common/uri';
import { generateUuid } from 'vs/base/common/uuid';
import { FileSystemProviderErrorCode, markAsFileSystemProviderError } from 'vs/platform/files/common/files';
import { RemoteAuthorityResolverErrorCode } from 'vs/platform/remote/common/remoteAuthorityResolver';
import type * as vscode from 'vscode';
import { Cache } from './cache';
import { assertIsDefined, isStringArray } from 'vs/base/common/types';
import { Schemas } from 'vs/base/common/network';
function es5ClassCompat(target: Function): any {
///@ts-expect-error
@ -2726,94 +2723,3 @@ export class TimelineItem implements vscode.TimelineItem {
}
//#endregion Timeline
//#region Custom Editors
interface EditState {
readonly allEdits: readonly number[];
readonly currentIndex: number;
readonly saveIndex: number;
}
export class CustomDocument<EditType = unknown> implements vscode.CustomDocument<EditType> {
readonly #edits = new Cache<EditType>('edits');
readonly #uri: vscode.Uri;
#editState: EditState = {
allEdits: [],
currentIndex: -1,
saveIndex: -1,
};
#isDisposed = false;
#version = 1;
constructor(uri: vscode.Uri) {
this.#uri = uri;
}
//#region Public API
public get uri(): vscode.Uri { return this.#uri; }
public get fileName(): string { return this.uri.fsPath; }
public get isUntitled() { return this.uri.scheme === Schemas.untitled; }
#onDidDispose = new Emitter<void>();
public readonly onDidDispose = this.#onDidDispose.event;
public get isClosed() { return this.#isDisposed; }
public get version() { return this.#version; }
public get isDirty() {
return this.#editState.currentIndex !== this.#editState.saveIndex;
}
public get appliedEdits() {
return this.#editState.allEdits.slice(0, this.#editState.currentIndex + 1)
.map(id => this._getEdit(id));
}
public get savedEdits() {
return this.#editState.allEdits.slice(0, this.#editState.saveIndex + 1)
.map(id => this._getEdit(id));
}
//#endregion
/** @internal */ _dispose(): void {
this.#isDisposed = true;
this.#onDidDispose.fire();
this.#onDidDispose.dispose();
}
/** @internal */ _updateEditState(state: EditState) {
++this.#version;
this.#editState = state;
}
/** @internal*/ _getEdit(editId: number): EditType {
return assertIsDefined(this.#edits.get(editId, 0));
}
/** @internal*/ _disposeEdits(editIds: number[]) {
for (const editId of editIds) {
this.#edits.delete(editId);
}
}
/** @internal*/ _addEdit(edit: EditType): number {
const id = this.#edits.add([edit]);
this._updateEditState({
allEdits: [...this.#editState.allEdits.slice(0, this.#editState.currentIndex + 1), id],
currentIndex: this.#editState.currentIndex + 1,
saveIndex: this.#editState.saveIndex,
});
return id;
}
}
// #endregion

View file

@ -18,6 +18,7 @@ import { IExtHostWorkspace } from 'vs/workbench/api/common/extHostWorkspace';
import { EditorViewColumn } from 'vs/workbench/api/common/shared/editor';
import { asWebviewUri, WebviewInitData } from 'vs/workbench/api/common/shared/webview';
import type * as vscode from 'vscode';
import { Cache } from './cache';
import * as extHostProtocol from './extHost.protocol';
import * as extHostTypes from './extHostTypes';
@ -262,22 +263,78 @@ export class ExtHostWebviewEditor extends Disposable implements vscode.WebviewPa
}
}
class WebviewDocumentStore {
private readonly _documents = new Map<string, extHostTypes.CustomDocument>();
class CustomDocumentStoreEntry {
public get(viewType: string, resource: vscode.Uri): extHostTypes.CustomDocument | undefined {
constructor(
public readonly document: vscode.CustomDocument,
) { }
private readonly _edits = new Cache<vscode.CustomDocumentEditEvent>('custom documents');
private _backup?: vscode.CustomDocumentBackup;
addEdit(item: vscode.CustomDocumentEditEvent): number {
return this._edits.add([item]);
}
async undo(editId: number, isDirty: boolean): Promise<void> {
await this.getEdit(editId).undo();
if (!isDirty) {
this.disposeBackup();
}
}
async redo(editId: number, isDirty: boolean): Promise<void> {
await this.getEdit(editId).redo();
if (!isDirty) {
this.disposeBackup();
}
}
disposeEdits(editIds: number[]): void {
for (const id of editIds) {
this._edits.delete(id);
}
}
updateBackup(backup: vscode.CustomDocumentBackup): void {
this._backup?.dispose();
this._backup = backup;
}
disposeBackup(): void {
this._backup?.dispose();
this._backup = undefined;
}
private getEdit(editId: number): vscode.CustomDocumentEditEvent {
const edit = this._edits.get(editId, 0);
if (!edit) {
throw new Error('No edit found');
}
return edit;
}
}
class CustomDocumentStore {
private readonly _documents = new Map<string, CustomDocumentStoreEntry>();
public get(viewType: string, resource: vscode.Uri): CustomDocumentStoreEntry | undefined {
return this._documents.get(this.key(viewType, resource));
}
public add(viewType: string, document: extHostTypes.CustomDocument) {
public add(viewType: string, document: vscode.CustomDocument): CustomDocumentStoreEntry {
const key = this.key(viewType, document.uri);
if (this._documents.has(key)) {
throw new Error(`Document already exists for viewType:${viewType} resource:${document.uri}`);
}
this._documents.set(key, document);
const entry = new CustomDocumentStoreEntry(document);
this._documents.set(key, entry);
return entry;
}
public delete(viewType: string, document: extHostTypes.CustomDocument) {
public delete(viewType: string, document: vscode.CustomDocument) {
const key = this.key(viewType, document.uri);
this._documents.delete(key);
}
@ -285,6 +342,7 @@ class WebviewDocumentStore {
private key(viewType: string, resource: vscode.Uri): string {
return `${viewType}@@@${resource}`;
}
}
const enum WebviewEditorType {
@ -342,7 +400,7 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
private readonly _editorProviders = new EditorProviderStore();
private readonly _documents = new WebviewDocumentStore();
private readonly _documents = new CustomDocumentStore();
constructor(
mainContext: extHostProtocol.IMainContext,
@ -410,13 +468,6 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
} else {
disposables.add(this._editorProviders.addCustomProvider(viewType, extension, provider));
this._proxy.$registerCustomEditorProvider(toExtensionData(extension), viewType, options);
if (provider.editingDelegate) {
disposables.add(provider.editingDelegate.onDidEdit(e => {
const document = e.document;
const editId = (document as extHostTypes.CustomDocument)._addEdit(e.edit);
this._proxy.$onDidEdit(document.uri, viewType, editId, e.label);
}));
}
}
return extHostTypes.Disposable.from(
@ -504,7 +555,7 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
await serializer.deserializeWebviewPanel(revivedPanel, state);
}
async $createWebviewCustomEditorDocument(resource: UriComponents, viewType: string, cancellation: CancellationToken) {
async $createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken) {
const entry = this._editorProviders.get(viewType);
if (!entry) {
throw new Error(`No provider found for '${viewType}'`);
@ -515,14 +566,20 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
}
const revivedResource = URI.revive(resource);
const document = await entry.provider.openCustomDocument(revivedResource, cancellation);
this._documents.add(viewType, document as extHostTypes.CustomDocument);
return {
editable: !!entry.provider.editingDelegate,
};
const document = await entry.provider.openCustomDocument(revivedResource, { backupId }, cancellation);
const documentEntry = this._documents.add(viewType, document);
if (this.isEditable(document)) {
document.onDidEdit(e => {
const editId = documentEntry.addEdit(e);
this._proxy.$onDidEdit(document.uri, viewType, editId, e.label);
});
}
return { editable: this.isEditable(document) };
}
async $disposeWebviewCustomEditorDocument(resource: UriComponents, viewType: string): Promise<void> {
async $disposeCustomDocument(resource: UriComponents, viewType: string): Promise<void> {
const entry = this._editorProviders.get(viewType);
if (!entry) {
throw new Error(`No provider found for '${viewType}'`);
@ -533,9 +590,9 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
}
const revivedResource = URI.revive(resource);
const document = this.getCustomDocument(viewType, revivedResource);
const { document } = this.getCustomDocumentEntry(viewType, revivedResource);
this._documents.delete(viewType, document);
document._dispose();
document.dispose();
}
async $resolveWebviewEditor(
@ -561,7 +618,7 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
switch (entry.type) {
case WebviewEditorType.Custom:
{
const document = this.getCustomDocument(viewType, revivedResource);
const { document } = this.getCustomDocumentEntry(viewType, revivedResource);
return entry.provider.resolveCustomEditor(document, revivedPanel, cancellation);
}
case WebviewEditorType.Text:
@ -577,8 +634,8 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
}
$disposeEdits(resourceComponents: UriComponents, viewType: string, editIds: number[]): void {
const document = this.getCustomDocument(viewType, resourceComponents);
document._disposeEdits(editIds);
const document = this.getCustomDocumentEntry(viewType, resourceComponents);
document.disposeEdits(editIds);
}
async $onMoveCustomEditor(handle: string, newResourceComponents: UriComponents, viewType: string): Promise<void> {
@ -601,69 +658,65 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
await (entry.provider as vscode.CustomTextEditorProvider).moveCustomTextEditor!(document, webview, CancellationToken.None);
}
async $undo(resourceComponents: UriComponents, viewType: string, editId: number, state: extHostProtocol.CustomDocumentEditState): Promise<void> {
const delegate = this.getEditingDelegate(viewType);
const document = this.getCustomDocument(viewType, resourceComponents);
document._updateEditState(state);
return delegate.undoEdits(document, [document._getEdit(editId)]);
async $undo(resourceComponents: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
return entry.undo(editId, isDirty);
}
async $redo(resourceComponents: UriComponents, viewType: string, editId: number, state: extHostProtocol.CustomDocumentEditState): Promise<void> {
const delegate = this.getEditingDelegate(viewType);
const document = this.getCustomDocument(viewType, resourceComponents);
document._updateEditState(state);
return delegate.applyEdits(document, [document._getEdit(editId)]);
async $redo(resourceComponents: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
return entry.redo(editId, isDirty);
}
async $revert(resourceComponents: UriComponents, viewType: string, changes: { undoneEdits: number[], redoneEdits: number[] }, state: extHostProtocol.CustomDocumentEditState): Promise<void> {
const delegate = this.getEditingDelegate(viewType);
const document = this.getCustomDocument(viewType, resourceComponents);
const undoneEdits = changes.undoneEdits.map(id => document._getEdit(id));
const appliedEdits = changes.redoneEdits.map(id => document._getEdit(id));
document._updateEditState(state);
return delegate.revert(document, { undoneEdits, appliedEdits });
async $revert(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
const document = this.getEditableCustomDocument(viewType, resourceComponents);
await document.revert(cancellation);
entry.disposeBackup();
}
async $onSave(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void> {
const delegate = this.getEditingDelegate(viewType);
const document = this.getCustomDocument(viewType, resourceComponents);
return delegate.save(document, cancellation);
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
const document = this.getEditableCustomDocument(viewType, resourceComponents);
await document.save(cancellation);
entry.disposeBackup();
}
async $onSaveAs(resourceComponents: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise<void> {
const delegate = this.getEditingDelegate(viewType);
const document = this.getCustomDocument(viewType, resourceComponents);
return delegate.saveAs(document, URI.revive(targetResource), cancellation);
const document = this.getEditableCustomDocument(viewType, resourceComponents);
return document.saveAs(URI.revive(targetResource), cancellation);
}
async $backup(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void> {
const delegate = this.getEditingDelegate(viewType);
const document = this.getCustomDocument(viewType, resourceComponents);
return delegate.backup(document, cancellation);
async $backup(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise<string> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
const document = this.getEditableCustomDocument(viewType, resourceComponents);
const backup = await document.backup(cancellation);
entry.updateBackup(backup);
return backup.backupId;
}
private getWebviewPanel(handle: extHostProtocol.WebviewPanelHandle): ExtHostWebviewEditor | undefined {
return this._webviewPanels.get(handle);
}
private getCustomDocument(viewType: string, resource: UriComponents): extHostTypes.CustomDocument {
const document = this._documents.get(viewType, URI.revive(resource));
if (!document) {
throw new Error('No webview editor custom document found');
private getCustomDocumentEntry(viewType: string, resource: UriComponents): CustomDocumentStoreEntry {
const entry = this._documents.get(viewType, URI.revive(resource));
if (!entry) {
throw new Error('No custom document found');
}
return document;
return entry;
}
private getEditingDelegate(viewType: string): vscode.CustomEditorEditingDelegate {
const entry = this._editorProviders.get(viewType);
if (!entry) {
throw new Error(`No provider found for '${viewType}'`);
private isEditable(document: vscode.CustomDocument): document is vscode.EditableCustomDocument {
return !!(document as vscode.EditableCustomDocument).onDidEdit;
}
private getEditableCustomDocument(viewType: string, resource: UriComponents): vscode.EditableCustomDocument {
const { document } = this.getCustomDocumentEntry(viewType, resource);
if (!this.isEditable(document)) {
throw new Error('Custom document is not editable');
}
const delegate = (entry.provider as vscode.CustomEditorProvider).editingDelegate;
if (!delegate) {
throw new Error(`Provider for ${viewType}' does not support editing`);
}
return delegate;
return document;
}
}

View file

@ -29,6 +29,8 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput {
private readonly _editorResource: URI;
private readonly _startsDirty: boolean | undefined;
public readonly backupId: string | undefined;
get resource() { return this._editorResource; }
private _modelRef?: IReference<ICustomEditorModel>;
@ -38,7 +40,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput {
viewType: string,
id: string,
webview: Lazy<WebviewOverlay>,
options: { startsDirty?: boolean },
options: { startsDirty?: boolean, backupId?: string },
@IWebviewService webviewService: IWebviewService,
@IWebviewWorkbenchService webviewWorkbenchService: IWebviewWorkbenchService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ -52,6 +54,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput {
super(id, viewType, '', webview, webviewService, webviewWorkbenchService);
this._editorResource = resource;
this._startsDirty = options.startsDirty;
this.backupId = options.backupId;
}
public getTypeId(): string {
@ -201,7 +204,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput {
this.viewType,
this.id,
new Lazy(() => undefined!),
{ startsDirty: this._startsDirty }); // this webview is replaced in the transfer call
{ startsDirty: this._startsDirty, backupId: this.backupId }); // this webview is replaced in the transfer call
this.transfer(newEditor);
newEditor.updateGroup(group);
return newEditor;

View file

@ -18,6 +18,8 @@ import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
export interface CustomDocumentBackupData {
readonly viewType: string;
readonly editorResource: UriComponents;
backupId: string;
readonly extension: undefined | {
readonly location: UriComponents;
readonly id: string;
@ -119,7 +121,7 @@ export class CustomEditorInputFactory extends WebviewEditorInputFactory {
return webview;
});
const editor = instantiationService.createInstance(CustomEditorInput, URI.revive(backupData.editorResource), backupData.viewType, id, webview, { startsDirty: true });
const editor = instantiationService.createInstance(CustomEditorInput, URI.revive(backupData.editorResource), backupData.viewType, id, webview, { startsDirty: true, backupId: backupData.backupId });
editor.updateGroup(0);
return editor;
});