diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 0fead2f1e705..e0516cf7b383 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -1099,67 +1099,70 @@ export interface IActiveResourceService { //#region Custom editors: https://github.com/microsoft/vscode/issues/77131 // tslint:disable: interface-name - -/** - * Defines the capabilities of a custom webview editor. - */ -export interface CustomEditorCapabilities { - /** - * Defines the editing capability of a custom webview document. - * - * When not provided, the document is considered readonly. - */ - readonly editing?: CustomEditorEditingCapability; -} - /** * Defines the editing capability of a custom webview editor. This allows the webview editor to hook into standard * editor events such as `undo` or `save`. * * @param EditType Type of edits. */ -export interface CustomEditorEditingCapability { +export interface CustomEditorEditingDelegate { /** * Event triggered by extensions to signal to VS Code that an edit has occurred. */ - readonly onDidEdit: Event; + readonly onDidEdit: Event>; /** * Save the resource. * + * @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 the save has completed. */ - save(): Thenable; + save(document: CustomDocument, cancellation: CancellationToken): Thenable; /** * Save the existing resource at a new path. * + * @param document Document to save. * @param targetResource Location to save to. * * @return Thenable signaling that the save has completed. */ - saveAs(targetResource: Uri): Thenable; + saveAs(document: CustomDocument, targetResource: Uri): Thenable; /** * Apply a set of edits. * * Note that is not invoked when `onDidEdit` is called because `onDidEdit` implies also updating the view to reflect the edit. * + * @param document Document to apply edits to. * @param edit Array of edits. Sorted from oldest to most recent. * * @return Thenable signaling that the change has completed. */ - applyEdits(edits: readonly EditType[]): Thenable; + applyEdits(document: CustomDocument, edits: readonly EditType[]): Thenable; /** * Undo a set of edits. * - * This is triggered when a user undoes an edit or when revert is called on a file. + * This is triggered when a user undoes an edit. * + * @param document Document to undo edits from. * @param edit Array of edits. Sorted from most recent to oldest. * * @return Thenable signaling that the change has completed. */ - undoEdits(edits: readonly EditType[]): Thenable; + undoEdits(document: CustomDocument, edits: readonly EditType[]): Thenable; + + /** + * Revert the file to its last saved state. + * + * @param document Document to revert. + * @param edits Added or applied edits. + * + * @return Thenable signaling that the change has completed. + */ + revert(document: CustomDocument, edits: CustomDocumentRevert): Thenable; /** * Back up the resource in its current state. @@ -1174,19 +1177,57 @@ export interface CustomEditorEditingCapability { * 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 revert. * @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(cancellation: CancellationToken): Thenable; + backup(document: CustomDocument, cancellation: CancellationToken): Thenable; +} + +/** + * Event triggered by extensions to signal to VS Code that an edit has occurred on a CustomDocument``. + */ +export interface CustomDocumentEditEvent { + /** + * Document the edit is for. + */ + readonly document: CustomDocument; + + /** + * Object that describes the edit. + * + * Edit objects are passed back to your extension in `undoEdits`, `applyEdits`, and `revert`. + */ + readonly edit: EditType; + + /** + * Display name describing the edit. + */ + readonly label?: string; +} + +/** + * Data about a revert for a `CustomDocument`. + */ +export interface CustomDocumentRevert { + /** + * List of edits that were undone to get the document back to its on disk state. + */ + readonly undoneEdits: readonly EditType[]; + + /** + * List of edits that were reapplied to get the document back to its on disk state. + */ + readonly appliedEdits: readonly EditType[]; } /** - * Represents a custom document for a custom webview editor. + * Represents a custom document used by a `CustomEditorProvider`. * * Custom documents are only used within a given `CustomEditorProvider`. The lifecycle of a - * `CustomDocument` is managed by VS Code. When more more references remain to a given `CustomDocument` + * `CustomDocument` is managed by VS Code. When no more references remain to a given `CustomDocument`, * then it is disposed of. * * @param UserDataType Type of custom object that extensions can store on the document. @@ -1223,23 +1264,38 @@ export interface CustomDocument { * based documents, use [`WebviewTextEditorProvider`](#WebviewTextEditorProvider) instead. */ export interface CustomEditorProvider { + /** + * Defines the editing capability of a custom webview document. + * + * When not provided, the document is considered readonly. + */ + readonly editingDelegate?: CustomEditorEditingDelegate; /** * Resolve the model for a given resource. * + * `resolveCustomDocument` is called when the first editor for a given resource is opened, and the resolve document + * is passed to `resolveCustomEditor`. The resolved `CustomDocument` is re-used for subsequent editor opens. + * If all editors for a given resource are closed, the `CustomDocument` is disposed of. Opening an editor at + * this point will trigger another call to `resolveCustomDocument`. + * * @param document Document to resolve. * * @return The capabilities of the resolved document. */ - resolveCustomDocument(document: CustomDocument): Thenable; + resolveCustomDocument(document: CustomDocument): Thenable; /** * Resolve a webview editor for a given resource. * + * This is called when a user first opens a resource for a `CustomTextEditorProvider`, or if they reopen an + * existing editor using this `CustomTextEditorProvider`. + * * To resolve a webview editor, the provider must fill in its initial html content and hook up all - * the event listeners it is interested it. The provider should also take ownership of the passed in `WebviewPanel`. + * the event listeners it is interested it. The provider can also hold onto the `WebviewPanel` to use later, + * for example in a command. See [`WebviewPanel`](#WebviewPanel) for additional details * * @param document Document for the resource being resolved. - * @param webviewPanel Webview to resolve. The provider should take ownership of this webview. + * @param webviewPanel Webview to resolve. * * @return Thenable indicating that the webview editor has been resolved. */ @@ -1258,13 +1314,17 @@ export interface CustomEditorProvider { */ export interface CustomTextEditorProvider { /** - * Resolve a webview editor for a given resource. + * Resolve a webview editor for a given text resource. + * + * This is called when a user first opens a resource for a `CustomTextEditorProvider`, or if they reopen an + * existing editor using this `CustomTextEditorProvider`. * * To resolve a webview editor, the provider must fill in its initial html content and hook up all - * the event listeners it is interested it. The provider should also take ownership of the passed in `WebviewPanel`. + * the event listeners it is interested it. The provider can also hold onto the `WebviewPanel` to use later, + * for example in a command. See [`WebviewPanel`](#WebviewPanel) for additional details. * - * @param document Resource being resolved. - * @param webviewPanel Webview to resolve. The provider should take ownership of this webview. + * @param document Document for the resource to resolve. + * @param webviewPanel Webview to resolve. * * @return Thenable indicating that the webview editor has been resolved. */ diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index a64cbd8553eb..409fa7a6194d 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -3,11 +3,13 @@ 'use strict'; import { inject, injectable } from 'inversify'; import * as uuid from 'uuid/v4'; -import { Disposable, Event, EventEmitter, Uri, WebviewPanel } from 'vscode'; +import { CancellationToken, Disposable, Event, EventEmitter, Uri, WebviewPanel } from 'vscode'; import { arePathsSame } from '../../../datascience-ui/react-common/arePathsSame'; import { CustomDocument, - CustomEditorCapabilities, + CustomDocumentEditEvent, + CustomDocumentRevert, + CustomEditorEditingDelegate, CustomEditorProvider, ICustomEditorService, IWorkspaceService @@ -22,6 +24,7 @@ import { } from '../../common/types'; import { createDeferred } from '../../common/utils/async'; import * as localize from '../../common/utils/localize'; +import { noop } from '../../common/utils/misc'; import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { Identifiers, Settings, Telemetry } from '../constants'; @@ -37,7 +40,12 @@ import { // Class that is registered as the custom editor provider for notebooks. VS code will call into this class when // opening an ipynb file. This class then creates a backing storage, model, and opens a view for the file. @injectable() -export class NativeEditorProvider implements INotebookEditorProvider, CustomEditorProvider, IAsyncDisposable { +export class NativeEditorProvider + implements + INotebookEditorProvider, + CustomEditorProvider, + IAsyncDisposable, + CustomEditorEditingDelegate { public get onDidChangeActiveNotebookEditor(): Event { return this._onDidChangeActiveNotebookEditor.event; } @@ -50,6 +58,12 @@ export class NativeEditorProvider implements INotebookEditorProvider, CustomEdit public get activeEditor(): INotebookEditor | undefined { return this.editors.find(e => e.visible && e.active); } + public get editingDelegate(): CustomEditorEditingDelegate { + return this; + } + public get onDidEdit(): Event> { + return this._onDidEdit.event; + } public get editors(): INotebookEditor[] { return [...this.openedEditors]; @@ -58,11 +72,13 @@ export class NativeEditorProvider implements INotebookEditorProvider, CustomEdit public static readonly customEditorViewType = 'NativeEditorProvider.ipynb'; protected readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); protected readonly _onDidOpenNotebookEditor = new EventEmitter(); + protected readonly _onDidEdit = new EventEmitter>(); + protected customDocuments = new Map(); private readonly _onDidCloseNotebookEditor = new EventEmitter(); private openedEditors: Set = new Set(); - private models = new Map>(); - private modelChangedHandlers: Map = new Map(); + private storageAndModels = new Map>(); private executedEditors: Set = new Set(); + private models = new WeakSet(); private notebookCount: number = 0; private openedNotebookCount: number = 0; private _id = uuid(); @@ -91,41 +107,58 @@ export class NativeEditorProvider implements INotebookEditorProvider, CustomEdit }); } - public save(resource: Uri): Promise { - return this.loadStorage(resource).then(async s => { + public save(document: CustomDocument, cancellation: CancellationToken): Promise { + return this.loadStorage(document.uri).then(async s => { if (s) { - await s.save(); + await s.save(cancellation); } }); } - public saveAs(resource: Uri, targetResource: Uri): Promise { - return this.loadStorage(resource).then(async s => { + public saveAs(document: CustomDocument, targetResource: Uri): Promise { + return this.loadStorage(document.uri).then(async s => { if (s) { await s.saveAs(targetResource); } }); } - public applyEdits(resource: Uri, edits: readonly NotebookModelChange[]): Promise { - return this.loadModel(resource).then(s => { + public applyEdits(document: CustomDocument, edits: readonly NotebookModelChange[]): Promise { + return this.loadModel(document.uri).then(s => { if (s) { edits.forEach(e => s.update({ ...e, source: 'redo' })); } }); } - public undoEdits(resource: Uri, edits: readonly NotebookModelChange[]): Promise { - return this.loadModel(resource).then(s => { + public undoEdits(document: CustomDocument, edits: readonly NotebookModelChange[]): Promise { + return this.loadModel(document.uri).then(s => { if (s) { edits.forEach(e => s.update({ ...e, source: 'undo' })); } }); } + public async revert(_document: CustomDocument, _edits: CustomDocumentRevert): Promise { + noop(); + } + public async backup(document: CustomDocument, cancellation: CancellationToken): Promise { + return this.loadStorage(document.uri).then(async s => { + if (s) { + await s.backup(cancellation); + } + }); + } + public async resolveCustomEditor(document: CustomDocument, panel: WebviewPanel) { + this.customDocuments.set(document.uri.fsPath, document); await this.createNotebookEditor(document.uri, panel); } - public async resolveCustomDocument(document: CustomDocument): Promise { - const model = await this.loadStorage(document.uri); - return { editing: model }; + public async resolveCustomDocument(document: CustomDocument): Promise { + this.customDocuments.set(document.uri.fsPath, document); + await this.loadStorage(document.uri); + } + + public async resolveNativeEditorStorage(document: CustomDocument): Promise { + this.customDocuments.set(document.uri.fsPath, document); + return this.loadStorage(document.uri); } public async dispose(): Promise { @@ -236,8 +269,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, CustomEdit // If last editor, dispose of the storage const key = editor.file.toString(); if (![...this.openedEditors].find(e => e.file.toString() === key)) { - this.modelChangedHandlers.delete(key); - this.models.delete(key); + this.storageAndModels.delete(key); } this._onDidCloseNotebookEditor.fire(editor); } @@ -252,12 +284,20 @@ export class NativeEditorProvider implements INotebookEditorProvider, CustomEdit } } - private async modelChanged(change: NotebookModelChange): Promise { + private async modelChanged(change: NotebookModelChange) { if (change.source === 'user' && change.kind === 'file') { // Update our storage - const promise = this.models.get(change.oldFile.toString()); - this.models.delete(change.oldFile.toString()); - this.models.set(change.newFile.toString(), promise!); + const promise = this.storageAndModels.get(change.oldFile.toString()); + this.storageAndModels.delete(change.oldFile.toString()); + this.storageAndModels.set(change.newFile.toString(), promise!); + } + } + + private async modelEdited(model: INotebookModel, change: NotebookModelChange) { + // Find the document associated with this edit. + const document = this.customDocuments.get(model.file.fsPath); + if (document) { + this._onDidEdit.fire({ document, edit: change }); } } @@ -273,24 +313,25 @@ export class NativeEditorProvider implements INotebookEditorProvider, CustomEdit private loadModelAndStorage(file: Uri, contents?: string) { const key = file.toString(); - let modelPromise = this.models.get(key); + let modelPromise = this.storageAndModels.get(key); if (!modelPromise) { const storage = this.serviceContainer.get(INotebookStorage); modelPromise = storage.load(file, contents).then(m => { - if (!this.modelChangedHandlers.has(key)) { - this.modelChangedHandlers.set(key, m.changed(this.modelChanged.bind(this))); + if (!this.models.has(m)) { + this.models.add(m); + this.disposables.push(m.changed(this.modelChanged.bind(this))); + this.disposables.push(storage.onDidEdit(this.modelEdited.bind(this, m))); } return { model: m, storage }; }); - - this.models.set(key, modelPromise); + this.storageAndModels.set(key, modelPromise); } return modelPromise; } private async getNextNewNotebookUri(): Promise { // See if we have any untitled storage already - const untitledStorage = [...this.models.keys()].filter(k => Uri.parse(k).scheme === 'untitled'); + const untitledStorage = [...this.storageAndModels.keys()].filter(k => Uri.parse(k).scheme === 'untitled'); // Just use the length (don't bother trying to fill in holes). We never remove storage objects from // our map, so we'll keep creating new untitled notebooks. diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProviderOld.ts b/src/client/datascience/interactive-ipynb/nativeEditorProviderOld.ts index 4b1c85ede533..4617fb753609 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProviderOld.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProviderOld.ts @@ -3,7 +3,7 @@ 'use strict'; import { inject, injectable } from 'inversify'; import * as path from 'path'; -import { TextDocument, TextEditor, Uri } from 'vscode'; +import { CancellationTokenSource, TextDocument, TextEditor, Uri } from 'vscode'; import { ICommandManager, @@ -53,14 +53,20 @@ export class NativeEditorProviderOld extends NativeEditorProvider { ); this.disposables.push( this.cmdManager.registerCommand(Commands.SaveNotebookNonCustomEditor, async (resource: Uri) => { - await this.save(resource); + const customDocument = this.customDocuments.get(resource.fsPath); + if (customDocument) { + await this.save(customDocument, new CancellationTokenSource().token); + } }) ); this.disposables.push( this.cmdManager.registerCommand( Commands.SaveAsNotebookNonCustomEditor, async (resource: Uri, targetResource: Uri) => { - await this.saveAs(resource, targetResource); + const customDocument = this.customDocuments.get(resource.fsPath); + if (customDocument) { + await this.saveAs(customDocument, targetResource); + } } ) ); diff --git a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts index 4506f7cb75c8..94f46631e42e 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts @@ -19,7 +19,6 @@ import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, INotebookModel // tslint:disable-next-line:no-require-imports no-var-requires import detectIndent = require('detect-indent'); -import { CustomEditorEditingCapability } from '../../common/application/types'; import { sendTelemetryEvent } from '../../telemetry'; // tslint:disable-next-line:no-require-imports no-var-requires const debounce = require('lodash/debounce') as typeof import('lodash/debounce'); @@ -36,8 +35,7 @@ interface INativeEditorStorageState { } @injectable() -export class NativeEditorStorage - implements INotebookModel, INotebookStorage, CustomEditorEditingCapability { +export class NativeEditorStorage implements INotebookModel, INotebookStorage { public get isDirty(): boolean { return this._state.changeCount !== this._state.saveChangeCount; } diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index 649cd073c58b..11a57af5221a 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -20,7 +20,7 @@ import { WebviewPanel } from 'vscode'; import { ServerStatus } from '../../datascience-ui/interactive-common/mainState'; -import { CustomEditorEditingCapability, ICommandManager } from '../common/application/types'; +import { ICommandManager } from '../common/application/types'; import { ExecutionResult, ObservableExecutionResult, SpawnOptions } from '../common/process/types'; import { IAsyncDisposable, IDataScienceSettings, IDisposable, Resource } from '../common/types'; import { StopWatch } from '../common/utils/stopWatch'; @@ -834,7 +834,13 @@ export interface INotebookModel { export const INotebookStorage = Symbol('INotebookStorage'); -export interface INotebookStorage extends CustomEditorEditingCapability { +export interface INotebookStorage { + readonly onDidEdit: Event; + save(cancellation: CancellationToken): Thenable; + saveAs(targetResource: Uri): Thenable; + applyEdits(edits: readonly NotebookModelChange[]): Thenable; + undoEdits(edits: readonly NotebookModelChange[]): Thenable; + backup(cancellation: CancellationToken): Thenable; load(file: Uri, contents?: string): Promise; } type WebViewViewState = { diff --git a/src/test/datascience/mockCustomEditorService.ts b/src/test/datascience/mockCustomEditorService.ts index 49a19e63e1d7..eb859669d515 100644 --- a/src/test/datascience/mockCustomEditorService.ts +++ b/src/test/datascience/mockCustomEditorService.ts @@ -1,9 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { Disposable, EventEmitter, Uri, WebviewPanel, WebviewPanelOptions } from 'vscode'; +import { CancellationTokenSource, Disposable, EventEmitter, Uri, WebviewPanel, WebviewPanelOptions } from 'vscode'; import { CustomDocument, - CustomEditorEditingCapability, CustomEditorProvider, ICommandManager, ICustomEditorService @@ -12,7 +11,7 @@ import { IDisposableRegistry } from '../../client/common/types'; import { noop } from '../../client/common/utils/misc'; import { NotebookModelChange } from '../../client/datascience/interactive-common/interactiveWindowTypes'; import { NativeEditorProvider } from '../../client/datascience/interactive-ipynb/nativeEditorProvider'; -import { INotebookEditor, INotebookEditorProvider } from '../../client/datascience/types'; +import { INotebookEditor, INotebookEditorProvider, INotebookStorage } from '../../client/datascience/types'; import { createTemporaryFile } from '../utils/fs'; export class MockCustomEditorService implements ICustomEditorService { @@ -116,13 +115,10 @@ export class MockCustomEditorService implements ICustomEditorService { }; } - private async getModel(file: Uri): Promise { - const nativeProvider = this.provider as CustomEditorProvider; + private async getModel(file: Uri): Promise { + const nativeProvider = this.provider as NativeEditorProvider; if (nativeProvider) { - const model = await nativeProvider.resolveCustomDocument(this.createDocument(file)); - if (model.editing) { - return model.editing; - } + return nativeProvider.resolveNativeEditorStorage(this.createDocument(file)); } return undefined; } @@ -130,7 +126,7 @@ export class MockCustomEditorService implements ICustomEditorService { private async onFileSave(file: Uri) { const model = await this.getModel(file); if (model) { - model.save(); + model.save(new CancellationTokenSource().token); } }