From 3073843d6c16d85cdbd9d5fe0de2767224bd2752 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Thu, 23 Jan 2020 15:29:36 -0800 Subject: [PATCH 01/18] Setup usage of the new API --- package.json | 1 + types/vscode.proposed.d.ts | 119 +++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 types/vscode.proposed.d.ts diff --git a/package.json b/package.json index f204e38206dc..3602765d1a05 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "version": "2020.2.0-dev", "languageServerVersion": "0.4.114", "publisher": "ms-python", + "enabledProposedApi": true, "author": { "name": "Microsoft Corporation" }, diff --git a/types/vscode.proposed.d.ts b/types/vscode.proposed.d.ts new file mode 100644 index 000000000000..a5eeae0b97f2 --- /dev/null +++ b/types/vscode.proposed.d.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * This is the place for API experiments and proposals. + * These API are NOT stable and subject to change. They are only available in the Insiders + * distribution and CANNOT be used in published extensions. + * + * To test these API in local environment: + * - Use Insiders release of VS Code. + * - Add `"enableProposedApi": true` to your package.json. + * - Copy this file to your project. + */ + +declare module 'vscode' { + + + /** + * Defines the editing functionality of a webview editor. This allows the webview editor to hook into standard + * editor events such as `undo` or `save`. + * + * @param EditType Type of edits. Edit objects must be json serializable. + */ + interface WebviewCustomEditorEditingDelegate { + /** + * Save a resource. + * + * @param resource Resource being saved. + * + * @return Thenable signaling that the save has completed. + */ + save(resource: Uri): Thenable; + + /** + * Save an existing resource at a new path. + * + * @param resource Resource being saved. + * @param targetResource Location to save to. + * + * @return Thenable signaling that the save has completed. + */ + saveAs(resource: Uri, targetResource: Uri): Thenable; + + /** + * Event triggered by extensions to signal to VS Code that an edit has occurred. + */ + readonly onEdit: Event<{ readonly resource: Uri, readonly edit: EditType }>; + + /** + * Apply a set of edits. + * + * Note that is not invoked when `onEdit` is called as `onEdit` implies also updating the view to reflect the edit. + * + * @param resource Resource being edited. + * @param edit Array of edits. Sorted from oldest to most recent. + * + * @return Thenable signaling that the change has completed. + */ + applyEdits(resource: Uri, 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. + * + * @param resource Resource being edited. + * @param edit Array of edits. Sorted from most recent to oldest. + * + * @return Thenable signaling that the change has completed. + */ + undoEdits(resource: Uri, edits: readonly EditType[]): Thenable; + } + + export interface WebviewCustomEditorProvider { + /** + * Resolve a webview editor for a given resource. + * + * To resolve a webview editor, a 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`. + * + * @param resource Resource being resolved. + * @param webview Webview being resolved. The provider should take ownership of this webview. + * + * @return Thenable indicating that the webview editor has been resolved. + */ + resolveWebviewEditor( + resource: Uri, + webview: WebviewPanel, + ): Thenable; + + /** + * Controls the editing functionality of a webview editor. This allows the webview editor to hook into standard + * editor events such as `undo` or `save`. + * + * WebviewEditors that do not have `editingCapability` are considered to be readonly. Users can still interact + * with readonly editors, but these editors will not integrate with VS Code's standard editor functionality. + */ + readonly editingDelegate?: WebviewCustomEditorEditingDelegate; + } + + namespace window { + /** + * Register a new provider for webview editors of a given type. + * + * @param viewType Type of the webview editor provider. + * @param provider Resolves webview editors. + * @param options Content settings for a webview panels the provider is given. + * + * @return Disposable that unregisters the `WebviewCustomEditorProvider`. + */ + export function registerWebviewCustomEditorProvider( + viewType: string, + provider: WebviewCustomEditorProvider, + options?: WebviewPanelOptions, + ): Disposable; + } +} From 05d03979fd5d5e10166ae2058c78b783c22e5380 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Thu, 23 Jan 2020 17:24:04 -0800 Subject: [PATCH 02/18] Partial ideas --- .../common/application/customEditorService.ts | 18 ++ src/client/common/application/types.ts | 20 ++ src/client/common/serviceRegistry.ts | 3 + .../interactive-common/interactiveBase.ts | 45 --- .../customNativeEditorProvider.ts | 135 +++++++++ .../hackyNativeEditorProvider.ts | 270 +++++++++++++++++ .../hackyNativeEditorStorage.ts | 0 .../interactive-ipynb/nativeEditor.ts | 11 +- .../interactive-ipynb/nativeEditorProvider.ts | 274 ++++-------------- .../interactive-window/interactiveWindow.ts | 2 - .../datascience/jupyter/jupyterExporter.ts | 51 +++- src/client/datascience/types.ts | 6 + 12 files changed, 563 insertions(+), 272 deletions(-) create mode 100644 src/client/common/application/customEditorService.ts create mode 100644 src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts create mode 100644 src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts create mode 100644 src/client/datascience/interactive-ipynb/hackyNativeEditorStorage.ts diff --git a/src/client/common/application/customEditorService.ts b/src/client/common/application/customEditorService.ts new file mode 100644 index 000000000000..c98cc3ba34b1 --- /dev/null +++ b/src/client/common/application/customEditorService.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { injectable } from 'inversify'; +import * as vscode from 'vscode'; + +import { ICustomEditorService } from './types'; + +@injectable() +export class CustomEditorService implements ICustomEditorService { + public get supportsCustomEditors(): boolean { + return vscode.window.registerWebviewCustomEditorProvider !== undefined; + } + + public registerWebviewCustomEditorProvider(viewType: string, provider: vscode.WebviewCustomEditorProvider, options?: vscode.WebviewPanelOptions): vscode.Disposable { + return vscode.window.registerWebviewCustomEditorProvider(viewType, provider, options); + } +} diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 4d14f7dbf609..70650a3b28dd 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -49,6 +49,8 @@ import { TreeViewOptions, Uri, ViewColumn, + WebviewCustomEditorProvider, + WebviewPanelOptions, WindowState, WorkspaceConfiguration, WorkspaceEdit, @@ -1042,3 +1044,21 @@ export const IActiveResourceService = Symbol('IActiveResourceService'); export interface IActiveResourceService { getActiveResource(): Resource; } + +export const ICustomEditorService = Symbol('ICustomEditorService'); +export interface ICustomEditorService { + /** + * Returns a boolean indicating if custom editors are supported or not. + */ + readonly supportsCustomEditors: boolean; + /** + * Register a new provider for webview editors of a given type. + * + * @param viewType Type of the webview editor provider. + * @param provider Resolves webview editors. + * @param options Content settings for a webview panels the provider is given. + * + * @return Disposable that unregisters the `WebviewCustomEditorProvider`. + */ + registerWebviewCustomEditorProvider(viewType: string, provider: WebviewCustomEditorProvider, options?: WebviewPanelOptions): Disposable; +} diff --git a/src/client/common/serviceRegistry.ts b/src/client/common/serviceRegistry.ts index 848e0c7f353c..1ff99c25f0e8 100644 --- a/src/client/common/serviceRegistry.ts +++ b/src/client/common/serviceRegistry.ts @@ -9,6 +9,7 @@ import { IImportTracker } from '../telemetry/types'; import { ApplicationEnvironment } from './application/applicationEnvironment'; import { ApplicationShell } from './application/applicationShell'; import { CommandManager } from './application/commandManager'; +import { CustomEditorService } from './application/customEditorService'; import { DebugService } from './application/debugService'; import { DebugSessionTelemetry } from './application/debugSessionTelemetry'; import { DocumentManager } from './application/documentManager'; @@ -19,6 +20,7 @@ import { IApplicationEnvironment, IApplicationShell, ICommandManager, + ICustomEditorService, IDebugService, IDocumentManager, ILanguageService, @@ -151,4 +153,5 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IExtensionChannelRule, ExtensionInsidersDailyChannelRule, ExtensionChannel.daily); serviceManager.addSingleton(IExtensionChannelRule, ExtensionInsidersWeeklyChannelRule, ExtensionChannel.weekly); serviceManager.addSingleton(IExtensionSingleActivationService, DebugSessionTelemetry); + serviceManager.addSingleton(ICustomEditorService, CustomEditorService); } diff --git a/src/client/datascience/interactive-common/interactiveBase.ts b/src/client/datascience/interactive-common/interactiveBase.ts index 222af4e6aa3e..f937b271c23e 100644 --- a/src/client/datascience/interactive-common/interactiveBase.ts +++ b/src/client/datascience/interactive-common/interactiveBase.ts @@ -108,7 +108,6 @@ export abstract class InteractiveBase extends WebViewHost { - // Take the list of cells, convert them to a notebook json format and write to disk - if (this._notebook) { - let directoryChange; - const settings = this.configuration.getSettings(); - if (settings.datascience.changeDirOnImportExport) { - directoryChange = file; - } - - const notebook = await this.jupyterExporter.translateToNotebook(cells, directoryChange); - - try { - // tslint:disable-next-line: no-any - const contents = JSON.stringify(notebook); - await this.fileSystem.writeFile(file, contents, { encoding: 'utf8', flag: 'w' }); - const openQuestion1 = localize.DataScience.exportOpenQuestion1(); - const openQuestion2 = (await this.jupyterExecution.isSpawnSupported()) ? localize.DataScience.exportOpenQuestion() : undefined; - this.showInformationMessage(localize.DataScience.exportDialogComplete().format(file), openQuestion1, openQuestion2).then(async (str: string | undefined) => { - try { - if (str === openQuestion2 && openQuestion2 && this._notebook) { - // If the user wants to, open the notebook they just generated. - await this.jupyterExecution.spawnNotebook(file); - } else if (str === openQuestion1) { - await this.ipynbProvider.open(Uri.file(file), contents); - } - } catch (e) { - await this.errorHandler.handleError(e); - } - }); - } catch (exc) { - traceError('Error in exporting notebook file'); - this.applicationShell.showInformationMessage(localize.DataScience.exportDialogFailed().format(exc)); - } - } - }; - protected setStatus = (message: string, showInWebView: boolean): Disposable => { const result = this.statusProvider.set(message, showInWebView, undefined, undefined, this); this.potentiallyUnfinishedStatus.push(result); @@ -1092,14 +1055,6 @@ export abstract class InteractiveBase extends WebViewHost { - if (question2) { - return this.applicationShell.showInformationMessage(message, question1, question2); - } else { - return this.applicationShell.showInformationMessage(message, question1); - } - } - private async ensureDarkSet(): Promise { if (!this.setDarkPromise) { this.setDarkPromise = createDeferred(); diff --git a/src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts new file mode 100644 index 000000000000..c20f762bcc93 --- /dev/null +++ b/src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { inject, injectable } from 'inversify'; +import * as path from 'path'; +import { Event, EventEmitter, TextDocument, TextEditor, Uri, WebviewCustomEditorEditingDelegate, WebviewCustomEditorProvider, WebviewPanel } from 'vscode'; + +import { ICommandManager, ICustomEditorService, IDocumentManager, IWorkspaceService } from '../../common/application/types'; +import { JUPYTER_LANGUAGE } from '../../common/constants'; +import { IFileSystem } from '../../common/platform/types'; +import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../common/types'; +import * as localize from '../../common/utils/localize'; +import { IServiceContainer } from '../../ioc/types'; +import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; +import { Identifiers, Settings, Telemetry } from '../constants'; +import { IDataScienceErrorHandler, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../types'; + +interface IEdit { + readonly value: string; +} + +export class CustomNativeEditorProvider implements INotebookEditorProvider, WebviewCustomEditorProvider, WebviewCustomEditorEditingDelegate { + public static readonly customEditorViewType = 'NativeEditorProvider.ipynb'; + private readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); + private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: IEdit }>(); + private activeEditors: Map> = new Map>(); + private _onDidOpenNotebookEditor = new EventEmitter(); + constructor( + @inject(IServiceContainer) private serviceContainer: IServiceContainer, + @inject(IDisposableRegistry) private disposables: IDisposableRegistry, + @inject(IWorkspaceService) private workspace: IWorkspaceService, + @inject(IConfigurationService) private configuration: IConfigurationService, + @inject(IFileSystem) private fileSystem: IFileSystem, + @inject(IDocumentManager) private documentManager: IDocumentManager, + @inject(ICommandManager) private readonly cmdManager: ICommandManager, + @inject(IDataScienceErrorHandler) private dataScienceErrorHandler: IDataScienceErrorHandler, + @inject(ICustomEditorService) private customEditorService: ICustomEditorService + ) { + if (this.customEditorService.supportsCustomEditors) { + this.customEditorService.registerWebviewCustomEditorProvider(CustomNativeEditorProvider.customEditorViewType, this, { + enableFindWidget: true, + retainContextWhenHidden: true + }); + } + } + public save(resource: Uri): Thenable { + throw new Error('Method not implemented.'); + } + public saveAs(resource: Uri, targetResource: Uri): Thenable { + throw new Error('Method not implemented.'); + } + public get onEdit(): Event<{ readonly resource: Uri; readonly edit: IEdit }> { + return this._editEventEmitter.event; + } + public applyEdits(resource: Uri, edits: readonly IEdit[]): Thenable { + throw new Error('Method not implemented.'); + } + public undoEdits(resource: Uri, edits: readonly IEdit[]): Thenable { + throw new Error('Method not implemented.'); + } + public resolveWebviewEditor(resource: Uri, webview: WebviewPanel): Thenable { + throw new Error('Method not implemented.'); + } + public get editingDelegate(): WebviewCustomEditorEditingDelegate | undefined { + return this; + } + + public get onDidChangeActiveNotebookEditor(): Event { + return this._onDidChangeActiveNotebookEditor.event; + } + + public get onDidOpenNotebookEditor(): Event { + return this._onDidOpenNotebookEditor.event; + } + + public get activeEditor(): INotebookEditor | undefined { + const active = [...this.activeEditors.entries()].find(e => e[1].active); + if (active) { + return active[1]; + } + } + + public get editors(): INotebookEditor[] { + return [...this.activeEditors.values()]; + } + + public async open(file: Uri, contents: string): Promise { + // Just use a vscode.open command. It should open the file. + } + + public async show(file: Uri): Promise { + // See if this file is open or not already + const editor = this.activeEditors.get(file.fsPath); + if (editor) { + await editor.show(); + } + return editor; + } + + @captureTelemetry(Telemetry.CreateNewNotebook, undefined, false) + public async createNew(contents?: string): Promise { + // Create a new URI for the dummy file using our root workspace path + const uri = await this.getNextNewNotebookUri(); + this.notebookCount += 1; + if (contents) { + return this.open(uri, contents); + } else { + return this.open(uri, ''); + } + } + + public async getNotebookOptions(): Promise { + return { + enableDebugging: true, + purpose: Identifiers.HistoryPurpose // Share the same one as the interactive window. Just need a new session + }; + } + + private onClosedEditor(e: INotebookEditor) { + this.activeEditors.delete(e.file.fsPath); + } + + private onOpenedEditor(e: INotebookEditor) { + this.activeEditors.set(e.file.fsPath, e); + this._onDidOpenNotebookEditor.fire(e); + } + + private onSavedEditor(oldPath: string, e: INotebookEditor) { + // Switch our key for this editor + if (this.activeEditors.has(oldPath)) { + this.activeEditors.delete(oldPath); + } + this.activeEditors.set(e.file.fsPath, e); + } +} diff --git a/src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts new file mode 100644 index 000000000000..a6c1d8cbaea3 --- /dev/null +++ b/src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import * as path from 'path'; +import { Event, EventEmitter, TextDocument, TextEditor, Uri } from 'vscode'; + +import { ICommandManager, IDocumentManager, IWorkspaceService } from '../../common/application/types'; +import { JUPYTER_LANGUAGE } from '../../common/constants'; +import { IFileSystem } from '../../common/platform/types'; +import { IConfigurationService, IDisposableRegistry } from '../../common/types'; +import * as localize from '../../common/utils/localize'; +import { IServiceContainer } from '../../ioc/types'; +import { captureTelemetry } from '../../telemetry'; +import { Identifiers, Settings, Telemetry } from '../constants'; +import { IDataScienceErrorHandler, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../types'; + +export class HackyNativeEditorProvider implements INotebookEditorProvider { + public get onDidChangeActiveNotebookEditor(): Event { + return this._onDidChangeActiveNotebookEditor.event; + } + private readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); + private activeEditors: Map = new Map(); + private _onDidOpenNotebookEditor = new EventEmitter(); + private nextNumber: number = 1; + public get onDidOpenNotebookEditor(): Event { + return this._onDidOpenNotebookEditor.event; + } + constructor( + private serviceContainer: IServiceContainer, + private disposables: IDisposableRegistry, + private workspace: IWorkspaceService, + private configuration: IConfigurationService, + private fileSystem: IFileSystem, + private documentManager: IDocumentManager, + private readonly cmdManager: ICommandManager, + private dataScienceErrorHandler: IDataScienceErrorHandler + ) { + // No live share sync required as open document from vscode will give us our contents. + this.disposables.push(this.documentManager.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditorHandler.bind(this))); + + // Since we may have activated after a document was opened, also run open document for all documents. + // This needs to be async though. Iterating over all of these in the .ctor is crashing the extension + // host, so postpone till after the ctor is finished. + setTimeout(() => { + if (this.documentManager.textDocuments && this.documentManager.textDocuments.forEach) { + this.documentManager.textDocuments.forEach(doc => this.openNotebookAndCloseEditor(doc, false)); + } + }, 0); + + // // Reopen our list of files that were open during shutdown. Actually not doing this for now. The files + // don't open until the extension loads and all they all steal focus. + // const uriList = this.workspaceStorage.get(NotebookUriListStorageKey); + // if (uriList && uriList.length) { + // uriList.forEach(u => { + // this.fileSystem.readFile(u.fsPath).then(c => this.open(u, c).ignoreErrors()).ignoreErrors(); + // }); + // } + } + + public get activeEditor(): INotebookEditor | undefined { + const active = [...this.activeEditors.entries()].find(e => e[1].active); + if (active) { + return active[1]; + } + } + + public get editors(): INotebookEditor[] { + return [...this.activeEditors.values()]; + } + + public async open(file: Uri, contents: string): Promise { + // See if this file is open or not already + let editor = this.activeEditors.get(file.fsPath); + if (!editor) { + editor = await this.create(file, contents); + this.onOpenedEditor(editor); + } else { + await editor.show(); + } + return editor; + } + + public async show(file: Uri): Promise { + // See if this file is open or not already + const editor = this.activeEditors.get(file.fsPath); + if (editor) { + await editor.show(); + } + return editor; + } + + @captureTelemetry(Telemetry.CreateNewNotebook, undefined, false) + public async createNew(contents?: string): Promise { + // Create a new URI for the dummy file using our root workspace path + const uri = await this.getNextNewNotebookUri(); + if (contents) { + return this.open(uri, contents); + } else { + return this.open(uri, ''); + } + } + + public async getNotebookOptions(): Promise { + const settings = this.configuration.getSettings(); + let serverURI: string | undefined = settings.datascience.jupyterServerURI; + const useDefaultConfig: boolean | undefined = settings.datascience.useDefaultConfigForJupyter; + + // For the local case pass in our URI as undefined, that way connect doesn't have to check the setting + if (serverURI.toLowerCase() === Settings.JupyterServerLocalLaunch) { + serverURI = undefined; + } + + return { + enableDebugging: true, + uri: serverURI, + useDefaultConfig, + purpose: Identifiers.HistoryPurpose // Share the same one as the interactive window. Just need a new session + }; + } + + /** + * Open ipynb files when user opens an ipynb file. + * + * @private + * @memberof NativeEditorProvider + */ + private onDidChangeActiveTextEditorHandler(editor?: TextEditor) { + // I we're a source control diff view, then ignore this editor. + if (!editor || this.isEditorPartOfDiffView(editor)) { + return; + } + this.openNotebookAndCloseEditor(editor.document, true).ignoreErrors(); + } + + private async create(file: Uri, contents: string): Promise { + const editor = this.serviceContainer.get(INotebookEditor); + await editor.load(contents, file); + this.disposables.push(editor.closed(this.onClosedEditor.bind(this))); + await editor.show(); + return editor; + } + + private onClosedEditor(e: INotebookEditor) { + this.activeEditors.delete(e.file.fsPath); + } + + private onOpenedEditor(e: INotebookEditor) { + this.activeEditors.set(e.file.fsPath, e); + this.disposables.push(e.saved(this.onSavedEditor.bind(this, e.file.fsPath))); + this._onDidOpenNotebookEditor.fire(e); + this.disposables.push(e.onDidChangeViewState(this.onDidChangeViewState, this)); + } + private onDidChangeViewState() { + this._onDidChangeActiveNotebookEditor.fire(this.activeEditor); + } + + private onSavedEditor(oldPath: string, e: INotebookEditor) { + // Switch our key for this editor + if (this.activeEditors.has(oldPath)) { + this.activeEditors.delete(oldPath); + } + this.activeEditors.set(e.file.fsPath, e); + } + + private async getNextNewNotebookUri(): Promise { + // Start in the root and look for files starting with untitled + let number = 1; + const dir = this.workspace.rootPath; + if (dir) { + const existing = await this.fileSystem.search(path.join(dir, `${localize.DataScience.untitledNotebookFileName()}-*.ipynb`)); + + // Sort by number + existing.sort(); + + // Add one onto the end of the last one + if (existing.length > 0) { + const match = /(\w+)-(\d+)\.ipynb/.exec(path.basename(existing[existing.length - 1])); + if (match && match.length > 1) { + number = parseInt(match[2], 10); + } + return Uri.file(path.join(dir, `${localize.DataScience.untitledNotebookFileName()}-${number + 1}`)); + } + } + + const result = Uri.file(`${localize.DataScience.untitledNotebookFileName()}-${this.nextNumber}`); + this.nextNumber += 1; + return result; + } + + private openNotebookAndCloseEditor = async (document: TextDocument, closeDocumentBeforeOpeningNotebook: boolean) => { + // See if this is an ipynb file + if (this.isNotebook(document) && this.configuration.getSettings().datascience.useNotebookEditor) { + const closeActiveEditorCommand = 'workbench.action.closeActiveEditor'; + try { + const contents = document.getText(); + const uri = document.uri; + + if (closeDocumentBeforeOpeningNotebook) { + if (!this.documentManager.activeTextEditor || this.documentManager.activeTextEditor.document !== document) { + await this.documentManager.showTextDocument(document); + } + await this.cmdManager.executeCommand(closeActiveEditorCommand); + } + + // Open our own editor. + await this.open(uri, contents); + + if (!closeDocumentBeforeOpeningNotebook) { + // Then switch back to the ipynb and close it. + // If we don't do it in this order, the close will switch to the wrong item + await this.documentManager.showTextDocument(document); + await this.cmdManager.executeCommand(closeActiveEditorCommand); + } + } catch (e) { + return this.dataScienceErrorHandler.handleError(e); + } + } + }; + /** + * Check if user is attempting to compare two ipynb files. + * If yes, then return `true`, else `false`. + * + * @private + * @param {TextEditor} editor + * @memberof NativeEditorProvider + */ + private isEditorPartOfDiffView(editor?: TextEditor) { + if (!editor) { + return false; + } + // There's no easy way to determine if the user is openeing a diff view. + // One simple way is to check if there are 2 editor opened, and if both editors point to the same file + // One file with the `file` scheme and the other with the `git` scheme. + if (this.documentManager.visibleTextEditors.length <= 1) { + return false; + } + + // If we have both `git` & `file` schemes for the same file, then we're most likely looking at a diff view. + // Also ensure both editors are in the same view column. + // Possible we have a git diff view (with two editors git and file scheme), and we open the file view + // on the side (different view column). + const gitSchemeEditor = this.documentManager.visibleTextEditors.find( + editorUri => editorUri.document.uri.scheme === 'git' && this.fileSystem.arePathsSame(editorUri.document.uri.fsPath, editor.document.uri.fsPath) + ); + + if (!gitSchemeEditor) { + return false; + } + + const fileSchemeEditor = this.documentManager.visibleTextEditors.find( + editorUri => + editorUri.document.uri.scheme === 'file' && + this.fileSystem.arePathsSame(editorUri.document.uri.fsPath, editor.document.uri.fsPath) && + editorUri.viewColumn === gitSchemeEditor.viewColumn + ); + if (!fileSchemeEditor) { + return false; + } + + // Also confirm the document we have passed in, belongs to one of the editors. + // If its not, then its another document (that is not in the diff view). + return gitSchemeEditor === editor || fileSchemeEditor === editor; + } + private isNotebook(document: TextDocument) { + // Only support file uris (we don't want to automatically open any other ipynb file from another resource as a notebook). + // E.g. when opening a document for comparison, the scheme is `git`, in live share the scheme is `vsls`. + const validUriScheme = document.uri.scheme === 'file' || document.uri.scheme === 'vsls'; + return validUriScheme && (document.languageId === JUPYTER_LANGUAGE || path.extname(document.fileName).toLocaleLowerCase() === '.ipynb'); + } +} diff --git a/src/client/datascience/interactive-ipynb/hackyNativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/hackyNativeEditorStorage.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index 0a04d3ea9c4f..862da7bc6dc0 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -111,7 +111,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { @inject(ICommandManager) commandManager: ICommandManager, @inject(INotebookExporter) jupyterExporter: INotebookExporter, @inject(IWorkspaceService) workspaceService: IWorkspaceService, - @inject(INotebookEditorProvider) editorProvider: INotebookEditorProvider, + @inject(INotebookEditorProvider) private editorProvider: INotebookEditorProvider, @inject(IDataViewerProvider) dataExplorerProvider: IDataViewerProvider, @inject(IJupyterVariables) jupyterVariables: IJupyterVariables, @inject(IJupyterDebugger) jupyterDebugger: IJupyterDebugger, @@ -143,7 +143,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { dataExplorerProvider, jupyterVariables, jupyterDebugger, - editorProvider, errorHandler, commandManager, globalStorage, @@ -183,7 +182,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { return this.visibleCells; } - public async load(contents: string, file: Uri): Promise { + public async load(storage: INativeEditorStorage): Promise { // Save our uri this._file = file; @@ -296,7 +295,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } public async getNotebookOptions(): Promise { - const options = await this.ipynbProvider.getNotebookOptions(); + const options = await this.editorProvider.getNotebookOptions(); const metadata = this.notebookJson.metadata; return { ...options, @@ -373,7 +372,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { if (info && info.code && info.id) { try { // Activate the other side, and send as if came from a file - this.ipynbProvider + this.editorProvider .show(this.file) .then(_v => { this.shareMessage(InteractiveWindowMessages.RemoteAddCode, { @@ -407,7 +406,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { await this.submitCode(info.code, Identifiers.EmptyFileName, 0, info.id); // Activate the other side, and send as if came from a file - await this.ipynbProvider.show(this.file); + await this.editorProvider.show(this.file); this.shareMessage(InteractiveWindowMessages.RemoteReexecuteCode, { code: info.code, file: Identifiers.EmptyFileName, diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index e17d73ceb25b..c0e804e689f9 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -3,9 +3,9 @@ 'use strict'; import { inject, injectable } from 'inversify'; import * as path from 'path'; -import { Event, EventEmitter, TextDocument, TextEditor, Uri } from 'vscode'; +import { Event, EventEmitter, TextDocument, TextEditor, Uri, WebviewCustomEditorProvider } from 'vscode'; -import { ICommandManager, IDocumentManager, IWorkspaceService } from '../../common/application/types'; +import { ICommandManager, ICustomEditorService, IDocumentManager, IWorkspaceService } from '../../common/application/types'; import { JUPYTER_LANGUAGE } from '../../common/constants'; import { IFileSystem } from '../../common/platform/types'; import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../common/types'; @@ -14,63 +14,71 @@ import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { Identifiers, Settings, Telemetry } from '../constants'; import { IDataScienceErrorHandler, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../types'; +import { CustomNativeEditorProvider } from './customNativeEditorProvider'; +import { HackyNativeEditorProvider } from './hackyNativeEditorProvider'; @injectable() export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisposable { - public get onDidChangeActiveNotebookEditor(): Event { - return this._onDidChangeActiveNotebookEditor.event; - } - private readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); - private activeEditors: Map = new Map(); private executedEditors: Set = new Set(); - private _onDidOpenNotebookEditor = new EventEmitter(); private notebookCount: number = 0; private openedNotebookCount: number = 0; - private nextNumber: number = 1; - public get onDidOpenNotebookEditor(): Event { - return this._onDidOpenNotebookEditor.event; - } + private realProvider: INotebookEditorProvider; constructor( - @inject(IServiceContainer) private serviceContainer: IServiceContainer, + @inject(IServiceContainer) serviceContainer: IServiceContainer, @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, - @inject(IDisposableRegistry) private disposables: IDisposableRegistry, - @inject(IWorkspaceService) private workspace: IWorkspaceService, + @inject(IDisposableRegistry) disposables: IDisposableRegistry, + @inject(IWorkspaceService) workspace: IWorkspaceService, @inject(IConfigurationService) private configuration: IConfigurationService, - @inject(IFileSystem) private fileSystem: IFileSystem, - @inject(IDocumentManager) private documentManager: IDocumentManager, - @inject(ICommandManager) private readonly cmdManager: ICommandManager, - @inject(IDataScienceErrorHandler) private dataScienceErrorHandler: IDataScienceErrorHandler + @inject(IFileSystem) fileSystem: IFileSystem, + @inject(IDocumentManager) documentManager: IDocumentManager, + @inject(ICommandManager) cmdManager: ICommandManager, + @inject(IDataScienceErrorHandler) dataScienceErrorHandler: IDataScienceErrorHandler, + @inject(ICustomEditorService) customEditorService: ICustomEditorService ) { asyncRegistry.push(this); - // No live share sync required as open document from vscode will give us our contents. - // Look through the file system for ipynb files to see how many we have in the workspace. Don't wait // on this though. - const findFilesPromise = this.workspace.findFiles('**/*.ipynb'); + const findFilesPromise = workspace.findFiles('**/*.ipynb'); if (findFilesPromise && findFilesPromise.then) { findFilesPromise.then(r => (this.notebookCount += r.length)); } - this.disposables.push(this.documentManager.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditorHandler.bind(this))); + if (customEditorService.supportsCustomEditors) { + this.realProvider = new CustomNativeEditorProvider( + serviceContainer, + asyncRegistry, + disposables, + workspace, + configuration, + fileSystem, + documentManager, + cmdManager, + dataScienceErrorHandler, + customEditorService + ); + } else { + this.realProvider = new HackyNativeEditorProvider( + serviceContainer, + disposables, + workspace, + configuration, + fileSystem, + documentManager, + cmdManager, + dataScienceErrorHandler + ); + } - // Since we may have activated after a document was opened, also run open document for all documents. - // This needs to be async though. Iterating over all of these in the .ctor is crashing the extension - // host, so postpone till after the ctor is finished. - setTimeout(() => { - if (this.documentManager.textDocuments && this.documentManager.textDocuments.forEach) { - this.documentManager.textDocuments.forEach(doc => this.openNotebookAndCloseEditor(doc, false)); - } - }, 0); + // When opening keep track of execution for our telemetry + this.realProvider.onDidOpenNotebookEditor(this.openedEditor.bind(this)); + } - // // Reopen our list of files that were open during shutdown. Actually not doing this for now. The files - // don't open until the extension loads and all they all steal focus. - // const uriList = this.workspaceStorage.get(NotebookUriListStorageKey); - // if (uriList && uriList.length) { - // uriList.forEach(u => { - // this.fileSystem.readFile(u.fsPath).then(c => this.open(u, c).ignoreErrors()).ignoreErrors(); - // }); - // } + public get onDidChangeActiveNotebookEditor(): Event { + return this.realProvider.onDidChangeActiveNotebookEditor; + } + public get onDidOpenNotebookEditor(): Event { + return this.realProvider.onDidOpenNotebookEditor; } public async dispose(): Promise { @@ -86,47 +94,24 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp } } public get activeEditor(): INotebookEditor | undefined { - const active = [...this.activeEditors.entries()].find(e => e[1].active); - if (active) { - return active[1]; - } + return this.realProvider.activeEditor; } public get editors(): INotebookEditor[] { - return [...this.activeEditors.values()]; + return this.realProvider.editors; } public async open(file: Uri, contents: string): Promise { - // See if this file is open or not already - let editor = this.activeEditors.get(file.fsPath); - if (!editor) { - editor = await this.create(file, contents); - this.onOpenedEditor(editor); - } else { - await editor.show(); - } - return editor; + return this.realProvider.open(file, contents); } public async show(file: Uri): Promise { - // See if this file is open or not already - const editor = this.activeEditors.get(file.fsPath); - if (editor) { - await editor.show(); - } - return editor; + return this.realProvider.show(file); } @captureTelemetry(Telemetry.CreateNewNotebook, undefined, false) public async createNew(contents?: string): Promise { - // Create a new URI for the dummy file using our root workspace path - const uri = await this.getNextNewNotebookUri(); - this.notebookCount += 1; - if (contents) { - return this.open(uri, contents); - } else { - return this.open(uri, ''); - } + return this.realProvider.createNew(contents); } public async getNotebookOptions(): Promise { @@ -147,159 +132,16 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp }; } - /** - * Open ipynb files when user opens an ipynb file. - * - * @private - * @memberof NativeEditorProvider - */ - private onDidChangeActiveTextEditorHandler(editor?: TextEditor) { - // I we're a source control diff view, then ignore this editor. - if (!editor || this.isEditorPartOfDiffView(editor)) { - return; - } - this.openNotebookAndCloseEditor(editor.document, true).ignoreErrors(); - } - - private async create(file: Uri, contents: string): Promise { - const editor = this.serviceContainer.get(INotebookEditor); - await editor.load(contents, file); - this.disposables.push(editor.closed(this.onClosedEditor.bind(this))); - this.disposables.push(editor.executed(this.onExecutedEditor.bind(this))); - await editor.show(); - return editor; - } - - private onClosedEditor(e: INotebookEditor) { - this.activeEditors.delete(e.file.fsPath); - } - - private onExecutedEditor(e: INotebookEditor) { - this.executedEditors.add(e.file.fsPath); - } - - private onOpenedEditor(e: INotebookEditor) { - this.activeEditors.set(e.file.fsPath, e); - this.disposables.push(e.saved(this.onSavedEditor.bind(this, e.file.fsPath))); + private openedEditor(editor: INotebookEditor): void { this.openedNotebookCount += 1; - this._onDidOpenNotebookEditor.fire(e); - this.disposables.push(e.onDidChangeViewState(this.onDidChangeViewState, this)); - } - private onDidChangeViewState() { - this._onDidChangeActiveNotebookEditor.fire(this.activeEditor); - } - - private onSavedEditor(oldPath: string, e: INotebookEditor) { - // Switch our key for this editor - if (this.activeEditors.has(oldPath)) { - this.activeEditors.delete(oldPath); - } - this.activeEditors.set(e.file.fsPath, e); - } - - private async getNextNewNotebookUri(): Promise { - // Start in the root and look for files starting with untitled - let number = 1; - const dir = this.workspace.rootPath; - if (dir) { - const existing = await this.fileSystem.search(path.join(dir, `${localize.DataScience.untitledNotebookFileName()}-*.ipynb`)); - - // Sort by number - existing.sort(); - - // Add one onto the end of the last one - if (existing.length > 0) { - const match = /(\w+)-(\d+)\.ipynb/.exec(path.basename(existing[existing.length - 1])); - if (match && match.length > 1) { - number = parseInt(match[2], 10); - } - return Uri.file(path.join(dir, `${localize.DataScience.untitledNotebookFileName()}-${number + 1}`)); - } + if (!this.executedEditors.has(editor.file.fsPath)) { + editor.executed(this.onExecuted.bind(this)); } - - const result = Uri.file(`${localize.DataScience.untitledNotebookFileName()}-${this.nextNumber}`); - this.nextNumber += 1; - return result; } - private openNotebookAndCloseEditor = async (document: TextDocument, closeDocumentBeforeOpeningNotebook: boolean) => { - // See if this is an ipynb file - if (this.isNotebook(document) && this.configuration.getSettings().datascience.useNotebookEditor) { - const closeActiveEditorCommand = 'workbench.action.closeActiveEditor'; - try { - const contents = document.getText(); - const uri = document.uri; - - if (closeDocumentBeforeOpeningNotebook) { - if (!this.documentManager.activeTextEditor || this.documentManager.activeTextEditor.document !== document) { - await this.documentManager.showTextDocument(document); - } - await this.cmdManager.executeCommand(closeActiveEditorCommand); - } - - // Open our own editor. - await this.open(uri, contents); - - if (!closeDocumentBeforeOpeningNotebook) { - // Then switch back to the ipynb and close it. - // If we don't do it in this order, the close will switch to the wrong item - await this.documentManager.showTextDocument(document); - await this.cmdManager.executeCommand(closeActiveEditorCommand); - } - } catch (e) { - return this.dataScienceErrorHandler.handleError(e); - } - } - }; - /** - * Check if user is attempting to compare two ipynb files. - * If yes, then return `true`, else `false`. - * - * @private - * @param {TextEditor} editor - * @memberof NativeEditorProvider - */ - private isEditorPartOfDiffView(editor?: TextEditor) { - if (!editor) { - return false; - } - // There's no easy way to determine if the user is openeing a diff view. - // One simple way is to check if there are 2 editor opened, and if both editors point to the same file - // One file with the `file` scheme and the other with the `git` scheme. - if (this.documentManager.visibleTextEditors.length <= 1) { - return false; - } - - // If we have both `git` & `file` schemes for the same file, then we're most likely looking at a diff view. - // Also ensure both editors are in the same view column. - // Possible we have a git diff view (with two editors git and file scheme), and we open the file view - // on the side (different view column). - const gitSchemeEditor = this.documentManager.visibleTextEditors.find( - editorUri => editorUri.document.uri.scheme === 'git' && this.fileSystem.arePathsSame(editorUri.document.uri.fsPath, editor.document.uri.fsPath) - ); - - if (!gitSchemeEditor) { - return false; - } - - const fileSchemeEditor = this.documentManager.visibleTextEditors.find( - editorUri => - editorUri.document.uri.scheme === 'file' && - this.fileSystem.arePathsSame(editorUri.document.uri.fsPath, editor.document.uri.fsPath) && - editorUri.viewColumn === gitSchemeEditor.viewColumn - ); - if (!fileSchemeEditor) { - return false; + private onExecuted(editor: INotebookEditor): void { + if (editor) { + this.executedEditors.add(editor.file.fsPath); } - - // Also confirm the document we have passed in, belongs to one of the editors. - // If its not, then its another document (that is not in the diff view). - return gitSchemeEditor === editor || fileSchemeEditor === editor; - } - private isNotebook(document: TextDocument) { - // Only support file uris (we don't want to automatically open any other ipynb file from another resource as a notebook). - // E.g. when opening a document for comparison, the scheme is `git`, in live share the scheme is `vsls`. - const validUriScheme = document.uri.scheme === 'file' || document.uri.scheme === 'vsls'; - return validUriScheme && (document.languageId === JUPYTER_LANGUAGE || path.extname(document.fileName).toLocaleLowerCase() === '.ipynb'); } } diff --git a/src/client/datascience/interactive-window/interactiveWindow.ts b/src/client/datascience/interactive-window/interactiveWindow.ts index 58a8b92c9cf0..45dbaa8b460d 100644 --- a/src/client/datascience/interactive-window/interactiveWindow.ts +++ b/src/client/datascience/interactive-window/interactiveWindow.ts @@ -77,7 +77,6 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi @inject(IDataViewerProvider) dataExplorerProvider: IDataViewerProvider, @inject(IJupyterVariables) jupyterVariables: IJupyterVariables, @inject(IJupyterDebugger) jupyterDebugger: IJupyterDebugger, - @inject(INotebookEditorProvider) editorProvider: INotebookEditorProvider, @inject(IDataScienceErrorHandler) errorHandler: IDataScienceErrorHandler, @inject(IPersistentStateFactory) private readonly stateFactory: IPersistentStateFactory, @inject(IMemento) @named(GLOBAL_MEMENTO) globalStorage: Memento, @@ -103,7 +102,6 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi dataExplorerProvider, jupyterVariables, jupyterDebugger, - editorProvider, errorHandler, commandManager, globalStorage, diff --git a/src/client/datascience/jupyter/jupyterExporter.ts b/src/client/datascience/jupyter/jupyterExporter.ts index ef7f9ed09d9a..768715eef6eb 100644 --- a/src/client/datascience/jupyter/jupyterExporter.ts +++ b/src/client/datascience/jupyter/jupyterExporter.ts @@ -7,16 +7,18 @@ import * as os from 'os'; import * as path from 'path'; import * as uuid from 'uuid/v4'; +import { Uri } from 'vscode'; import { concatMultilineStringInput } from '../../../datascience-ui/common'; import { createCodeCell } from '../../../datascience-ui/common/cellFactory'; -import { IWorkspaceService } from '../../common/application/types'; +import { IApplicationShell, IWorkspaceService } from '../../common/application/types'; +import { traceError } from '../../common/logger'; import { IFileSystem, IPlatformService } from '../../common/platform/types'; import { IConfigurationService } from '../../common/types'; import * as localize from '../../common/utils/localize'; import { noop } from '../../common/utils/misc'; import { CellMatcher } from '../cellMatcher'; import { CodeSnippits, Identifiers } from '../constants'; -import { CellState, ICell, IJupyterExecution, INotebookExporter } from '../types'; +import { CellState, ICell, IDataScienceErrorHandler, IJupyterExecution, INotebookEditorProvider, INotebookExporter } from '../types'; @injectable() export class JupyterExporter implements INotebookExporter { @@ -25,13 +27,48 @@ export class JupyterExporter implements INotebookExporter { @inject(IWorkspaceService) private workspaceService: IWorkspaceService, @inject(IConfigurationService) private configService: IConfigurationService, @inject(IFileSystem) private fileSystem: IFileSystem, - @inject(IPlatformService) private readonly platform: IPlatformService + @inject(IPlatformService) private readonly platform: IPlatformService, + @inject(IApplicationShell) private readonly applicationShell: IApplicationShell, + @inject(INotebookEditorProvider) protected ipynbProvider: INotebookEditorProvider, + @inject(IDataScienceErrorHandler) protected errorHandler: IDataScienceErrorHandler ) {} public dispose() { noop(); } + public async exportToFile(cells: ICell[], file: string): Promise { + let directoryChange; + const settings = this.configService.getSettings(); + if (settings.datascience.changeDirOnImportExport) { + directoryChange = file; + } + + const notebook = await this.translateToNotebook(cells, directoryChange); + + try { + // tslint:disable-next-line: no-any + const contents = JSON.stringify(notebook); + await this.fileSystem.writeFile(file, contents, { encoding: 'utf8', flag: 'w' }); + const openQuestion1 = localize.DataScience.exportOpenQuestion1(); + const openQuestion2 = (await this.jupyterExecution.isSpawnSupported()) ? localize.DataScience.exportOpenQuestion() : undefined; + this.showInformationMessage(localize.DataScience.exportDialogComplete().format(file), openQuestion1, openQuestion2).then(async (str: string | undefined) => { + try { + if (str === openQuestion2 && openQuestion2) { + // If the user wants to, open the notebook they just generated. + await this.jupyterExecution.spawnNotebook(file); + } else if (str === openQuestion1) { + await this.ipynbProvider.open(Uri.file(file), contents); + } + } catch (e) { + await this.errorHandler.handleError(e); + } + }); + } catch (exc) { + traceError('Error in exporting notebook file'); + this.applicationShell.showInformationMessage(localize.DataScience.exportDialogFailed().format(exc)); + } + } public async translateToNotebook(cells: ICell[], changeDirectory?: string): Promise { // If requested, add in a change directory cell to fix relative paths if (changeDirectory && this.configService.getSettings().datascience.changeDirOnImportExport) { @@ -70,6 +107,14 @@ export class JupyterExporter implements INotebookExporter { }; } + private showInformationMessage(message: string, question1: string, question2?: string): Thenable { + if (question2) { + return this.applicationShell.showInformationMessage(message, question1, question2); + } else { + return this.applicationShell.showInformationMessage(message, question1); + } + } + // For exporting, put in a cell that will change the working directory back to the workspace directory so relative data paths will load correctly private addDirectoryChangeCell = async (cells: ICell[], file: string): Promise => { const changeDirectory = await this.calculateDirectoryChange(file, cells); diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index a8d738b7781b..e1d46c5ffd3c 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -254,6 +254,7 @@ export interface INotebookImporter extends Disposable { export const INotebookExporter = Symbol('INotebookExporter'); export interface INotebookExporter extends Disposable { translateToNotebook(cells: ICell[], directoryChange?: string): Promise; + exportToFile(cells: ICell[], file: string): Promise; } export const IInteractiveWindowProvider = Symbol('IInteractiveWindowProvider'); @@ -725,3 +726,8 @@ export interface IJupyterInterpreterDependencyManager { */ installMissingDependencies(err?: JupyterInstallError): Promise; } + +export interface INotebookStorage { + readonly file: Uri; + save(): Promise; +} From cd58b47f06fb7f8ec13eb5653efa0114c5f492a6 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Fri, 24 Jan 2020 13:19:32 -0800 Subject: [PATCH 03/18] Idea for splitting --- src/client/common/application/commands.ts | 16 +- src/client/datascience/constants.ts | 13 + .../customNativeEditorProvider.ts | 14 +- .../hackyNativeEditorProvider.ts | 5 +- .../hackyNativeEditorStorage.ts | 601 +++++++++++++++ .../interactive-ipynb/nativeEditor.ts | 684 ++---------------- .../interactive-ipynb/nativeEditorProvider.ts | 6 +- src/client/datascience/types.ts | 12 +- 8 files changed, 718 insertions(+), 633 deletions(-) diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index 61ccbd43cef2..c8e73332464f 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -6,7 +6,10 @@ import { CancellationToken, Position, TextDocument, Uri } from 'vscode'; import { Commands as LSCommands } from '../../activation/languageServer/constants'; import { Commands as DSCommands } from '../../datascience/constants'; -import { INotebook } from '../../datascience/types'; +import type { IEditCell, IInsertCell, ISaveAll, ISwapCells } from '../../datascience/interactive-common/interactiveWindowTypes'; +import type { LiveKernelModel } from '../../datascience/jupyter/kernels/types'; +import { ICell, IJupyterKernelSpec, INotebook } from '../../datascience/types'; +import type { PythonInterpreter } from '../../interpreter/contracts'; import { CommandSource } from '../../testing/common/constants'; import { TestFunction, TestsToRun } from '../../testing/common/types'; import { TestDataItem, TestWorkspaceFolder } from '../../testing/types'; @@ -142,4 +145,15 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu [DSCommands.ScrollToCell]: [string, string]; [DSCommands.ViewJupyterOutput]: []; [DSCommands.SwitchJupyterKernel]: [INotebook | undefined]; + [DSCommands.NotebookStorage_DeleteAllCells]: [Uri]; + [DSCommands.NotebookStorage_Close]: [Uri]; + [DSCommands.NotebookStorage_ModifyCells]: [Uri, ICell[]]; + [DSCommands.NotebookStorage_EditCell]: [Uri, IEditCell]; + [DSCommands.NotebookStorage_InsertCell]: [Uri, IInsertCell]; + [DSCommands.NotebookStorage_RemoveCell]: [Uri, string]; + [DSCommands.NotebookStorage_SwapCells]: [Uri, ISwapCells]; + [DSCommands.NotebookStorage_Save]: [Uri, ISaveAll]; + [DSCommands.NotebookStorage_ClearCellOutputs]: [Uri]; + [DSCommands.NotebookStorage_SaveAs]: [Uri, Uri, ICell[]]; + [DSCommands.NotebookStorage_UpdateVersion]: [Uri, PythonInterpreter | undefined, IJupyterKernelSpec | LiveKernelModel | undefined]; } diff --git a/src/client/datascience/constants.ts b/src/client/datascience/constants.ts index 2ee2f375c7b0..a7e4967cb2a1 100644 --- a/src/client/datascience/constants.ts +++ b/src/client/datascience/constants.ts @@ -62,6 +62,19 @@ export namespace Commands { export const ScrollToCell = 'python.datascience.scrolltocell'; export const CreateNewNotebook = 'python.datascience.createnewnotebook'; export const ViewJupyterOutput = 'python.datascience.viewJupyterOutput'; + + // Make sure to put these into the package .json + export const NotebookStorage_DeleteAllCells = 'python.datascience.notebook.deleteall'; + export const NotebookStorage_Close = 'python.datascience.notebook.close'; + export const NotebookStorage_ModifyCells = 'python.datascience.notebook.modifycells'; + export const NotebookStorage_EditCell = 'python.datascience.notebook.editcell'; + export const NotebookStorage_InsertCell = 'python.datascience.notebook.insertcell'; + export const NotebookStorage_RemoveCell = 'python.datascience.notebook.removecell'; + export const NotebookStorage_SwapCells = 'python.datascience.notebook.swapcells'; + export const NotebookStorage_Save = 'python.datascience.notebook.save'; + export const NotebookStorage_ClearCellOutputs = 'python.datascience.notebook.clearoutputs'; + export const NotebookStorage_SaveAs = 'python.datascience.notebook.saveas'; + export const NotebookStorage_UpdateVersion = 'python.datascience.notebook.updateversion'; } export namespace CodeLensCommands { diff --git a/src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts index c20f762bcc93..f168df434658 100644 --- a/src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts @@ -15,14 +15,10 @@ import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { Identifiers, Settings, Telemetry } from '../constants'; import { IDataScienceErrorHandler, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../types'; -interface IEdit { - readonly value: string; -} - -export class CustomNativeEditorProvider implements INotebookEditorProvider, WebviewCustomEditorProvider, WebviewCustomEditorEditingDelegate { +export class CustomNativeEditorProvider implements INotebookEditorProvider, WebviewCustomEditorProvider, WebviewCustomEditorEditingDelegate { public static readonly customEditorViewType = 'NativeEditorProvider.ipynb'; private readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); - private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: IEdit }>(); + private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: INotebookEdit }>(); private activeEditors: Map> = new Map>(); private _onDidOpenNotebookEditor = new EventEmitter(); constructor( @@ -49,13 +45,13 @@ export class CustomNativeEditorProvider implements INotebookEditorProvider, Webv public saveAs(resource: Uri, targetResource: Uri): Thenable { throw new Error('Method not implemented.'); } - public get onEdit(): Event<{ readonly resource: Uri; readonly edit: IEdit }> { + public get onEdit(): Event<{ readonly resource: Uri; readonly edit: INotebookEdit }> { return this._editEventEmitter.event; } - public applyEdits(resource: Uri, edits: readonly IEdit[]): Thenable { + public applyEdits(resource: Uri, edits: readonly INotebookEdit[]): Thenable { throw new Error('Method not implemented.'); } - public undoEdits(resource: Uri, edits: readonly IEdit[]): Thenable { + public undoEdits(resource: Uri, edits: readonly INotebookEdit[]): Thenable { throw new Error('Method not implemented.'); } public resolveWebviewEditor(resource: Uri, webview: WebviewPanel): Thenable { diff --git a/src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts index a6c1d8cbaea3..00b82b975bcb 100644 --- a/src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts @@ -11,7 +11,7 @@ import { IConfigurationService, IDisposableRegistry } from '../../common/types'; import * as localize from '../../common/utils/localize'; import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry } from '../../telemetry'; -import { Identifiers, Settings, Telemetry } from '../constants'; +import { Commands, Identifiers, Settings, Telemetry } from '../constants'; import { IDataScienceErrorHandler, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../types'; export class HackyNativeEditorProvider implements INotebookEditorProvider { @@ -55,6 +55,9 @@ export class HackyNativeEditorProvider implements INotebookEditorProvider { // this.fileSystem.readFile(u.fsPath).then(c => this.open(u, c).ignoreErrors()).ignoreErrors(); // }); // } + + // Signup for the notebook commands + this.cmdManager.registerCommand(Commands.NotebookStorage_ClearCellOutputs, this.; } public get activeEditor(): INotebookEditor | undefined { diff --git a/src/client/datascience/interactive-ipynb/hackyNativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/hackyNativeEditorStorage.ts index e69de29bb2d1..44c061b67860 100644 --- a/src/client/datascience/interactive-ipynb/hackyNativeEditorStorage.ts +++ b/src/client/datascience/interactive-ipynb/hackyNativeEditorStorage.ts @@ -0,0 +1,601 @@ +import { nbformat } from '@jupyterlab/coreutils'; +import * as path from 'path'; +import * as uuid from 'uuid/v4'; +import { Event, EventEmitter, Memento, Uri } from 'vscode'; +import { concatMultilineStringInput, splitMultilineString } from '../../../datascience-ui/common'; +import { createCodeCell } from '../../../datascience-ui/common/cellFactory'; +import { IApplicationShell, IWorkspaceService } from '../../common/application/types'; +import { traceError } from '../../common/logger'; +import { IFileSystem } from '../../common/platform/types'; +import { ICryptoUtils, IExtensionContext } from '../../common/types'; +import * as localize from '../../common/utils/localize'; +import { noop } from '../../common/utils/misc'; +import { PythonInterpreter } from '../../interpreter/contracts'; +import { captureTelemetry } from '../../telemetry'; +import { Identifiers, Telemetry } from '../constants'; +import { IEditCell, IInsertCell, IRemoveCell, ISwapCells } from '../interactive-common/interactiveWindowTypes'; +import { InvalidNotebookFileError } from '../jupyter/invalidNotebookFileError'; +import { LiveKernelModel } from '../jupyter/kernels/types'; +import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, INotebookEditorProvider, INotebookStorage } from '../types'; + +// tslint:disable-next-line:no-require-imports no-var-requires +const debounce = require('lodash/debounce') as typeof import('lodash/debounce'); + +// tslint:disable-next-line:no-require-imports no-var-requires +import detectIndent = require('detect-indent'); + +enum AskForSaveResult { + Yes, + No, + Cancel +} + +const KeyPrefix = 'notebook-storage-'; +const NotebookTransferKey = 'notebook-transfered'; +export class HackyNativeEditorStorage implements INotebookStorage { + public get isDirty(): boolean { + return this._isDirty; + } + public get changed(): Event { + return this._changedEmitter.event; + } + public get file(): Uri { + return this._file; + } + + public get isUntitled(): boolean { + const baseName = path.basename(this.file.fsPath); + return baseName.includes(localize.DataScience.untitledNotebookFileName()); + } + private _changedEmitter = new EventEmitter(); + private _cells: ICell[] = []; + private _loadPromise: Promise | undefined; + private _loaded = false; + private _file: Uri; + private _isDirty: boolean = false; + private indentAmount: string = ' '; + private notebookJson: Partial = {}; + private isPromptingToSaveToDisc = false; + private debouncedWriteToStorage = debounce(this.writeToStorage.bind(this), 250); + + constructor( + file: Uri, + private initialContents: string, + private workspaceService: IWorkspaceService, + private jupyterExecution: IJupyterExecution, + private fileSystem: IFileSystem, + private crypto: ICryptoUtils, + private context: IExtensionContext, + private applicationShell: IApplicationShell, + private provider: INotebookEditorProvider, + private globalStorage: Memento, + private localStorage: Memento + ) { + this._file = file; + } + public getCells(): Promise { + if (!this._loadPromise) { + this._loadPromise = this.load(); + } + if (!this._loaded) { + return this._loadPromise; + } + + // If already loaded, return the updated cell values + return Promise.resolve(this._cells); + } + + public async getJson(): Promise> { + await this.ensureNotebookJson(); + return this.notebookJson; + } + + public async handleEdit(request: IEditCell): Promise { + // Apply the changes to the visible cell list. We won't get an update until + // submission otherwise + if (request.changes && request.changes.length) { + const change = request.changes[0]; + const normalized = change.text.replace(/\r/g, ''); + + // Figure out which cell we're editing. + const cell = this._cells.find(c => c.id === request.id); + if (cell) { + // This is an actual edit. + const contents = concatMultilineStringInput(cell.data.source); + const before = contents.substr(0, change.rangeOffset); + const after = contents.substr(change.rangeOffset + change.rangeLength); + const newContents = `${before}${normalized}${after}`; + if (contents !== newContents) { + cell.data.source = newContents; + return this.setDirty(); + } + } + } + } + + public async handleInsert(request: IInsertCell): Promise { + // Insert a cell into our visible list based on the index. They should be in sync + this._cells.splice(request.index, 0, request.cell); + return this.setDirty(); + } + + public async handleRemoveCell(request: IRemoveCell): Promise { + // Filter our list + this._cells = this._cells.filter(v => v.id !== request.id); + return this.setDirty(); + } + + public async handleSwapCells(request: ISwapCells): Promise { + // Swap two cells in our list + const first = this._cells.findIndex(v => v.id === request.firstCellId); + const second = this._cells.findIndex(v => v.id === request.secondCellId); + if (first >= 0 && second >= 0) { + const temp = { ...this._cells[first] }; + this._cells[first] = this._cells[second]; + this._cells[second] = temp; + return this.setDirty(); + } + } + + public async handleDeleteAllCells(): Promise { + this._cells = []; + return this.setDirty(); + } + + public async handleModifyCells(cells: ICell[]): Promise { + // Update these cells in our list + cells.forEach(c => { + const index = this._cells.findIndex(v => v.id === c.id); + this._cells[index] = c; + }); + + // Indicate dirty + return this.setDirty(); + } + + public async handleSaveAs(newFile: Uri, cells: ICell[]): Promise { + return this.fileSystem.writeFile(newFile.fsPath, await this.generateNotebookContent(cells), { encoding: 'utf-8' }); + } + + public async handleClose(): Promise { + // Ask user if they want to save. It seems hotExit has no bearing on + // whether or not we should ask + if (this.isDirty) { + const askResult = await this.askForSave(); + switch (askResult) { + case AskForSaveResult.Yes: + // Save the file + await this.saveToDisk(); + break; + + case AskForSaveResult.No: + // Mark as not dirty, so we update our storage + await this.setClean(); + break; + + default: + // Reopen + await this.provider.open(this.file, await this.generateNotebookContent(this._cells)); + break; + } + } + } + + public async handleUpdateVersionInfo(interpreter: PythonInterpreter | undefined, kernelSpec: IJupyterKernelSpec | LiveKernelModel | undefined): Promise { + // Get our kernel_info and language_info from the current notebook + if (interpreter && interpreter.version && this.notebookJson.metadata && this.notebookJson.metadata.language_info) { + this.notebookJson.metadata.language_info.version = interpreter.version.raw; + } + + if (kernelSpec && this.notebookJson.metadata && !this.notebookJson.metadata.kernelspec) { + // Add a new spec in this case + this.notebookJson.metadata.kernelspec = { + name: kernelSpec.name || kernelSpec.display_name || '', + display_name: kernelSpec.display_name || kernelSpec.name || '' + }; + } else if (kernelSpec && this.notebookJson.metadata && this.notebookJson.metadata.kernelspec) { + // Spec exists, just update name and display_name + this.notebookJson.metadata.kernelspec.name = kernelSpec.name || kernelSpec.display_name || ''; + this.notebookJson.metadata.kernelspec.display_name = kernelSpec.display_name || kernelSpec.name || ''; + } + } + private async load(): Promise { + // Clear out old global storage the first time somebody opens + // a notebook + if (!this.globalStorage.get(NotebookTransferKey)) { + await this.transferStorage(); + } + + // See if this file was stored in storage prior to shutdown + const dirtyContents = await this.getStoredContents(); + if (dirtyContents) { + // This means we're dirty. Indicate dirty and load from this content + return this.loadContents(dirtyContents, true); + } else { + // Load without setting dirty + return this.loadContents(this.initialContents, false); + } + } + + private async loadContents(contents: string | undefined, forceDirty: boolean): Promise { + // tslint:disable-next-line: no-any + const json = contents ? (JSON.parse(contents) as any) : undefined; + + // Double check json (if we have any) + if (json && !json.cells) { + throw new InvalidNotebookFileError(this.file.fsPath); + } + + // Then compute indent. It's computed from the contents + if (contents) { + this.indentAmount = detectIndent(contents).indent; + } + + // Then save the contents. We'll stick our cells back into this format when we save + if (json) { + this.notebookJson = json; + } + + // Extract cells from the json + const cells = contents ? (json.cells as (nbformat.ICodeCell | nbformat.IRawCell | nbformat.IMarkdownCell)[]) : []; + + // Remap the ids + const remapped = cells.map((c, index) => { + return { + id: `NotebookImport#${index}`, + file: Identifiers.EmptyFileName, + line: 0, + state: CellState.finished, + data: c + }; + }); + + // Turn this into our cell list + if (remapped.length === 0) { + const defaultCell: ICell = { + id: uuid(), + line: 0, + file: Identifiers.EmptyFileName, + state: CellState.finished, + data: createCodeCell() + }; + // tslint:disable-next-line: no-any + remapped.splice(0, 0, defaultCell as any); + forceDirty = true; + } + + // Save as our visible list + this._cells = remapped; + + // Make dirty if necessary + if (forceDirty) { + await this.setDirty(); + } + + // Indicate loaded + this._loaded = true; + + return this._cells; + } + + private async extractPythonMainVersion(notebookData: Partial): Promise { + if ( + notebookData && + notebookData.metadata && + notebookData.metadata.language_info && + notebookData.metadata.language_info.codemirror_mode && + // tslint:disable-next-line: no-any + typeof (notebookData.metadata.language_info.codemirror_mode as any).version === 'number' + ) { + // tslint:disable-next-line: no-any + return (notebookData.metadata.language_info.codemirror_mode as any).version; + } + // Use the active interpreter + const usableInterpreter = await this.jupyterExecution.getUsableJupyterPython(); + return usableInterpreter && usableInterpreter.version ? usableInterpreter.version.major : 3; + } + + private async ensureNotebookJson(): Promise { + if (!this.notebookJson || !this.notebookJson.metadata) { + const pythonNumber = await this.extractPythonMainVersion(this.notebookJson); + // Use this to build our metadata object + // Use these as the defaults unless we have been given some in the options. + const metadata: nbformat.INotebookMetadata = { + language_info: { + name: 'python', + codemirror_mode: { + name: 'ipython', + version: pythonNumber + } + }, + orig_nbformat: 2, + file_extension: '.py', + mimetype: 'text/x-python', + name: 'python', + npconvert_exporter: 'python', + pygments_lexer: `ipython${pythonNumber}`, + version: pythonNumber + }; + + // Default notebook data. + this.notebookJson = { + nbformat: 4, + nbformat_minor: 2, + metadata: metadata + }; + } + } + + private async generateNotebookContent(cells: ICell[]): Promise { + // Make sure we have some + await this.ensureNotebookJson(); + + // Reuse our original json except for the cells. + const json = { + ...(this.notebookJson as nbformat.INotebookContent), + cells: cells.map(c => this.fixupCell(c.data)) + }; + return JSON.stringify(json, null, this.indentAmount); + } + + private fixupCell(cell: nbformat.ICell): nbformat.ICell { + // Source is usually a single string on input. Convert back to an array + return ({ + ...cell, + source: splitMultilineString(cell.source) + // tslint:disable-next-line: no-any + } as any) as nbformat.ICell; // nyc (code coverage) barfs on this so just trick it. + } + + private async setDirty(): Promise { + // Update storage if not untitled. Don't wait for results. + if (!this.isUntitled) { + this.generateNotebookContent(this._cells) + .then(c => this.storeContents(c).catch(ex => traceError('Failed to generate notebook content to store in state', ex))) + .ignoreErrors(); + } + + // Then update dirty flag. + if (!this._isDirty) { + this._isDirty = true; + + // Tell listeners we're dirty + this._changedEmitter.fire(); + } + } + private getStorageKey(): string { + return `${KeyPrefix}${this._file.toString()}`; + } + + /** + * Gets any unsaved changes to the notebook file. + * If the file has been modified since the uncommitted changes were stored, then ignore the uncommitted changes. + * + * @private + * @returns {(Promise)} + * @memberof NativeEditor + */ + private async getStoredContents(): Promise { + const key = this.getStorageKey(); + + // First look in the global storage file location + let result = await this.getStoredContentsFromFile(key); + if (!result) { + result = await this.getStoredContentsFromGlobalStorage(key); + if (!result) { + result = await this.getStoredContentsFromLocalStorage(key); + } + } + + return result; + } + + private async getStoredContentsFromFile(key: string): Promise { + const filePath = this.getHashedFileName(key); + try { + // Use this to read from the extension global location + const contents = await this.fileSystem.readFile(filePath); + const data = JSON.parse(contents); + // Check whether the file has been modified since the last time the contents were saved. + if (data && data.lastModifiedTimeMs && !this.isUntitled && this.file.scheme === 'file') { + const stat = await this.fileSystem.stat(this.file.fsPath); + if (stat.mtime > data.lastModifiedTimeMs) { + return; + } + } + if (data && !this.isUntitled && data.contents) { + return data.contents; + } + } catch { + noop(); + } + } + + private async getStoredContentsFromGlobalStorage(key: string): Promise { + try { + const data = this.globalStorage.get<{ contents?: string; lastModifiedTimeMs?: number }>(key); + + // If we have data here, make sure we eliminate any remnants of storage + if (data) { + await this.transferStorage(); + } + + // Check whether the file has been modified since the last time the contents were saved. + if (data && data.lastModifiedTimeMs && !this.isUntitled && this.file.scheme === 'file') { + const stat = await this.fileSystem.stat(this.file.fsPath); + if (stat.mtime > data.lastModifiedTimeMs) { + return; + } + } + if (data && !this.isUntitled && data.contents) { + return data.contents; + } + } catch { + noop(); + } + } + + private async getStoredContentsFromLocalStorage(key: string): Promise { + const workspaceData = this.localStorage.get(key); + if (workspaceData && !this.isUntitled) { + // Make sure to clear so we don't use this again. + this.localStorage.update(key, undefined); + + // Transfer this to a file so we use that next time instead. + const filePath = this.getHashedFileName(key); + await this.writeToStorage(filePath, workspaceData); + + return workspaceData; + } + } + + // VS code recommended we use the hidden '_values' to iterate over all of the entries in + // the global storage map and delete the ones we own. + private async transferStorage(): Promise { + const promises: Thenable[] = []; + + // Indicate we ran this function + await this.globalStorage.update(NotebookTransferKey, true); + + try { + // tslint:disable-next-line: no-any + if ((this.globalStorage as any)._value) { + // tslint:disable-next-line: no-any + const keys = Object.keys((this.globalStorage as any)._value); + [...keys].forEach((k: string) => { + if (k.startsWith(KeyPrefix)) { + // Write each pair to our alternate storage, but don't bother waiting for each + // to finish. + const filePath = this.getHashedFileName(k); + const contents = this.globalStorage.get(k); + if (contents) { + this.writeToStorage(filePath, JSON.stringify(contents)).ignoreErrors(); + } + + // Remove from the map so that global storage does not have this anymore. + // Use the real API here as we don't know how the map really gets updated. + promises.push(this.globalStorage.update(k, undefined)); + } + }); + } + } catch (e) { + traceError('Exception eliminating global storage parts:', e); + } + + return Promise.all(promises); + } + + /** + * Stores the uncommitted notebook changes into a temporary location. + * Also keep track of the current time. This way we can check whether changes were + * made to the file since the last time uncommitted changes were stored. + * + * @private + * @param {string} [contents] + * @returns {Promise} + * @memberof NativeEditor + */ + private async storeContents(contents?: string): Promise { + // Skip doing this if auto save is enabled. + const filesConfig = this.workspaceService.getConfiguration('files', this.file); + const autoSave = filesConfig.get('autoSave', 'off'); + if (autoSave === 'off') { + const key = this.getStorageKey(); + const filePath = this.getHashedFileName(key); + + // Keep track of the time when this data was saved. + // This way when we retrieve the data we can compare it against last modified date of the file. + const specialContents = contents ? JSON.stringify({ contents, lastModifiedTimeMs: Date.now() }) : undefined; + + // Write but debounced (wait at least 250 ms) + return this.debouncedWriteToStorage(filePath, specialContents); + } + } + + private async writeToStorage(filePath: string, contents?: string): Promise { + try { + if (contents) { + await this.fileSystem.createDirectory(path.dirname(filePath)); + return this.fileSystem.writeFile(filePath, contents); + } else { + return this.fileSystem.deleteFile(filePath); + } + } catch (exc) { + traceError(`Error writing storage for ${filePath}: `, exc); + } + } + + private getHashedFileName(key: string): string { + const file = `${this.crypto.createHash(key, 'string')}.ipynb`; + return path.join(this.context.globalStoragePath, file); + } + + private async setClean(): Promise { + // Always update storage + this.storeContents(undefined).catch(ex => traceError('Failed to clear notebook store', ex)); + + if (this._isDirty) { + this._isDirty = false; + this._changedEmitter.fire(); + } + } + + private async askForSave(): Promise { + const message1 = localize.DataScience.dirtyNotebookMessage1().format(`${path.basename(this.file.fsPath)}`); + const message2 = localize.DataScience.dirtyNotebookMessage2(); + const yes = localize.DataScience.dirtyNotebookYes(); + const no = localize.DataScience.dirtyNotebookNo(); + // tslint:disable-next-line: messages-must-be-localized + const result = await this.applicationShell.showInformationMessage(`${message1}\n${message2}`, { modal: true }, yes, no); + switch (result) { + case yes: + return AskForSaveResult.Yes; + + case no: + return AskForSaveResult.No; + + default: + return AskForSaveResult.Cancel; + } + } + + @captureTelemetry(Telemetry.Save, undefined, true) + private async saveToDisk(): Promise { + // If we're already in the middle of prompting the user to save, then get out of here. + // We could add a debounce decorator, unfortunately that slows saving (by waiting for no more save events to get sent). + if (this.isPromptingToSaveToDisc && this.isUntitled) { + return; + } + try { + let fileToSaveTo: Uri | undefined = this.file; + let isDirty = this._isDirty; + + // Ask user for a save as dialog if no title + if (this.isUntitled) { + this.isPromptingToSaveToDisc = true; + const filtersKey = localize.DataScience.dirtyNotebookDialogFilter(); + const filtersObject: { [name: string]: string[] } = {}; + filtersObject[filtersKey] = ['ipynb']; + isDirty = true; + + fileToSaveTo = await this.applicationShell.showSaveDialog({ + saveLabel: localize.DataScience.dirtyNotebookDialogTitle(), + filters: filtersObject + }); + } + + if (fileToSaveTo && isDirty) { + // Write out our visible cells + await this.fileSystem.writeFile(fileToSaveTo.fsPath, await this.generateNotebookContent(this._cells)); + + // Update our file name and dirty state + this._file = fileToSaveTo; + await this.setClean(); + } + } catch (e) { + traceError(e); + } finally { + this.isPromptingToSaveToDisc = false; + } + } +} diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index 862da7bc6dc0..e5c38dde1d14 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -7,8 +7,7 @@ import { nbformat } from '@jupyterlab/coreutils/lib/nbformat'; import * as detectIndent from 'detect-indent'; import { inject, injectable, multiInject, named } from 'inversify'; import * as path from 'path'; -import * as uuid from 'uuid/v4'; -import { Event, EventEmitter, Memento, TextEditor, Uri, ViewColumn } from 'vscode'; +import { Event, EventEmitter, Memento, Uri, ViewColumn } from 'vscode'; import { concatMultilineStringInput, splitMultilineString } from '../../../datascience-ui/common'; import { createCodeCell, createErrorOutput } from '../../../datascience-ui/common/cellFactory'; @@ -19,12 +18,11 @@ import { IFileSystem, TemporaryFile } from '../../common/platform/types'; import { GLOBAL_MEMENTO, IConfigurationService, ICryptoUtils, IDisposableRegistry, IExtensionContext, IMemento, WORKSPACE_MEMENTO } from '../../common/types'; import { createDeferred, Deferred } from '../../common/utils/async'; import * as localize from '../../common/utils/localize'; -import { noop } from '../../common/utils/misc'; import { StopWatch } from '../../common/utils/stopWatch'; import { EXTENSION_ROOT_DIR } from '../../constants'; import { IInterpreterService } from '../../interpreter/contracts'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; -import { EditorContexts, Identifiers, NativeKeyboardCommandTelemetryLookup, NativeMouseCommandTelemetryLookup, Telemetry } from '../constants'; +import { Commands, EditorContexts, Identifiers, NativeKeyboardCommandTelemetryLookup, NativeMouseCommandTelemetryLookup, Telemetry } from '../constants'; import { InteractiveBase } from '../interactive-common/interactiveBase'; import { IEditCell, @@ -55,23 +53,12 @@ import { INotebookExporter, INotebookImporter, INotebookServerOptions, + INotebookStorage, IStatusProvider, IThemeFinder } from '../types'; -// tslint:disable-next-line:no-require-imports no-var-requires -const debounce = require('lodash/debounce') as typeof import('lodash/debounce'); - const nativeEditorDir = path.join(EXTENSION_ROOT_DIR, 'out', 'datascience-ui', 'native-editor'); -enum AskForSaveResult { - Yes, - No, - Cancel -} - -const KeyPrefix = 'notebook-storage-'; -const NotebookTransferKey = 'notebook-transfered'; - @injectable() export class NativeEditor extends InteractiveBase implements INotebookEditor { public get onDidChangeViewState(): Event { @@ -82,17 +69,10 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { private executedEvent: EventEmitter = new EventEmitter(); private modifiedEvent: EventEmitter = new EventEmitter(); private savedEvent: EventEmitter = new EventEmitter(); - private metadataUpdatedEvent: EventEmitter = new EventEmitter(); private loadedPromise: Deferred = createDeferred(); - private _file: Uri = Uri.file(''); - private _dirty: boolean = false; - private isPromptingToSaveToDisc: boolean = false; - private visibleCells: ICell[] = []; private startupTimer: StopWatch = new StopWatch(); private loadedAllCells: boolean = false; - private indentAmount: string = ' '; - private notebookJson: Partial = {}; - private debouncedWriteToStorage = debounce(this.writeToStorage.bind(this), 250); + private _storage: INotebookStorage | undefined; constructor( @multiInject(IInteractiveWindowListener) listeners: IInteractiveWindowListener[], @@ -162,7 +142,10 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } public get file(): Uri { - return this._file; + if (this._storage) { + return this._storage.file; + } + return Uri.file(''); } public get isUntitled(): boolean { @@ -174,46 +157,28 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { return this.close(); } - public getContents(): Promise { - return this.generateNotebookContent(this.visibleCells); - } - - public get cells(): ICell[] { - return this.visibleCells; - } - - public async load(storage: INativeEditorStorage): Promise { - // Save our uri - this._file = file; + public async load(storage: INotebookStorage): Promise { + // Save the storage we're using + this._storage = storage; // Indicate we have our identity this.loadedPromise.resolve(); // Load the web panel using our file path so it can find // relative files next to the notebook. - await super.loadWebPanel(path.dirname(file.fsPath)); + await super.loadWebPanel(path.dirname(this.file.fsPath)); // Update our title to match - this.setTitle(path.basename(file.fsPath)); + this.setTitle(path.basename(this.file.fsPath)); // Show ourselves await this.show(); - // Clear out old global storage the first time somebody opens - // a notebook - if (!this.globalStorage.get(NotebookTransferKey)) { - await this.transferStorage(); - } + // Sign up for dirty events + storage.changed(this.contentsChanged.bind(this)); - // See if this file was stored in storage prior to shutdown - const dirtyContents = await this.getStoredContents(); - if (dirtyContents) { - // This means we're dirty. Indicate dirty and load from this content - return this.loadContents(dirtyContents, true); - } else { - // Load without setting dirty - return this.loadContents(contents, false); - } + // Load our cells + await this.loadCells(await storage.getCells()); } public get closed(): Event { @@ -232,12 +197,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { return this.savedEvent.event; } - public get metadataUpdated(): Event { - return this.metadataUpdatedEvent.event; - } - public get isDirty(): boolean { - return this._dirty; + return this._storage ? this._storage.isDirty : false; } // tslint:disable-next-line: no-any @@ -296,11 +257,15 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { public async getNotebookOptions(): Promise { const options = await this.editorProvider.getNotebookOptions(); - const metadata = this.notebookJson.metadata; - return { - ...options, - metadata - }; + if (this._storage) { + const metadata = (await this._storage.getJson()).metadata; + return { + ...options, + metadata + }; + } else { + return options; + } } public runAllCells() { @@ -317,9 +282,9 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { public async removeAllCells(): Promise { super.removeAllCells(); - // Clear our visible cells - this.visibleCells = []; - return this.setDirty(); + // Clear our visible cells in our model too. This should cause an update to the model + // that will fire off a changed event + this.commandManager.executeCommand(Commands.NotebookStorage_DeleteAllCells, this.file); } protected addSysInfo(_reason: SysInfoReason): Promise { @@ -327,44 +292,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { return Promise.resolve(); } - protected async reopen(cells: ICell[]): Promise { - try { - // Reload the web panel too. - await super.loadWebPanel(path.basename(this._file.fsPath)); - await this.show(); - - // Indicate we have our identity - this.loadedPromise.resolve(); - - // Update our title to match - if (this._dirty) { - this._dirty = false; - await this.setDirty(); - } else { - this.setTitle(path.basename(this._file.fsPath)); - } - - // If that works, send the cells to the web view - return this.postMessage(InteractiveWindowMessages.LoadAllCells, { cells }); - } catch (e) { - return this.errorHandler.handleError(e); - } - } - - protected submitCode(code: string, file: string, line: number, id?: string, editor?: TextEditor, debug?: boolean): Promise { - // When code is executed, update the version number in the metadata. - return super.submitCode(code, file, line, id, editor, debug).then(value => { - this.updateVersionInfoInNotebook() - .then(() => { - this.metadataUpdatedEvent.fire(this); - }) - .catch(ex => { - traceError('Failed to update version info in notebook file metadata', ex); - }); - return value; - }); - } - @captureTelemetry(Telemetry.SubmitCellThroughInput, undefined, false) // tslint:disable-next-line:no-any protected submitNewCell(info: ISubmitNewCell) { @@ -440,7 +367,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { await this.loadedPromise.promise; // File should be set now - return this._file; + return this.file; } protected async setLaunchingFile(_file: string): Promise { @@ -455,14 +382,16 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Filter out sysinfo messages. Don't want to show those const filtered = cells.filter(c => c.data.cell_type !== 'messages'); - // Update these cells in our list - cells.forEach(c => { - const index = this.visibleCells.findIndex(v => v.id === c.id); - this.visibleCells[index] = c; - }); + // Update these cells in our storage + this.commandManager.executeCommand(Commands.NotebookStorage_ModifyCells, this.file, cells); - // Indicate dirty - this.setDirty().ignoreErrors(); + // Tell storage about our notebook object + const notebook = this.getNotebook(); + if (notebook) { + const interpreter = notebook.getMatchingInterpreter(); + const kernelSpec = notebook.getKernelSpec(); + this.commandManager.executeCommand(Commands.NotebookStorage_UpdateVersion, this.file, interpreter, kernelSpec); + } // Send onto the webview. super.sendCellsToWebView(filtered); @@ -503,444 +432,56 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Actually don't close, just let the error bubble out } - /** - * Update the Python Version number in the notebook data. - * - * @private - * @memberof NativeEditor - */ - private async updateVersionInfoInNotebook(): Promise { - // Get our kernel_info and language_info from the current notebook - const notebook = this.getNotebook(); - - if (notebook) { - const interpreter = notebook.getMatchingInterpreter(); - const kernelSpec = notebook.getKernelSpec(); - - if (interpreter && interpreter.version && this.notebookJson.metadata && this.notebookJson.metadata.language_info) { - this.notebookJson.metadata.language_info.version = interpreter.version.raw; - } - - if (kernelSpec && this.notebookJson.metadata && !this.notebookJson.metadata.kernelspec) { - // Add a new spec in this case - this.notebookJson.metadata.kernelspec = { - name: kernelSpec.name || kernelSpec.display_name || '', - display_name: kernelSpec.display_name || kernelSpec.name || '' - }; - } else if (kernelSpec && this.notebookJson.metadata && this.notebookJson.metadata.kernelspec) { - // Spec exists, just update name and display_name - this.notebookJson.metadata.kernelspec.name = kernelSpec.name || kernelSpec.display_name || ''; - this.notebookJson.metadata.kernelspec.display_name = kernelSpec.display_name || kernelSpec.name || ''; - } - } - } - - private async ensureNotebookJson(): Promise { - if (!this.notebookJson || !this.notebookJson.metadata) { - const pythonNumber = await this.extractPythonMainVersion(this.notebookJson); - // Use this to build our metadata object - // Use these as the defaults unless we have been given some in the options. - const metadata: nbformat.INotebookMetadata = { - language_info: { - name: 'python', - codemirror_mode: { - name: 'ipython', - version: pythonNumber - } - }, - orig_nbformat: 2, - file_extension: '.py', - mimetype: 'text/x-python', - name: 'python', - npconvert_exporter: 'python', - pygments_lexer: `ipython${pythonNumber}`, - version: pythonNumber - }; - - // Default notebook data. - this.notebookJson = { - nbformat: 4, - nbformat_minor: 2, - metadata: metadata - }; + private contentsChanged(): Promise { + this.modifiedEvent.fire(); + const dirty = !this._storage || this._storage.isDirty; + if (dirty) { + return this.postMessage(InteractiveWindowMessages.NotebookDirty); + } else { + return this.postMessage(InteractiveWindowMessages.NotebookClean); } } - private async loadContents(contents: string | undefined, forceDirty: boolean): Promise { - // tslint:disable-next-line: no-any - const json = contents ? (JSON.parse(contents) as any) : undefined; - - // Double check json (if we have any) - if (json && !json.cells) { - throw new InvalidNotebookFileError(this.file.fsPath); - } - - // Then compute indent. It's computed from the contents - if (contents) { - this.indentAmount = detectIndent(contents).indent; - } - - // Then save the contents. We'll stick our cells back into this format when we save - if (json) { - this.notebookJson = json; - } - - // Extract cells from the json - const cells = contents ? (json.cells as (nbformat.ICodeCell | nbformat.IRawCell | nbformat.IMarkdownCell)[]) : []; - - // Then parse the cells - return this.loadCells( - cells.map((c, index) => { - return { - id: `NotebookImport#${index}`, - file: Identifiers.EmptyFileName, - line: 0, - state: CellState.finished, - data: c - }; - }), - forceDirty - ); - } - - private async loadCells(cells: ICell[], forceDirty: boolean): Promise { - // Make sure cells have at least 1 - if (cells.length === 0) { - const defaultCell: ICell = { - id: uuid(), - line: 0, - file: Identifiers.EmptyFileName, - state: CellState.finished, - data: createCodeCell() - }; - cells.splice(0, 0, defaultCell); - forceDirty = true; - } - - // Save as our visible list - this.visibleCells = cells; - - // Make dirty if necessary - if (forceDirty) { - await this.setDirty(); - } + private async loadCells(cells: ICell[]): Promise { sendTelemetryEvent(Telemetry.CellCount, undefined, { count: cells.length }); return this.postMessage(InteractiveWindowMessages.LoadAllCells, { cells }); } - private getStorageKey(): string { - return `${KeyPrefix}${this._file.toString()}`; - } - /** - * Gets any unsaved changes to the notebook file. - * If the file has been modified since the uncommitted changes were stored, then ignore the uncommitted changes. - * - * @private - * @returns {(Promise)} - * @memberof NativeEditor - */ - private async getStoredContents(): Promise { - const key = this.getStorageKey(); - - // First look in the global storage file location - let result = await this.getStoredContentsFromFile(key); - if (!result) { - result = await this.getStoredContentsFromGlobalStorage(key); - if (!result) { - result = await this.getStoredContentsFromLocalStorage(key); - } - } - - return result; - } - - private async getStoredContentsFromFile(key: string): Promise { - const filePath = this.getHashedFileName(key); - try { - // Use this to read from the extension global location - const contents = await this.fileSystem.readFile(filePath); - const data = JSON.parse(contents); - // Check whether the file has been modified since the last time the contents were saved. - if (data && data.lastModifiedTimeMs && !this.isUntitled && this.file.scheme === 'file') { - const stat = await this.fileSystem.stat(this.file.fsPath); - if (stat.mtime > data.lastModifiedTimeMs) { - return; - } - } - if (data && !this.isUntitled && data.contents) { - return data.contents; - } - } catch { - noop(); - } - } - - private async getStoredContentsFromGlobalStorage(key: string): Promise { - try { - const data = this.globalStorage.get<{ contents?: string; lastModifiedTimeMs?: number }>(key); - - // If we have data here, make sure we eliminate any remnants of storage - if (data) { - await this.transferStorage(); - } - - // Check whether the file has been modified since the last time the contents were saved. - if (data && data.lastModifiedTimeMs && !this.isUntitled && this.file.scheme === 'file') { - const stat = await this.fileSystem.stat(this.file.fsPath); - if (stat.mtime > data.lastModifiedTimeMs) { - return; - } - } - if (data && !this.isUntitled && data.contents) { - return data.contents; - } - } catch { - noop(); - } - } - - private async getStoredContentsFromLocalStorage(key: string): Promise { - const workspaceData = this.localStorage.get(key); - if (workspaceData && !this.isUntitled) { - // Make sure to clear so we don't use this again. - this.localStorage.update(key, undefined); - - // Transfer this to a file so we use that next time instead. - const filePath = this.getHashedFileName(key); - await this.writeToStorage(filePath, workspaceData); - - return workspaceData; - } - } - - // VS code recommended we use the hidden '_values' to iterate over all of the entries in - // the global storage map and delete the ones we own. - private async transferStorage(): Promise { - const promises: Thenable[] = []; - - // Indicate we ran this function - await this.globalStorage.update(NotebookTransferKey, true); - - try { - // tslint:disable-next-line: no-any - if ((this.globalStorage as any)._value) { - // tslint:disable-next-line: no-any - const keys = Object.keys((this.globalStorage as any)._value); - [...keys].forEach((k: string) => { - if (k.startsWith(KeyPrefix)) { - // Write each pair to our alternate storage, but don't bother waiting for each - // to finish. - const filePath = this.getHashedFileName(k); - const contents = this.globalStorage.get(k); - if (contents) { - this.writeToStorage(filePath, JSON.stringify(contents)).ignoreErrors(); - } - - // Remove from the map so that global storage does not have this anymore. - // Use the real API here as we don't know how the map really gets updated. - promises.push(this.globalStorage.update(k, undefined)); - } - }); - } - } catch (e) { - traceError('Exception eliminating global storage parts:', e); - } + private async close(): Promise { + // Send a close command to our model + await this.commandManager.executeCommand(Commands.NotebookStorage_Close, this.file); - return Promise.all(promises); - } + // Fire our event + this.closedEvent.fire(this); - /** - * Stores the uncommitted notebook changes into a temporary location. - * Also keep track of the current time. This way we can check whether changes were - * made to the file since the last time uncommitted changes were stored. - * - * @private - * @param {string} [contents] - * @returns {Promise} - * @memberof NativeEditor - */ - private async storeContents(contents?: string): Promise { - // Skip doing this if auto save is enabled. - const filesConfig = this.workspaceService.getConfiguration('files', this.file); - const autoSave = filesConfig.get('autoSave', 'off'); - if (autoSave === 'off') { - const key = this.getStorageKey(); - const filePath = this.getHashedFileName(key); - - // Keep track of the time when this data was saved. - // This way when we retrieve the data we can compare it against last modified date of the file. - const specialContents = contents ? JSON.stringify({ contents, lastModifiedTimeMs: Date.now() }) : undefined; - - // Write but debounced (wait at least 250 ms) - return this.debouncedWriteToStorage(filePath, specialContents); + // Restart our kernel so that execution counts are reset + let oldAsk: boolean | undefined = false; + const settings = this.configuration.getSettings(); + if (settings && settings.datascience) { + oldAsk = settings.datascience.askForKernelRestart; + settings.datascience.askForKernelRestart = false; } - } - - private async writeToStorage(filePath: string, contents?: string): Promise { - try { - if (contents) { - await this.fileSystem.createDirectory(path.dirname(filePath)); - return this.fileSystem.writeFile(filePath, contents); - } else { - return this.fileSystem.deleteFile(filePath); - } - } catch (exc) { - traceError(`Error writing storage for ${filePath}: `, exc); + await this.restartKernel(); + if (oldAsk && settings && settings.datascience) { + settings.datascience.askForKernelRestart = true; } } - private getHashedFileName(key: string): string { - const file = `${this.crypto.createHash(key, 'string')}.ipynb`; - return path.join(this.context.globalStoragePath, file); - } - - private async close(): Promise { - const actuallyClose = async () => { - // Tell listeners. - this.closedEvent.fire(this); - - // Restart our kernel so that execution counts are reset - let oldAsk: boolean | undefined = false; - const settings = this.configuration.getSettings(); - if (settings && settings.datascience) { - oldAsk = settings.datascience.askForKernelRestart; - settings.datascience.askForKernelRestart = false; - } - await this.restartKernel(); - if (oldAsk && settings && settings.datascience) { - settings.datascience.askForKernelRestart = true; - } - }; - - // Ask user if they want to save. It seems hotExit has no bearing on - // whether or not we should ask - if (this._dirty) { - const askResult = await this.askForSave(); - switch (askResult) { - case AskForSaveResult.Yes: - // Save the file - await this.saveToDisk(); - - // Close it - await actuallyClose(); - break; - - case AskForSaveResult.No: - // Mark as not dirty, so we update our storage - await this.setClean(); - - // Close it - await actuallyClose(); - break; - - default: - // Reopen - await this.reopen(this.visibleCells); - break; - } - } else { - // Not dirty, just close normally. - return actuallyClose(); - } - } - - private editCell(request: IEditCell) { - // Apply the changes to the visible cell list. We won't get an update until - // submission otherwise - if (request.changes && request.changes.length) { - const change = request.changes[0]; - const normalized = change.text.replace(/\r/g, ''); - - // Figure out which cell we're editing. - const cell = this.visibleCells.find(c => c.id === request.id); - if (cell) { - // This is an actual edit. - const contents = concatMultilineStringInput(cell.data.source); - const before = contents.substr(0, change.rangeOffset); - const after = contents.substr(change.rangeOffset + change.rangeLength); - const newContents = `${before}${normalized}${after}`; - if (contents !== newContents) { - cell.data.source = newContents; - this.setDirty().ignoreErrors(); - } - } - } + private async editCell(request: IEditCell) { + this.commandManager.executeCommand(Commands.NotebookStorage_EditCell, this.file, request); } private async insertCell(request: IInsertCell): Promise { - // Insert a cell into our visible list based on the index. They should be in sync - this.visibleCells.splice(request.index, 0, request.cell); - - return this.setDirty(); + this.commandManager.executeCommand(Commands.NotebookStorage_InsertCell, this.file, request); } private async removeCell(request: IRemoveCell): Promise { - // Filter our list - this.visibleCells = this.visibleCells.filter(v => v.id !== request.id); - return this.setDirty(); + this.commandManager.executeCommand(Commands.NotebookStorage_RemoveCell, this.file, request.id); } private async swapCells(request: ISwapCells): Promise { // Swap two cells in our list - const first = this.visibleCells.findIndex(v => v.id === request.firstCellId); - const second = this.visibleCells.findIndex(v => v.id === request.secondCellId); - if (first >= 0 && second >= 0) { - const temp = { ...this.visibleCells[first] }; - this.visibleCells[first] = this.visibleCells[second]; - this.visibleCells[second] = temp; - return this.setDirty(); - } - } - - private async askForSave(): Promise { - const message1 = localize.DataScience.dirtyNotebookMessage1().format(`${path.basename(this.file.fsPath)}`); - const message2 = localize.DataScience.dirtyNotebookMessage2(); - const yes = localize.DataScience.dirtyNotebookYes(); - const no = localize.DataScience.dirtyNotebookNo(); - // tslint:disable-next-line: messages-must-be-localized - const result = await this.applicationShell.showInformationMessage(`${message1}\n${message2}`, { modal: true }, yes, no); - switch (result) { - case yes: - return AskForSaveResult.Yes; - - case no: - return AskForSaveResult.No; - - default: - return AskForSaveResult.Cancel; - } - } - - private async setDirty(): Promise { - // Update storage if not untitled. Don't wait for results. - if (!this.isUntitled) { - this.generateNotebookContent(this.visibleCells) - .then(c => this.storeContents(c).catch(ex => traceError('Failed to generate notebook content to store in state', ex))) - .ignoreErrors(); - } - - // Then update dirty flag. - if (!this._dirty) { - this._dirty = true; - this.setTitle(`${path.basename(this.file.fsPath)}*`); - - // Tell the webview we're dirty - await this.postMessage(InteractiveWindowMessages.NotebookDirty); - - // Tell listeners we're dirty - this.modifiedEvent.fire(this); - } - } - - private async setClean(): Promise { - // Always update storage - this.storeContents(undefined).catch(ex => traceError('Failed to clear notebook store', ex)); - - if (this._dirty) { - this._dirty = false; - this.setTitle(`${path.basename(this.file.fsPath)}`); - await this.postMessage(InteractiveWindowMessages.NotebookClean); - } + this.commandManager.executeCommand(Commands.NotebookStorage_SwapCells, this.file, request); } @captureTelemetry(Telemetry.ConvertToPythonFile, undefined, false) @@ -952,7 +493,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { tempFile = await this.fileSystem.createTemporaryFile('.ipynb'); // Translate the cells into a notebook - await this.fileSystem.writeFile(tempFile.filePath, await this.generateNotebookContent(cells), { encoding: 'utf-8' }); + await this.commandManager.executeCommand(Commands.NotebookStorage_SaveAs, this.file, Uri.file(tempFile.filePath), cells); // Import this file and show it const contents = await this.importer.importFromFile(tempFile.filePath, this.file.fsPath); @@ -974,88 +515,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { await this.documentManager.showTextDocument(doc, ViewColumn.One); } - private fixupCell(cell: nbformat.ICell): nbformat.ICell { - // Source is usually a single string on input. Convert back to an array - return ({ - ...cell, - source: splitMultilineString(cell.source) - // tslint:disable-next-line: no-any - } as any) as nbformat.ICell; // nyc (code coverage) barfs on this so just trick it. - } - - private async extractPythonMainVersion(notebookData: Partial): Promise { - if ( - notebookData && - notebookData.metadata && - notebookData.metadata.language_info && - notebookData.metadata.language_info.codemirror_mode && - // tslint:disable-next-line: no-any - typeof (notebookData.metadata.language_info.codemirror_mode as any).version === 'number' - ) { - // tslint:disable-next-line: no-any - return (notebookData.metadata.language_info.codemirror_mode as any).version; - } - // Use the active interpreter - const usableInterpreter = await this.jupyterExecution.getUsableJupyterPython(); - return usableInterpreter && usableInterpreter.version ? usableInterpreter.version.major : 3; - } - - private async generateNotebookContent(cells: ICell[]): Promise { - // Make sure we have some - await this.ensureNotebookJson(); - - // Reuse our original json except for the cells. - const json = { - ...(this.notebookJson as nbformat.INotebookContent), - cells: cells.map(c => this.fixupCell(c.data)) - }; - return JSON.stringify(json, null, this.indentAmount); - } - - @captureTelemetry(Telemetry.Save, undefined, true) - private async saveToDisk(): Promise { - // If we're already in the middle of prompting the user to save, then get out of here. - // We could add a debounce decorator, unfortunately that slows saving (by waiting for no more save events to get sent). - if (this.isPromptingToSaveToDisc && this.isUntitled) { - return; - } - try { - let fileToSaveTo: Uri | undefined = this.file; - let isDirty = this._dirty; - - // Ask user for a save as dialog if no title - if (this.isUntitled) { - this.isPromptingToSaveToDisc = true; - const filtersKey = localize.DataScience.dirtyNotebookDialogFilter(); - const filtersObject: { [name: string]: string[] } = {}; - filtersObject[filtersKey] = ['ipynb']; - isDirty = true; - - fileToSaveTo = await this.applicationShell.showSaveDialog({ - saveLabel: localize.DataScience.dirtyNotebookDialogTitle(), - filters: filtersObject - }); - } - - if (fileToSaveTo && isDirty) { - // Write out our visible cells - await this.fileSystem.writeFile(fileToSaveTo.fsPath, await this.generateNotebookContent(this.visibleCells)); - - // Update our file name and dirty state - this._file = fileToSaveTo; - await this.setClean(); - this.savedEvent.fire(this); - } - } catch (e) { - traceError(e); - } finally { - this.isPromptingToSaveToDisc = false; - } - } - private saveAll(args: ISaveAll) { - this.visibleCells = args.cells; - this.saveToDisk().ignoreErrors(); + this.commandManager.executeCommand(Commands.NotebookStorage_Save, this.file, args); } private logNativeCommand(args: INativeCommand) { @@ -1071,11 +532,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } private async clearAllOutputs() { - this.visibleCells.forEach(cell => { - cell.data.execution_count = null; - cell.data.outputs = []; - }); - - await this.setDirty(); + this.commandManager.executeCommand(Commands.NotebookStorage_ClearCellOutputs, this.file); } } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index c0e804e689f9..94e9b6bf1340 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -2,14 +2,11 @@ // Licensed under the MIT License. 'use strict'; import { inject, injectable } from 'inversify'; -import * as path from 'path'; -import { Event, EventEmitter, TextDocument, TextEditor, Uri, WebviewCustomEditorProvider } from 'vscode'; +import { Event, Uri } from 'vscode'; import { ICommandManager, ICustomEditorService, IDocumentManager, IWorkspaceService } from '../../common/application/types'; -import { JUPYTER_LANGUAGE } from '../../common/constants'; import { IFileSystem } from '../../common/platform/types'; import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../common/types'; -import * as localize from '../../common/utils/localize'; import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { Identifiers, Settings, Telemetry } from '../constants'; @@ -47,7 +44,6 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp if (customEditorService.supportsCustomEditors) { this.realProvider = new CustomNativeEditorProvider( serviceContainer, - asyncRegistry, disposables, workspace, configuration, diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index e1d46c5ffd3c..fec261d04a94 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -320,7 +320,6 @@ export interface INotebookEditor extends IInteractiveBase { readonly executed: Event; readonly modified: Event; readonly saved: Event; - readonly metadataUpdated: Event; /** * Is this notebook representing an untitled file which has never been saved yet. */ @@ -332,7 +331,7 @@ export interface INotebookEditor extends IInteractiveBase { readonly file: Uri; readonly visible: boolean; readonly active: boolean; - load(contents: string, file: Uri): Promise; + load(storage: INotebookStorage): Promise; runAllCells(): void; runSelectedCell(): void; addCellBelow(): void; @@ -729,5 +728,12 @@ export interface IJupyterInterpreterDependencyManager { export interface INotebookStorage { readonly file: Uri; - save(): Promise; + readonly isDirty: boolean; + readonly changed: Event; + getCells(): Promise; + getJson(): Promise>; +} + +export interface INotebookEdit { + readonly changedCells: ICell[]; } From 31663f098499a723c769bcbd5d04cded3a76cc5a Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Fri, 24 Jan 2020 15:32:04 -0800 Subject: [PATCH 04/18] Idea in place --- src/client/common/application/commands.ts | 11 +- src/client/common/application/types.ts | 3 + .../common/application/webPanels/webPanel.ts | 26 +- .../interactive-common/interactiveBase.ts | 1 - .../interactive-ipynb/autoSaveService.ts | 142 ------- .../customNativeEditorProvider.ts | 131 ------- .../hackyNativeEditorProvider.ts | 273 ------------- .../interactive-ipynb/nativeEditor.ts | 18 +- .../interactive-ipynb/nativeEditorProvider.ts | 162 +++++--- ...ditorStorage.ts => nativeEditorStorage.ts} | 371 +++++++++--------- .../interactive-window/interactiveWindow.ts | 3 +- src/client/datascience/types.ts | 17 +- src/client/datascience/webViewHost.ts | 7 +- 13 files changed, 335 insertions(+), 830 deletions(-) delete mode 100644 src/client/datascience/interactive-ipynb/autoSaveService.ts delete mode 100644 src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts delete mode 100644 src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts rename src/client/datascience/interactive-ipynb/{hackyNativeEditorStorage.ts => nativeEditorStorage.ts} (61%) diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index c8e73332464f..9b684052b1bb 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -6,10 +6,10 @@ import { CancellationToken, Position, TextDocument, Uri } from 'vscode'; import { Commands as LSCommands } from '../../activation/languageServer/constants'; import { Commands as DSCommands } from '../../datascience/constants'; -import type { IEditCell, IInsertCell, ISaveAll, ISwapCells } from '../../datascience/interactive-common/interactiveWindowTypes'; -import type { LiveKernelModel } from '../../datascience/jupyter/kernels/types'; +import { IEditCell, IInsertCell, ISwapCells } from '../../datascience/interactive-common/interactiveWindowTypes'; +import { LiveKernelModel } from '../../datascience/jupyter/kernels/types'; import { ICell, IJupyterKernelSpec, INotebook } from '../../datascience/types'; -import type { PythonInterpreter } from '../../interpreter/contracts'; +import { PythonInterpreter } from '../../interpreter/contracts'; import { CommandSource } from '../../testing/common/constants'; import { TestFunction, TestsToRun } from '../../testing/common/types'; import { TestDataItem, TestWorkspaceFolder } from '../../testing/types'; @@ -90,6 +90,7 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu ['python._loadLanguageServerExtension']: {}[]; ['python.SelectAndInsertDebugConfiguration']: [TextDocument, Position, CancellationToken]; ['python.viewLanguageServerOutput']: []; + ['vscode.open']: [Uri]; [Commands.Build_Workspace_Symbols]: [boolean, CancellationToken]; [Commands.Sort_Imports]: [undefined, Uri]; [Commands.Exec_In_Terminal]: [undefined, Uri]; @@ -152,8 +153,8 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu [DSCommands.NotebookStorage_InsertCell]: [Uri, IInsertCell]; [DSCommands.NotebookStorage_RemoveCell]: [Uri, string]; [DSCommands.NotebookStorage_SwapCells]: [Uri, ISwapCells]; - [DSCommands.NotebookStorage_Save]: [Uri, ISaveAll]; + [DSCommands.NotebookStorage_Save]: [Uri, ICell[] | undefined]; [DSCommands.NotebookStorage_ClearCellOutputs]: [Uri]; - [DSCommands.NotebookStorage_SaveAs]: [Uri, Uri, ICell[]]; + [DSCommands.NotebookStorage_SaveAs]: [Uri, Uri, ICell[] | undefined]; [DSCommands.NotebookStorage_UpdateVersion]: [Uri, PythonInterpreter | undefined, IJupyterKernelSpec | LiveKernelModel | undefined]; } diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 70650a3b28dd..0055bf7722f6 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -50,6 +50,7 @@ import { Uri, ViewColumn, WebviewCustomEditorProvider, + WebviewPanel, WebviewPanelOptions, WindowState, WorkspaceConfiguration, @@ -985,6 +986,8 @@ export interface IWebPanelOptions { cwd: string; // tslint:disable-next-line: no-any settings?: any; + // Web panel to use if supplied by VS code instead + webViewPanel?: WebviewPanel; } // Wraps the VS Code api for creating a web panel diff --git a/src/client/common/application/webPanels/webPanel.ts b/src/client/common/application/webPanels/webPanel.ts index e0de1174c4c3..a4c4134b1aa5 100644 --- a/src/client/common/application/webPanels/webPanel.ts +++ b/src/client/common/application/webPanels/webPanel.ts @@ -31,18 +31,20 @@ export class WebPanel implements IWebPanel { private token: string | undefined, private options: IWebPanelOptions ) { - this.panel = window.createWebviewPanel( - options.title.toLowerCase().replace(' ', ''), - options.title, - { viewColumn: options.viewColumn, preserveFocus: true }, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [Uri.file(this.options.rootPath), Uri.file(this.options.cwd)], - enableFindWidget: true, - portMapping: port ? [{ webviewPort: RemappedPort, extensionHostPort: port }] : undefined - } - ); + this.panel = options.webViewPanel + ? options.webViewPanel + : window.createWebviewPanel( + options.title.toLowerCase().replace(' ', ''), + options.title, + { viewColumn: options.viewColumn, preserveFocus: true }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [Uri.file(this.options.rootPath), Uri.file(this.options.cwd)], + enableFindWidget: true, + portMapping: port ? [{ webviewPort: RemappedPort, extensionHostPort: port }] : undefined + } + ); this.loadPromise = this.load(); } diff --git a/src/client/datascience/interactive-common/interactiveBase.ts b/src/client/datascience/interactive-common/interactiveBase.ts index f937b271c23e..3db8736d1aaf 100644 --- a/src/client/datascience/interactive-common/interactiveBase.ts +++ b/src/client/datascience/interactive-common/interactiveBase.ts @@ -62,7 +62,6 @@ import { IJupyterVariablesResponse, IMessageCell, INotebook, - INotebookEditorProvider, INotebookExporter, INotebookServer, INotebookServerOptions, diff --git a/src/client/datascience/interactive-ipynb/autoSaveService.ts b/src/client/datascience/interactive-ipynb/autoSaveService.ts deleted file mode 100644 index 5c243c859bfa..000000000000 --- a/src/client/datascience/interactive-ipynb/autoSaveService.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -'use strict'; - -import { inject, injectable } from 'inversify'; -import { ConfigurationChangeEvent, Event, EventEmitter, TextEditor, Uri } from 'vscode'; -import { IApplicationShell, IDocumentManager, IWorkspaceService } from '../../common/application/types'; -import '../../common/extensions'; -import { traceError } from '../../common/logger'; -import { IFileSystem } from '../../common/platform/types'; -import { IDisposable } from '../../common/types'; -import { INotebookIdentity, InteractiveWindowMessages } from '../interactive-common/interactiveWindowTypes'; -import { FileSettings, IInteractiveWindowListener, INotebookEditor, INotebookEditorProvider } from '../types'; - -// tslint:disable: no-any - -/** - * Sends notifications to Notebooks to save the notebook. - * Based on auto save settings, this class will regularly check for changes and send a save requet. - * If window state changes or active editor changes, then notify notebooks (if auto save is configured to do so). - * Monitor save and modified events on editor to determine its current dirty state. - * - * @export - * @class AutoSaveService - * @implements {IInteractiveWindowListener} - */ -@injectable() -export class AutoSaveService implements IInteractiveWindowListener { - private postEmitter: EventEmitter<{ message: string; payload: any }> = new EventEmitter<{ message: string; payload: any }>(); - private disposables: IDisposable[] = []; - private notebookUri?: Uri; - private timeout?: ReturnType; - constructor( - @inject(IApplicationShell) appShell: IApplicationShell, - @inject(IDocumentManager) documentManager: IDocumentManager, - @inject(INotebookEditorProvider) private readonly notebookProvider: INotebookEditorProvider, - @inject(IFileSystem) private readonly fileSystem: IFileSystem, - @inject(IWorkspaceService) private readonly workspace: IWorkspaceService - ) { - this.workspace.onDidChangeConfiguration(this.onSettingsChanded.bind(this), this, this.disposables); - this.disposables.push(appShell.onDidChangeWindowState(this.onDidChangeWindowState.bind(this))); - this.disposables.push(documentManager.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditor.bind(this))); - } - - public get postMessage(): Event<{ message: string; payload: any }> { - return this.postEmitter.event; - } - - public onMessage(message: string, payload?: any): void { - if (message === InteractiveWindowMessages.NotebookIdentity) { - this.notebookUri = Uri.parse((payload as INotebookIdentity).resource); - } - if (message === InteractiveWindowMessages.LoadAllCellsComplete) { - const notebook = this.getNotebook(); - if (!notebook) { - traceError(`Received message ${message}, but there is no notebook for ${this.notebookUri ? this.notebookUri.fsPath : undefined}`); - return; - } - this.disposables.push(notebook.modified(this.onNotebookModified, this, this.disposables)); - this.disposables.push(notebook.saved(this.onNotebookSaved, this, this.disposables)); - } - } - public dispose(): void | undefined { - this.disposables.filter(item => !!item).forEach(item => item.dispose()); - this.clearTimeout(); - } - private onNotebookModified(_: INotebookEditor) { - // If we haven't started a timer, then start if necessary. - if (!this.timeout) { - this.setTimer(); - } - } - private onNotebookSaved(_: INotebookEditor) { - // If we haven't started a timer, then start if necessary. - if (!this.timeout) { - this.setTimer(); - } - } - private getNotebook(): INotebookEditor | undefined { - const uri = this.notebookUri; - if (!uri) { - return; - } - return this.notebookProvider.editors.find(item => this.fileSystem.arePathsSame(item.file.fsPath, uri.fsPath)); - } - private getAutoSaveSettings(): FileSettings { - const filesConfig = this.workspace.getConfiguration('files', this.notebookUri); - return { - autoSave: filesConfig.get('autoSave', 'off'), - autoSaveDelay: filesConfig.get('autoSaveDelay', 1000) - }; - } - private onSettingsChanded(e: ConfigurationChangeEvent) { - if (e.affectsConfiguration('files.autoSave', this.notebookUri) || e.affectsConfiguration('files.autoSaveDelay', this.notebookUri)) { - // Reset the timer, as we may have increased it, turned it off or other. - this.clearTimeout(); - this.setTimer(); - } - } - private setTimer() { - const settings = this.getAutoSaveSettings(); - if (!settings || settings.autoSave === 'off') { - return; - } - if (settings && settings.autoSave === 'afterDelay') { - // Add a timeout to save after n milli seconds. - // Do not use setInterval, as that will cause all handlers to queue up. - this.timeout = setTimeout(() => { - this.save(); - }, settings.autoSaveDelay); - } - } - private clearTimeout() { - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = undefined; - } - } - private save() { - this.clearTimeout(); - const notebook = this.getNotebook(); - if (notebook && notebook.isDirty && !notebook.isUntitled) { - // Notify webview to perform a save. - this.postEmitter.fire({ message: InteractiveWindowMessages.DoSave, payload: undefined }); - } else { - this.setTimer(); - } - } - private onDidChangeWindowState() { - const settings = this.getAutoSaveSettings(); - if (settings && settings.autoSave === 'onWindowChange') { - this.save(); - } - } - private onDidChangeActiveTextEditor(_e?: TextEditor) { - const settings = this.getAutoSaveSettings(); - if (settings && settings.autoSave === 'onFocusChange') { - this.save(); - } - } -} diff --git a/src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts deleted file mode 100644 index f168df434658..000000000000 --- a/src/client/datascience/interactive-ipynb/customNativeEditorProvider.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -'use strict'; -import { inject, injectable } from 'inversify'; -import * as path from 'path'; -import { Event, EventEmitter, TextDocument, TextEditor, Uri, WebviewCustomEditorEditingDelegate, WebviewCustomEditorProvider, WebviewPanel } from 'vscode'; - -import { ICommandManager, ICustomEditorService, IDocumentManager, IWorkspaceService } from '../../common/application/types'; -import { JUPYTER_LANGUAGE } from '../../common/constants'; -import { IFileSystem } from '../../common/platform/types'; -import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../common/types'; -import * as localize from '../../common/utils/localize'; -import { IServiceContainer } from '../../ioc/types'; -import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; -import { Identifiers, Settings, Telemetry } from '../constants'; -import { IDataScienceErrorHandler, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../types'; - -export class CustomNativeEditorProvider implements INotebookEditorProvider, WebviewCustomEditorProvider, WebviewCustomEditorEditingDelegate { - public static readonly customEditorViewType = 'NativeEditorProvider.ipynb'; - private readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); - private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: INotebookEdit }>(); - private activeEditors: Map> = new Map>(); - private _onDidOpenNotebookEditor = new EventEmitter(); - constructor( - @inject(IServiceContainer) private serviceContainer: IServiceContainer, - @inject(IDisposableRegistry) private disposables: IDisposableRegistry, - @inject(IWorkspaceService) private workspace: IWorkspaceService, - @inject(IConfigurationService) private configuration: IConfigurationService, - @inject(IFileSystem) private fileSystem: IFileSystem, - @inject(IDocumentManager) private documentManager: IDocumentManager, - @inject(ICommandManager) private readonly cmdManager: ICommandManager, - @inject(IDataScienceErrorHandler) private dataScienceErrorHandler: IDataScienceErrorHandler, - @inject(ICustomEditorService) private customEditorService: ICustomEditorService - ) { - if (this.customEditorService.supportsCustomEditors) { - this.customEditorService.registerWebviewCustomEditorProvider(CustomNativeEditorProvider.customEditorViewType, this, { - enableFindWidget: true, - retainContextWhenHidden: true - }); - } - } - public save(resource: Uri): Thenable { - throw new Error('Method not implemented.'); - } - public saveAs(resource: Uri, targetResource: Uri): Thenable { - throw new Error('Method not implemented.'); - } - public get onEdit(): Event<{ readonly resource: Uri; readonly edit: INotebookEdit }> { - return this._editEventEmitter.event; - } - public applyEdits(resource: Uri, edits: readonly INotebookEdit[]): Thenable { - throw new Error('Method not implemented.'); - } - public undoEdits(resource: Uri, edits: readonly INotebookEdit[]): Thenable { - throw new Error('Method not implemented.'); - } - public resolveWebviewEditor(resource: Uri, webview: WebviewPanel): Thenable { - throw new Error('Method not implemented.'); - } - public get editingDelegate(): WebviewCustomEditorEditingDelegate | undefined { - return this; - } - - public get onDidChangeActiveNotebookEditor(): Event { - return this._onDidChangeActiveNotebookEditor.event; - } - - public get onDidOpenNotebookEditor(): Event { - return this._onDidOpenNotebookEditor.event; - } - - public get activeEditor(): INotebookEditor | undefined { - const active = [...this.activeEditors.entries()].find(e => e[1].active); - if (active) { - return active[1]; - } - } - - public get editors(): INotebookEditor[] { - return [...this.activeEditors.values()]; - } - - public async open(file: Uri, contents: string): Promise { - // Just use a vscode.open command. It should open the file. - } - - public async show(file: Uri): Promise { - // See if this file is open or not already - const editor = this.activeEditors.get(file.fsPath); - if (editor) { - await editor.show(); - } - return editor; - } - - @captureTelemetry(Telemetry.CreateNewNotebook, undefined, false) - public async createNew(contents?: string): Promise { - // Create a new URI for the dummy file using our root workspace path - const uri = await this.getNextNewNotebookUri(); - this.notebookCount += 1; - if (contents) { - return this.open(uri, contents); - } else { - return this.open(uri, ''); - } - } - - public async getNotebookOptions(): Promise { - return { - enableDebugging: true, - purpose: Identifiers.HistoryPurpose // Share the same one as the interactive window. Just need a new session - }; - } - - private onClosedEditor(e: INotebookEditor) { - this.activeEditors.delete(e.file.fsPath); - } - - private onOpenedEditor(e: INotebookEditor) { - this.activeEditors.set(e.file.fsPath, e); - this._onDidOpenNotebookEditor.fire(e); - } - - private onSavedEditor(oldPath: string, e: INotebookEditor) { - // Switch our key for this editor - if (this.activeEditors.has(oldPath)) { - this.activeEditors.delete(oldPath); - } - this.activeEditors.set(e.file.fsPath, e); - } -} diff --git a/src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts deleted file mode 100644 index 00b82b975bcb..000000000000 --- a/src/client/datascience/interactive-ipynb/hackyNativeEditorProvider.ts +++ /dev/null @@ -1,273 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -'use strict'; -import * as path from 'path'; -import { Event, EventEmitter, TextDocument, TextEditor, Uri } from 'vscode'; - -import { ICommandManager, IDocumentManager, IWorkspaceService } from '../../common/application/types'; -import { JUPYTER_LANGUAGE } from '../../common/constants'; -import { IFileSystem } from '../../common/platform/types'; -import { IConfigurationService, IDisposableRegistry } from '../../common/types'; -import * as localize from '../../common/utils/localize'; -import { IServiceContainer } from '../../ioc/types'; -import { captureTelemetry } from '../../telemetry'; -import { Commands, Identifiers, Settings, Telemetry } from '../constants'; -import { IDataScienceErrorHandler, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../types'; - -export class HackyNativeEditorProvider implements INotebookEditorProvider { - public get onDidChangeActiveNotebookEditor(): Event { - return this._onDidChangeActiveNotebookEditor.event; - } - private readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); - private activeEditors: Map = new Map(); - private _onDidOpenNotebookEditor = new EventEmitter(); - private nextNumber: number = 1; - public get onDidOpenNotebookEditor(): Event { - return this._onDidOpenNotebookEditor.event; - } - constructor( - private serviceContainer: IServiceContainer, - private disposables: IDisposableRegistry, - private workspace: IWorkspaceService, - private configuration: IConfigurationService, - private fileSystem: IFileSystem, - private documentManager: IDocumentManager, - private readonly cmdManager: ICommandManager, - private dataScienceErrorHandler: IDataScienceErrorHandler - ) { - // No live share sync required as open document from vscode will give us our contents. - this.disposables.push(this.documentManager.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditorHandler.bind(this))); - - // Since we may have activated after a document was opened, also run open document for all documents. - // This needs to be async though. Iterating over all of these in the .ctor is crashing the extension - // host, so postpone till after the ctor is finished. - setTimeout(() => { - if (this.documentManager.textDocuments && this.documentManager.textDocuments.forEach) { - this.documentManager.textDocuments.forEach(doc => this.openNotebookAndCloseEditor(doc, false)); - } - }, 0); - - // // Reopen our list of files that were open during shutdown. Actually not doing this for now. The files - // don't open until the extension loads and all they all steal focus. - // const uriList = this.workspaceStorage.get(NotebookUriListStorageKey); - // if (uriList && uriList.length) { - // uriList.forEach(u => { - // this.fileSystem.readFile(u.fsPath).then(c => this.open(u, c).ignoreErrors()).ignoreErrors(); - // }); - // } - - // Signup for the notebook commands - this.cmdManager.registerCommand(Commands.NotebookStorage_ClearCellOutputs, this.; - } - - public get activeEditor(): INotebookEditor | undefined { - const active = [...this.activeEditors.entries()].find(e => e[1].active); - if (active) { - return active[1]; - } - } - - public get editors(): INotebookEditor[] { - return [...this.activeEditors.values()]; - } - - public async open(file: Uri, contents: string): Promise { - // See if this file is open or not already - let editor = this.activeEditors.get(file.fsPath); - if (!editor) { - editor = await this.create(file, contents); - this.onOpenedEditor(editor); - } else { - await editor.show(); - } - return editor; - } - - public async show(file: Uri): Promise { - // See if this file is open or not already - const editor = this.activeEditors.get(file.fsPath); - if (editor) { - await editor.show(); - } - return editor; - } - - @captureTelemetry(Telemetry.CreateNewNotebook, undefined, false) - public async createNew(contents?: string): Promise { - // Create a new URI for the dummy file using our root workspace path - const uri = await this.getNextNewNotebookUri(); - if (contents) { - return this.open(uri, contents); - } else { - return this.open(uri, ''); - } - } - - public async getNotebookOptions(): Promise { - const settings = this.configuration.getSettings(); - let serverURI: string | undefined = settings.datascience.jupyterServerURI; - const useDefaultConfig: boolean | undefined = settings.datascience.useDefaultConfigForJupyter; - - // For the local case pass in our URI as undefined, that way connect doesn't have to check the setting - if (serverURI.toLowerCase() === Settings.JupyterServerLocalLaunch) { - serverURI = undefined; - } - - return { - enableDebugging: true, - uri: serverURI, - useDefaultConfig, - purpose: Identifiers.HistoryPurpose // Share the same one as the interactive window. Just need a new session - }; - } - - /** - * Open ipynb files when user opens an ipynb file. - * - * @private - * @memberof NativeEditorProvider - */ - private onDidChangeActiveTextEditorHandler(editor?: TextEditor) { - // I we're a source control diff view, then ignore this editor. - if (!editor || this.isEditorPartOfDiffView(editor)) { - return; - } - this.openNotebookAndCloseEditor(editor.document, true).ignoreErrors(); - } - - private async create(file: Uri, contents: string): Promise { - const editor = this.serviceContainer.get(INotebookEditor); - await editor.load(contents, file); - this.disposables.push(editor.closed(this.onClosedEditor.bind(this))); - await editor.show(); - return editor; - } - - private onClosedEditor(e: INotebookEditor) { - this.activeEditors.delete(e.file.fsPath); - } - - private onOpenedEditor(e: INotebookEditor) { - this.activeEditors.set(e.file.fsPath, e); - this.disposables.push(e.saved(this.onSavedEditor.bind(this, e.file.fsPath))); - this._onDidOpenNotebookEditor.fire(e); - this.disposables.push(e.onDidChangeViewState(this.onDidChangeViewState, this)); - } - private onDidChangeViewState() { - this._onDidChangeActiveNotebookEditor.fire(this.activeEditor); - } - - private onSavedEditor(oldPath: string, e: INotebookEditor) { - // Switch our key for this editor - if (this.activeEditors.has(oldPath)) { - this.activeEditors.delete(oldPath); - } - this.activeEditors.set(e.file.fsPath, e); - } - - private async getNextNewNotebookUri(): Promise { - // Start in the root and look for files starting with untitled - let number = 1; - const dir = this.workspace.rootPath; - if (dir) { - const existing = await this.fileSystem.search(path.join(dir, `${localize.DataScience.untitledNotebookFileName()}-*.ipynb`)); - - // Sort by number - existing.sort(); - - // Add one onto the end of the last one - if (existing.length > 0) { - const match = /(\w+)-(\d+)\.ipynb/.exec(path.basename(existing[existing.length - 1])); - if (match && match.length > 1) { - number = parseInt(match[2], 10); - } - return Uri.file(path.join(dir, `${localize.DataScience.untitledNotebookFileName()}-${number + 1}`)); - } - } - - const result = Uri.file(`${localize.DataScience.untitledNotebookFileName()}-${this.nextNumber}`); - this.nextNumber += 1; - return result; - } - - private openNotebookAndCloseEditor = async (document: TextDocument, closeDocumentBeforeOpeningNotebook: boolean) => { - // See if this is an ipynb file - if (this.isNotebook(document) && this.configuration.getSettings().datascience.useNotebookEditor) { - const closeActiveEditorCommand = 'workbench.action.closeActiveEditor'; - try { - const contents = document.getText(); - const uri = document.uri; - - if (closeDocumentBeforeOpeningNotebook) { - if (!this.documentManager.activeTextEditor || this.documentManager.activeTextEditor.document !== document) { - await this.documentManager.showTextDocument(document); - } - await this.cmdManager.executeCommand(closeActiveEditorCommand); - } - - // Open our own editor. - await this.open(uri, contents); - - if (!closeDocumentBeforeOpeningNotebook) { - // Then switch back to the ipynb and close it. - // If we don't do it in this order, the close will switch to the wrong item - await this.documentManager.showTextDocument(document); - await this.cmdManager.executeCommand(closeActiveEditorCommand); - } - } catch (e) { - return this.dataScienceErrorHandler.handleError(e); - } - } - }; - /** - * Check if user is attempting to compare two ipynb files. - * If yes, then return `true`, else `false`. - * - * @private - * @param {TextEditor} editor - * @memberof NativeEditorProvider - */ - private isEditorPartOfDiffView(editor?: TextEditor) { - if (!editor) { - return false; - } - // There's no easy way to determine if the user is openeing a diff view. - // One simple way is to check if there are 2 editor opened, and if both editors point to the same file - // One file with the `file` scheme and the other with the `git` scheme. - if (this.documentManager.visibleTextEditors.length <= 1) { - return false; - } - - // If we have both `git` & `file` schemes for the same file, then we're most likely looking at a diff view. - // Also ensure both editors are in the same view column. - // Possible we have a git diff view (with two editors git and file scheme), and we open the file view - // on the side (different view column). - const gitSchemeEditor = this.documentManager.visibleTextEditors.find( - editorUri => editorUri.document.uri.scheme === 'git' && this.fileSystem.arePathsSame(editorUri.document.uri.fsPath, editor.document.uri.fsPath) - ); - - if (!gitSchemeEditor) { - return false; - } - - const fileSchemeEditor = this.documentManager.visibleTextEditors.find( - editorUri => - editorUri.document.uri.scheme === 'file' && - this.fileSystem.arePathsSame(editorUri.document.uri.fsPath, editor.document.uri.fsPath) && - editorUri.viewColumn === gitSchemeEditor.viewColumn - ); - if (!fileSchemeEditor) { - return false; - } - - // Also confirm the document we have passed in, belongs to one of the editors. - // If its not, then its another document (that is not in the diff view). - return gitSchemeEditor === editor || fileSchemeEditor === editor; - } - private isNotebook(document: TextDocument) { - // Only support file uris (we don't want to automatically open any other ipynb file from another resource as a notebook). - // E.g. when opening a document for comparison, the scheme is `git`, in live share the scheme is `vsls`. - const validUriScheme = document.uri.scheme === 'file' || document.uri.scheme === 'vsls'; - return validUriScheme && (document.languageId === JUPYTER_LANGUAGE || path.extname(document.fileName).toLocaleLowerCase() === '.ipynb'); - } -} diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index e5c38dde1d14..ac6e049267df 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -3,19 +3,15 @@ 'use strict'; import '../../common/extensions'; -import { nbformat } from '@jupyterlab/coreutils/lib/nbformat'; -import * as detectIndent from 'detect-indent'; import { inject, injectable, multiInject, named } from 'inversify'; import * as path from 'path'; -import { Event, EventEmitter, Memento, Uri, ViewColumn } from 'vscode'; +import { Event, EventEmitter, Memento, Uri, ViewColumn, WebviewPanel } from 'vscode'; -import { concatMultilineStringInput, splitMultilineString } from '../../../datascience-ui/common'; import { createCodeCell, createErrorOutput } from '../../../datascience-ui/common/cellFactory'; import { IApplicationShell, ICommandManager, IDocumentManager, ILiveShareApi, IWebPanelProvider, IWorkspaceService } from '../../common/application/types'; import { ContextKey } from '../../common/contextKey'; -import { traceError } from '../../common/logger'; import { IFileSystem, TemporaryFile } from '../../common/platform/types'; -import { GLOBAL_MEMENTO, IConfigurationService, ICryptoUtils, IDisposableRegistry, IExtensionContext, IMemento, WORKSPACE_MEMENTO } from '../../common/types'; +import { GLOBAL_MEMENTO, IConfigurationService, IDisposableRegistry, IMemento } from '../../common/types'; import { createDeferred, Deferred } from '../../common/utils/async'; import * as localize from '../../common/utils/localize'; import { StopWatch } from '../../common/utils/stopWatch'; @@ -35,7 +31,6 @@ import { ISwapCells, SysInfoReason } from '../interactive-common/interactiveWindowTypes'; -import { InvalidNotebookFileError } from '../jupyter/invalidNotebookFileError'; import { ProgressReporter } from '../progress/progressReporter'; import { CellState, @@ -98,9 +93,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { @inject(INotebookImporter) private importer: INotebookImporter, @inject(IDataScienceErrorHandler) errorHandler: IDataScienceErrorHandler, @inject(IMemento) @named(GLOBAL_MEMENTO) globalStorage: Memento, - @inject(IMemento) @named(WORKSPACE_MEMENTO) private localStorage: Memento, - @inject(ICryptoUtils) private crypto: ICryptoUtils, - @inject(IExtensionContext) private context: IExtensionContext, @inject(ProgressReporter) progressReporter: ProgressReporter ) { super( @@ -157,7 +149,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { return this.close(); } - public async load(storage: INotebookStorage): Promise { + public async load(storage: INotebookStorage, webViewPanel: WebviewPanel): Promise { // Save the storage we're using this._storage = storage; @@ -166,7 +158,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Load the web panel using our file path so it can find // relative files next to the notebook. - await super.loadWebPanel(path.dirname(this.file.fsPath)); + await super.loadWebPanel(path.dirname(this.file.fsPath), webViewPanel); // Update our title to match this.setTitle(path.basename(this.file.fsPath)); @@ -516,7 +508,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } private saveAll(args: ISaveAll) { - this.commandManager.executeCommand(Commands.NotebookStorage_Save, this.file, args); + this.commandManager.executeCommand(Commands.NotebookStorage_Save, this.file, args.cells); } private logNativeCommand(args: INativeCommand) { diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index 94e9b6bf1340..04dc1143376b 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -2,34 +2,35 @@ // Licensed under the MIT License. 'use strict'; import { inject, injectable } from 'inversify'; -import { Event, Uri } from 'vscode'; +import { Disposable, Event, EventEmitter, Uri, WebviewCustomEditorEditingDelegate, WebviewCustomEditorProvider, WebviewPanel } from 'vscode'; -import { ICommandManager, ICustomEditorService, IDocumentManager, IWorkspaceService } from '../../common/application/types'; +import { ICommandManager, ICustomEditorService, IWorkspaceService } from '../../common/application/types'; import { IFileSystem } from '../../common/platform/types'; -import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../common/types'; +import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService } from '../../common/types'; +import { createDeferred } from '../../common/utils/async'; import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; -import { Identifiers, Settings, Telemetry } from '../constants'; -import { IDataScienceErrorHandler, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../types'; -import { CustomNativeEditorProvider } from './customNativeEditorProvider'; -import { HackyNativeEditorProvider } from './hackyNativeEditorProvider'; +import { Commands, Identifiers, Settings, Telemetry } from '../constants'; +import { ILoadableNotebookStorage, INotebookEdit, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../types'; @injectable() -export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisposable { +export class NativeEditorProvider implements INotebookEditorProvider, WebviewCustomEditorProvider, WebviewCustomEditorEditingDelegate, IAsyncDisposable { + public static readonly customEditorViewType = 'NativeEditorProvider.ipynb'; + private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: INotebookEdit }>(); + private openedEditors: Set = new Set(); + private storage: Map> = new Map>(); + private storageChangedHandlers: Map = new Map(); + private _onDidOpenNotebookEditor = new EventEmitter(); private executedEditors: Set = new Set(); private notebookCount: number = 0; private openedNotebookCount: number = 0; - private realProvider: INotebookEditorProvider; constructor( - @inject(IServiceContainer) serviceContainer: IServiceContainer, + @inject(IServiceContainer) private serviceContainer: IServiceContainer, @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, - @inject(IDisposableRegistry) disposables: IDisposableRegistry, @inject(IWorkspaceService) workspace: IWorkspaceService, @inject(IConfigurationService) private configuration: IConfigurationService, - @inject(IFileSystem) fileSystem: IFileSystem, - @inject(IDocumentManager) documentManager: IDocumentManager, - @inject(ICommandManager) cmdManager: ICommandManager, - @inject(IDataScienceErrorHandler) dataScienceErrorHandler: IDataScienceErrorHandler, + @inject(IFileSystem) private fileSystem: IFileSystem, + @inject(ICommandManager) private cmdManager: ICommandManager, @inject(ICustomEditorService) customEditorService: ICustomEditorService ) { asyncRegistry.push(this); @@ -41,40 +42,44 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp findFilesPromise.then(r => (this.notebookCount += r.length)); } - if (customEditorService.supportsCustomEditors) { - this.realProvider = new CustomNativeEditorProvider( - serviceContainer, - disposables, - workspace, - configuration, - fileSystem, - documentManager, - cmdManager, - dataScienceErrorHandler, - customEditorService - ); - } else { - this.realProvider = new HackyNativeEditorProvider( - serviceContainer, - disposables, - workspace, - configuration, - fileSystem, - documentManager, - cmdManager, - dataScienceErrorHandler - ); - } - - // When opening keep track of execution for our telemetry - this.realProvider.onDidOpenNotebookEditor(this.openedEditor.bind(this)); + // Register for the custom editor service. + customEditorService.registerWebviewCustomEditorProvider(NativeEditorProvider.customEditorViewType, this, { enableFindWidget: true, retainContextWhenHidden: true }); } - public get onDidChangeActiveNotebookEditor(): Event { - return this.realProvider.onDidChangeActiveNotebookEditor; + public save(resource: Uri): Thenable { + return this.cmdManager.executeCommand(Commands.NotebookStorage_Save, resource, undefined); + } + public saveAs(resource: Uri, targetResource: Uri): Thenable { + return this.cmdManager.executeCommand(Commands.NotebookStorage_SaveAs, resource, targetResource, undefined); + } + public get onEdit(): Event<{ readonly resource: Uri; readonly edit: INotebookEdit }> { + return this._editEventEmitter.event; + } + public applyEdits(_resource: Uri, _edits: readonly INotebookEdit[]): Thenable { + throw new Error('Method not implemented.'); + } + public undoEdits(_resource: Uri, _edits: readonly INotebookEdit[]): Thenable { + throw new Error('Method not implemented.'); } + public resolveWebviewEditor(resource: Uri, webview: WebviewPanel): Thenable { + // Get the storage + return this.getStorage(resource).then(s => { + // Create a new editor + const editor = this.serviceContainer.get(INotebookEditor); + + // Indicate opened + this.openedEditor(editor); + + // Load it (should already be visible) + return editor.load(s, webview); + }); + } + public get editingDelegate(): WebviewCustomEditorEditingDelegate | undefined { + return this; + } + public get onDidOpenNotebookEditor(): Event { - return this.realProvider.onDidOpenNotebookEditor; + return this._onDidOpenNotebookEditor.event; } public async dispose(): Promise { @@ -90,24 +95,51 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp } } public get activeEditor(): INotebookEditor | undefined { - return this.realProvider.activeEditor; + return this.editors.find(e => e.visible && e.active); } public get editors(): INotebookEditor[] { - return this.realProvider.editors; + return [...this.openedEditors]; } - public async open(file: Uri, contents: string): Promise { - return this.realProvider.open(file, contents); + public async open(file: Uri): Promise { + // Create a deferred promise that will fire when the notebook + // actually opens + const deferred = createDeferred(); + + // Sign up for open event once it does open + let disposable: Disposable | undefined; + const handler = (e: INotebookEditor) => { + if (e.file === file) { + if (disposable) { + disposable.dispose(); + } + deferred.resolve(e); + } + }; + disposable = this.onDidOpenNotebookEditor(handler); + + // Send an open command. + this.cmdManager.executeCommand('vscode.open', file); + + // Promise should resolve when the file opens. + return deferred.promise; } public async show(file: Uri): Promise { - return this.realProvider.show(file); + return this.open(file); } @captureTelemetry(Telemetry.CreateNewNotebook, undefined, false) public async createNew(contents?: string): Promise { - return this.realProvider.createNew(contents); + // Create a temporary file on disk to hold the contents + const tempFile = await this.fileSystem.createTemporaryFile('ipynb'); + if (contents) { + await this.fileSystem.writeFile(tempFile.filePath, contents, 'utf-8'); + } + + // Use an 'untitled' URI + return this.open(Uri.parse(`untitled://${tempFile.filePath}`)); } public async getNotebookOptions(): Promise { @@ -128,11 +160,18 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp }; } + private closedEditor(editor: INotebookEditor): void { + this.openedEditors.delete(editor); + } + private openedEditor(editor: INotebookEditor): void { this.openedNotebookCount += 1; if (!this.executedEditors.has(editor.file.fsPath)) { editor.executed(this.onExecuted.bind(this)); } + this.openedEditors.add(editor); + editor.closed.bind(this.closedEditor.bind(this)); + this._onDidOpenNotebookEditor.fire(editor); } private onExecuted(editor: INotebookEditor): void { @@ -140,4 +179,25 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp this.executedEditors.add(editor.file.fsPath); } } + + private async storageChanged(file: Uri): Promise { + // When the storage changes, tell VS code about the edit + const storage = await this.getStorage(file); + const cells = await storage.getCells(); + this._editEventEmitter.fire({ resource: file, edit: { contents: cells } }); + } + + private getStorage(file: Uri): Promise { + let storagePromise = this.storage.get(file.fsPath); + if (!storagePromise) { + const storage = this.serviceContainer.get(ILoadableNotebookStorage); + storagePromise = storage.load(file).then(_v => { + this.storageChangedHandlers.set(file.fsPath, storage.changed(this.storageChanged.bind(this, file))); + return storage; + }); + + this.storage.set(file.fsPath, storagePromise); + } + return storagePromise; + } } diff --git a/src/client/datascience/interactive-ipynb/hackyNativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts similarity index 61% rename from src/client/datascience/interactive-ipynb/hackyNativeEditorStorage.ts rename to src/client/datascience/interactive-ipynb/nativeEditorStorage.ts index 44c061b67860..dc56dbd7fb64 100644 --- a/src/client/datascience/interactive-ipynb/hackyNativeEditorStorage.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts @@ -4,35 +4,31 @@ import * as uuid from 'uuid/v4'; import { Event, EventEmitter, Memento, Uri } from 'vscode'; import { concatMultilineStringInput, splitMultilineString } from '../../../datascience-ui/common'; import { createCodeCell } from '../../../datascience-ui/common/cellFactory'; -import { IApplicationShell, IWorkspaceService } from '../../common/application/types'; +import { ICommandManager, IWorkspaceService } from '../../common/application/types'; import { traceError } from '../../common/logger'; import { IFileSystem } from '../../common/platform/types'; -import { ICryptoUtils, IExtensionContext } from '../../common/types'; +import { GLOBAL_MEMENTO, ICryptoUtils, IExtensionContext, IMemento, WORKSPACE_MEMENTO } from '../../common/types'; import * as localize from '../../common/utils/localize'; import { noop } from '../../common/utils/misc'; import { PythonInterpreter } from '../../interpreter/contracts'; -import { captureTelemetry } from '../../telemetry'; -import { Identifiers, Telemetry } from '../constants'; -import { IEditCell, IInsertCell, IRemoveCell, ISwapCells } from '../interactive-common/interactiveWindowTypes'; +import { Commands, Identifiers } from '../constants'; +import { IEditCell, IInsertCell, ISwapCells } from '../interactive-common/interactiveWindowTypes'; import { InvalidNotebookFileError } from '../jupyter/invalidNotebookFileError'; import { LiveKernelModel } from '../jupyter/kernels/types'; -import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, INotebookEditorProvider, INotebookStorage } from '../types'; +import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, ILoadableNotebookStorage, INotebookStorage } from '../types'; // tslint:disable-next-line:no-require-imports no-var-requires const debounce = require('lodash/debounce') as typeof import('lodash/debounce'); // tslint:disable-next-line:no-require-imports no-var-requires import detectIndent = require('detect-indent'); - -enum AskForSaveResult { - Yes, - No, - Cancel -} +import { inject, injectable, named } from 'inversify'; const KeyPrefix = 'notebook-storage-'; const NotebookTransferKey = 'notebook-transfered'; -export class HackyNativeEditorStorage implements INotebookStorage { + +@injectable() +export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookStorage { public get isDirty(): boolean { return this._isDirty; } @@ -47,159 +43,221 @@ export class HackyNativeEditorStorage implements INotebookStorage { const baseName = path.basename(this.file.fsPath); return baseName.includes(localize.DataScience.untitledNotebookFileName()); } + private static signedUpForCommands = false; + + private static storageMap = new Map(); private _changedEmitter = new EventEmitter(); private _cells: ICell[] = []; private _loadPromise: Promise | undefined; private _loaded = false; - private _file: Uri; + private _file: Uri = Uri.file(''); private _isDirty: boolean = false; private indentAmount: string = ' '; private notebookJson: Partial = {}; - private isPromptingToSaveToDisc = false; private debouncedWriteToStorage = debounce(this.writeToStorage.bind(this), 250); constructor( - file: Uri, - private initialContents: string, - private workspaceService: IWorkspaceService, - private jupyterExecution: IJupyterExecution, - private fileSystem: IFileSystem, - private crypto: ICryptoUtils, - private context: IExtensionContext, - private applicationShell: IApplicationShell, - private provider: INotebookEditorProvider, - private globalStorage: Memento, - private localStorage: Memento + @inject(IWorkspaceService) private workspaceService: IWorkspaceService, + @inject(IJupyterExecution) private jupyterExecution: IJupyterExecution, + @inject(IFileSystem) private fileSystem: IFileSystem, + @inject(ICryptoUtils) private crypto: ICryptoUtils, + @inject(IExtensionContext) private context: IExtensionContext, + @inject(IMemento) @named(GLOBAL_MEMENTO) private globalStorage: Memento, + @inject(IMemento) @named(WORKSPACE_MEMENTO) private localStorage: Memento, + @inject(ICommandManager) cmdManager: ICommandManager ) { - this._file = file; - } - public getCells(): Promise { - if (!this._loadPromise) { - this._loadPromise = this.load(); - } - if (!this._loaded) { - return this._loadPromise; + // Sign up for commands if this is the first storage created. + if (!NativeEditorStorage.signedUpForCommands) { + NativeEditorStorage.registerCommands(cmdManager); } + } - // If already loaded, return the updated cell values - return Promise.resolve(this._cells); + private static registerCommands(commandManager: ICommandManager): void { + NativeEditorStorage.signedUpForCommands = true; + commandManager.registerCommand(Commands.NotebookStorage_ClearCellOutputs, NativeEditorStorage.handleClearAllOutputs); + commandManager.registerCommand(Commands.NotebookStorage_Close, NativeEditorStorage.handleClose); + commandManager.registerCommand(Commands.NotebookStorage_DeleteAllCells, NativeEditorStorage.handleDeleteAllCells); + commandManager.registerCommand(Commands.NotebookStorage_EditCell, NativeEditorStorage.handleEdit); + commandManager.registerCommand(Commands.NotebookStorage_InsertCell, NativeEditorStorage.handleInsert); + commandManager.registerCommand(Commands.NotebookStorage_ModifyCells, NativeEditorStorage.handleModifyCells); + commandManager.registerCommand(Commands.NotebookStorage_RemoveCell, NativeEditorStorage.handleRemoveCell); + commandManager.registerCommand(Commands.NotebookStorage_SwapCells, NativeEditorStorage.handleSwapCells); + commandManager.registerCommand(Commands.NotebookStorage_Save, NativeEditorStorage.handleSave); + commandManager.registerCommand(Commands.NotebookStorage_SaveAs, NativeEditorStorage.handleSaveAs); + commandManager.registerCommand(Commands.NotebookStorage_UpdateVersion, NativeEditorStorage.handleUpdateVersionInfo); + } + + private static async getStorage(resource: Uri): Promise { + const storage = NativeEditorStorage.storageMap.get(resource.fsPath); + if (storage && storage._loadPromise) { + await storage._loadPromise; + return storage; + } + return undefined; } - public async getJson(): Promise> { - await this.ensureNotebookJson(); - return this.notebookJson; + private static handleCallback(resource: Uri, callback: (storage: NativeEditorStorage) => Promise): Promise { + return NativeEditorStorage.getStorage(resource).then(s => { + if (s) { + return callback(s); + } + }); } - public async handleEdit(request: IEditCell): Promise { - // Apply the changes to the visible cell list. We won't get an update until - // submission otherwise - if (request.changes && request.changes.length) { - const change = request.changes[0]; - const normalized = change.text.replace(/\r/g, ''); - - // Figure out which cell we're editing. - const cell = this._cells.find(c => c.id === request.id); - if (cell) { - // This is an actual edit. - const contents = concatMultilineStringInput(cell.data.source); - const before = contents.substr(0, change.rangeOffset); - const after = contents.substr(change.rangeOffset + change.rangeLength); - const newContents = `${before}${normalized}${after}`; - if (contents !== newContents) { - cell.data.source = newContents; - return this.setDirty(); + private static async handleEdit(resource: Uri, request: IEditCell): Promise { + return NativeEditorStorage.handleCallback(resource, async s => { + // Apply the changes to the visible cell list. We won't get an update until + // submission otherwise + if (request.changes && request.changes.length) { + const change = request.changes[0]; + const normalized = change.text.replace(/\r/g, ''); + + // Figure out which cell we're editing. + const cell = s._cells.find(c => c.id === request.id); + if (cell) { + // This is an actual edit. + const contents = concatMultilineStringInput(cell.data.source); + const before = contents.substr(0, change.rangeOffset); + const after = contents.substr(change.rangeOffset + change.rangeLength); + const newContents = `${before}${normalized}${after}`; + if (contents !== newContents) { + cell.data.source = newContents; + return s.setDirty(); + } } } - } + }); } - public async handleInsert(request: IInsertCell): Promise { - // Insert a cell into our visible list based on the index. They should be in sync - this._cells.splice(request.index, 0, request.cell); - return this.setDirty(); + private static async handleInsert(resource: Uri, request: IInsertCell): Promise { + return NativeEditorStorage.handleCallback(resource, async s => { + // Insert a cell into our visible list based on the index. They should be in sync + s._cells.splice(request.index, 0, request.cell); + return s.setDirty(); + }); } - public async handleRemoveCell(request: IRemoveCell): Promise { + private static async handleRemoveCell(resource: Uri, id: string): Promise { // Filter our list - this._cells = this._cells.filter(v => v.id !== request.id); - return this.setDirty(); + return NativeEditorStorage.handleCallback(resource, async s => { + s._cells = s._cells.filter(v => v.id !== id); + return s.setDirty(); + }); } - public async handleSwapCells(request: ISwapCells): Promise { + private static async handleSwapCells(resource: Uri, request: ISwapCells): Promise { // Swap two cells in our list - const first = this._cells.findIndex(v => v.id === request.firstCellId); - const second = this._cells.findIndex(v => v.id === request.secondCellId); - if (first >= 0 && second >= 0) { - const temp = { ...this._cells[first] }; - this._cells[first] = this._cells[second]; - this._cells[second] = temp; - return this.setDirty(); - } + return NativeEditorStorage.handleCallback(resource, async s => { + const first = s._cells.findIndex(v => v.id === request.firstCellId); + const second = s._cells.findIndex(v => v.id === request.secondCellId); + if (first >= 0 && second >= 0) { + const temp = { ...s._cells[first] }; + s._cells[first] = s._cells[second]; + s._cells[second] = temp; + return s.setDirty(); + } + }); } - public async handleDeleteAllCells(): Promise { - this._cells = []; - return this.setDirty(); + private static handleDeleteAllCells(resource: Uri): Promise { + return NativeEditorStorage.handleCallback(resource, async s => { + s._cells = []; + return s.setDirty(); + }); } - public async handleModifyCells(cells: ICell[]): Promise { - // Update these cells in our list - cells.forEach(c => { - const index = this._cells.findIndex(v => v.id === c.id); - this._cells[index] = c; + private static handleClearAllOutputs(resource: Uri): Promise { + return NativeEditorStorage.handleCallback(resource, async s => { + s._cells.forEach(cell => { + cell.data.execution_count = null; + cell.data.outputs = []; + }); + return s.setDirty(); }); + } + + private static async handleModifyCells(resource: Uri, cells: ICell[]): Promise { + return NativeEditorStorage.handleCallback(resource, async s => { + // Update these cells in our list + cells.forEach(c => { + const index = s._cells.findIndex(v => v.id === c.id); + s._cells[index] = c; + }); - // Indicate dirty - return this.setDirty(); + // Indicate dirty + return s.setDirty(); + }); } - public async handleSaveAs(newFile: Uri, cells: ICell[]): Promise { - return this.fileSystem.writeFile(newFile.fsPath, await this.generateNotebookContent(cells), { encoding: 'utf-8' }); + private static async handleSave(resource: Uri, cells: ICell[] | undefined): Promise { + return NativeEditorStorage.handleCallback(resource, async s => { + return NativeEditorStorage.handleSaveAs(s._file, s._file, cells); + }); } - public async handleClose(): Promise { - // Ask user if they want to save. It seems hotExit has no bearing on - // whether or not we should ask - if (this.isDirty) { - const askResult = await this.askForSave(); - switch (askResult) { - case AskForSaveResult.Yes: - // Save the file - await this.saveToDisk(); - break; + private static async handleSaveAs(resource: Uri, newFile: Uri, cells: ICell[] | undefined): Promise { + return NativeEditorStorage.handleCallback(resource, async s => { + const actualCells = cells ? cells : s._cells; + return s.fileSystem.writeFile(newFile.fsPath, await s.generateNotebookContent(actualCells), { encoding: 'utf-8' }); + }); + } - case AskForSaveResult.No: - // Mark as not dirty, so we update our storage - await this.setClean(); - break; + private static async handleClose(_resource: Uri): Promise { + // Don't care about close (used to) + } - default: - // Reopen - await this.provider.open(this.file, await this.generateNotebookContent(this._cells)); - break; + private static async handleUpdateVersionInfo( + resource: Uri, + interpreter: PythonInterpreter | undefined, + kernelSpec: IJupyterKernelSpec | LiveKernelModel | undefined + ): Promise { + return NativeEditorStorage.handleCallback(resource, async s => { + // Get our kernel_info and language_info from the current notebook + if (interpreter && interpreter.version && s.notebookJson.metadata && s.notebookJson.metadata.language_info) { + s.notebookJson.metadata.language_info.version = interpreter.version.raw; } - } + + if (kernelSpec && s.notebookJson.metadata && !s.notebookJson.metadata.kernelspec) { + // Add a new spec in this case + s.notebookJson.metadata.kernelspec = { + name: kernelSpec.name || kernelSpec.display_name || '', + display_name: kernelSpec.display_name || kernelSpec.name || '' + }; + } else if (kernelSpec && s.notebookJson.metadata && s.notebookJson.metadata.kernelspec) { + // Spec exists, just update name and display_name + s.notebookJson.metadata.kernelspec.name = kernelSpec.name || kernelSpec.display_name || ''; + s.notebookJson.metadata.kernelspec.display_name = kernelSpec.display_name || kernelSpec.name || ''; + } + }); + } + public async load(file: Uri): Promise { + // Reset the load promise and reload our cells + this._loaded = false; + this._loadPromise = this.loadFromFile(file); + await this._loadPromise; } - public async handleUpdateVersionInfo(interpreter: PythonInterpreter | undefined, kernelSpec: IJupyterKernelSpec | LiveKernelModel | undefined): Promise { - // Get our kernel_info and language_info from the current notebook - if (interpreter && interpreter.version && this.notebookJson.metadata && this.notebookJson.metadata.language_info) { - this.notebookJson.metadata.language_info.version = interpreter.version.raw; + public getCells(): Promise { + if (!this._loaded && this._loadPromise) { + return this._loadPromise; } - if (kernelSpec && this.notebookJson.metadata && !this.notebookJson.metadata.kernelspec) { - // Add a new spec in this case - this.notebookJson.metadata.kernelspec = { - name: kernelSpec.name || kernelSpec.display_name || '', - display_name: kernelSpec.display_name || kernelSpec.name || '' - }; - } else if (kernelSpec && this.notebookJson.metadata && this.notebookJson.metadata.kernelspec) { - // Spec exists, just update name and display_name - this.notebookJson.metadata.kernelspec.name = kernelSpec.name || kernelSpec.display_name || ''; - this.notebookJson.metadata.kernelspec.display_name = kernelSpec.display_name || kernelSpec.name || ''; - } + // If already loaded, return the updated cell values + return Promise.resolve(this._cells); } - private async load(): Promise { + + public async getJson(): Promise> { + await this.ensureNotebookJson(); + return this.notebookJson; + } + + private async loadFromFile(file: Uri): Promise { + // Save file + this._file = file; + + // Attempt to read the contents + const contents = await this.fileSystem.readFile(this._file.fsPath); + // Clear out old global storage the first time somebody opens // a notebook if (!this.globalStorage.get(NotebookTransferKey)) { @@ -213,7 +271,7 @@ export class HackyNativeEditorStorage implements INotebookStorage { return this.loadContents(dirtyContents, true); } else { // Load without setting dirty - return this.loadContents(this.initialContents, false); + return this.loadContents(contents, false); } } @@ -529,73 +587,4 @@ export class HackyNativeEditorStorage implements INotebookStorage { const file = `${this.crypto.createHash(key, 'string')}.ipynb`; return path.join(this.context.globalStoragePath, file); } - - private async setClean(): Promise { - // Always update storage - this.storeContents(undefined).catch(ex => traceError('Failed to clear notebook store', ex)); - - if (this._isDirty) { - this._isDirty = false; - this._changedEmitter.fire(); - } - } - - private async askForSave(): Promise { - const message1 = localize.DataScience.dirtyNotebookMessage1().format(`${path.basename(this.file.fsPath)}`); - const message2 = localize.DataScience.dirtyNotebookMessage2(); - const yes = localize.DataScience.dirtyNotebookYes(); - const no = localize.DataScience.dirtyNotebookNo(); - // tslint:disable-next-line: messages-must-be-localized - const result = await this.applicationShell.showInformationMessage(`${message1}\n${message2}`, { modal: true }, yes, no); - switch (result) { - case yes: - return AskForSaveResult.Yes; - - case no: - return AskForSaveResult.No; - - default: - return AskForSaveResult.Cancel; - } - } - - @captureTelemetry(Telemetry.Save, undefined, true) - private async saveToDisk(): Promise { - // If we're already in the middle of prompting the user to save, then get out of here. - // We could add a debounce decorator, unfortunately that slows saving (by waiting for no more save events to get sent). - if (this.isPromptingToSaveToDisc && this.isUntitled) { - return; - } - try { - let fileToSaveTo: Uri | undefined = this.file; - let isDirty = this._isDirty; - - // Ask user for a save as dialog if no title - if (this.isUntitled) { - this.isPromptingToSaveToDisc = true; - const filtersKey = localize.DataScience.dirtyNotebookDialogFilter(); - const filtersObject: { [name: string]: string[] } = {}; - filtersObject[filtersKey] = ['ipynb']; - isDirty = true; - - fileToSaveTo = await this.applicationShell.showSaveDialog({ - saveLabel: localize.DataScience.dirtyNotebookDialogTitle(), - filters: filtersObject - }); - } - - if (fileToSaveTo && isDirty) { - // Write out our visible cells - await this.fileSystem.writeFile(fileToSaveTo.fsPath, await this.generateNotebookContent(this._cells)); - - // Update our file name and dirty state - this._file = fileToSaveTo; - await this.setClean(); - } - } catch (e) { - traceError(e); - } finally { - this.isPromptingToSaveToDisc = false; - } - } } diff --git a/src/client/datascience/interactive-window/interactiveWindow.ts b/src/client/datascience/interactive-window/interactiveWindow.ts index 45dbaa8b460d..046a192773ad 100644 --- a/src/client/datascience/interactive-window/interactiveWindow.ts +++ b/src/client/datascience/interactive-window/interactiveWindow.ts @@ -30,7 +30,6 @@ import { IJupyterDebugger, IJupyterExecution, IJupyterVariables, - INotebookEditorProvider, INotebookExporter, INotebookServerOptions, IStatusProvider, @@ -330,7 +329,7 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi filters: filtersObject }); if (uri) { - await this.exportToFile(cells, uri.fsPath); + await this.jupyterExporter.exportToFile(cells, uri.fsPath); } } finally { this.stopProgress(); diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index fec261d04a94..989043f6d607 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -6,7 +6,7 @@ import { Session } from '@jupyterlab/services'; import { Kernel, KernelMessage } from '@jupyterlab/services/lib/kernel'; import { JSONObject } from '@phosphor/coreutils'; import { Observable } from 'rxjs/Observable'; -import { CancellationToken, CodeLens, CodeLensProvider, DebugSession, Disposable, Event, Range, TextDocument, TextEditor, Uri } from 'vscode'; +import { CancellationToken, CodeLens, CodeLensProvider, DebugSession, Disposable, Event, Range, TextDocument, TextEditor, Uri, WebviewPanel } from 'vscode'; import { ServerStatus } from '../../datascience-ui/interactive-common/mainState'; import { ICommandManager } from '../common/application/types'; import { ExecutionResult, ObservableExecutionResult, SpawnOptions } from '../common/process/types'; @@ -305,8 +305,7 @@ export interface INotebookEditorProvider { readonly activeEditor: INotebookEditor | undefined; readonly editors: INotebookEditor[]; readonly onDidOpenNotebookEditor: Event; - readonly onDidChangeActiveNotebookEditor: Event; - open(file: Uri, contents: string): Promise; + open(file: Uri): Promise; show(file: Uri): Promise; createNew(contents?: string): Promise; getNotebookOptions(): Promise; @@ -331,7 +330,7 @@ export interface INotebookEditor extends IInteractiveBase { readonly file: Uri; readonly visible: boolean; readonly active: boolean; - load(storage: INotebookStorage): Promise; + load(storage: INotebookStorage, webViewPanel: WebviewPanel): Promise; runAllCells(): void; runSelectedCell(): void; addCellBelow(): void; @@ -726,6 +725,10 @@ export interface IJupyterInterpreterDependencyManager { installMissingDependencies(err?: JupyterInstallError): Promise; } +export interface INotebookEdit { + readonly contents: ICell[]; +} + export interface INotebookStorage { readonly file: Uri; readonly isDirty: boolean; @@ -734,6 +737,8 @@ export interface INotebookStorage { getJson(): Promise>; } -export interface INotebookEdit { - readonly changedCells: ICell[]; +export const ILoadableNotebookStorage = Symbol('ILoadableNotebookStorage'); + +export interface ILoadableNotebookStorage extends INotebookStorage { + load(file: Uri): Promise; } diff --git a/src/client/datascience/webViewHost.ts b/src/client/datascience/webViewHost.ts index aadee7d564eb..ccf09bb4d92c 100644 --- a/src/client/datascience/webViewHost.ts +++ b/src/client/datascience/webViewHost.ts @@ -4,7 +4,7 @@ import '../common/extensions'; import { injectable, unmanaged } from 'inversify'; -import { ConfigurationChangeEvent, ViewColumn, WorkspaceConfiguration } from 'vscode'; +import { ConfigurationChangeEvent, ViewColumn, WebviewPanel, WorkspaceConfiguration } from 'vscode'; import { IWebPanel, IWebPanelMessageListener, IWebPanelProvider, IWorkspaceService } from '../common/application/types'; import { traceInfo } from '../common/logger'; @@ -200,7 +200,7 @@ export class WebViewHost implements IDisposable { return this.themeIsDarkPromise ? this.themeIsDarkPromise.promise : Promise.resolve(false); } - protected async loadWebPanel(cwd: string) { + protected async loadWebPanel(cwd: string, webViewPanel?: WebviewPanel) { // Make not disposed anymore this.disposed = false; @@ -238,7 +238,8 @@ export class WebViewHost implements IDisposable { scripts: this.scripts, settings, startHttpServer, - cwd + cwd, + webViewPanel }); traceInfo('Web view created.'); From 72221d9a601bf1e2503f461264358b6f6f31e811 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Fri, 24 Jan 2020 16:57:07 -0800 Subject: [PATCH 05/18] Get all of the tests to build --- .../common/application/customEditorService.ts | 16 +- src/client/common/application/types.ts | 4 + .../nativeEditorCommandListener.ts | 7 +- .../interactive-ipynb/nativeEditorProvider.ts | 16 +- .../interactive-ipynb/nativeEditorStorage.ts | 73 +- .../interactiveWindowCommandListener.ts | 4 +- .../datascience/jupyter/jupyterExporter.ts | 2 +- src/client/datascience/serviceRegistry.ts | 5 +- src/client/datascience/types.ts | 1 + .../datascience/dataScienceIocContainer.ts | 2 - .../nativeEditor.unit.test.ts | 639 ------------------ .../nativeEditorProvider.unit.test.ts | 101 +-- .../nativeEditorStorage.unit.test.ts | 471 +++++++++++++ src/test/datascience/mockFileSystem.ts | 24 + .../nativeEditor.functional.test.tsx | 6 - .../datascience/nativeEditorTestHelpers.tsx | 9 +- .../datascience/testNativeEditorProvider.ts | 23 +- src/test/serviceRegistry.ts | 10 +- 18 files changed, 564 insertions(+), 849 deletions(-) delete mode 100644 src/test/datascience/interactive-ipynb/nativeEditor.unit.test.ts create mode 100644 src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts create mode 100644 src/test/datascience/mockFileSystem.ts diff --git a/src/client/common/application/customEditorService.ts b/src/client/common/application/customEditorService.ts index c98cc3ba34b1..97d369de9f4c 100644 --- a/src/client/common/application/customEditorService.ts +++ b/src/client/common/application/customEditorService.ts @@ -1,18 +1,28 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 'use strict'; -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; import * as vscode from 'vscode'; -import { ICustomEditorService } from './types'; +import { ICommandManager, ICustomEditorService } from './types'; @injectable() export class CustomEditorService implements ICustomEditorService { + constructor(@inject(ICommandManager) private commandManager: ICommandManager) {} + public get supportsCustomEditors(): boolean { - return vscode.window.registerWebviewCustomEditorProvider !== undefined; + try { + return vscode.window.registerWebviewCustomEditorProvider !== undefined; + } catch { + return false; + } } public registerWebviewCustomEditorProvider(viewType: string, provider: vscode.WebviewCustomEditorProvider, options?: vscode.WebviewPanelOptions): vscode.Disposable { return vscode.window.registerWebviewCustomEditorProvider(viewType, provider, options); } + + public openEditor(file: vscode.Uri): Thenable { + return this.commandManager.executeCommand('vscode.open', file); + } } diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 0055bf7722f6..57d36ff31b92 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -1064,4 +1064,8 @@ export interface ICustomEditorService { * @return Disposable that unregisters the `WebviewCustomEditorProvider`. */ registerWebviewCustomEditorProvider(viewType: string, provider: WebviewCustomEditorProvider, options?: WebviewPanelOptions): Disposable; + /** + * Opens a file with a custom editor + */ + openEditor(file: Uri): Thenable; } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorCommandListener.ts b/src/client/datascience/interactive-ipynb/nativeEditorCommandListener.ts index a93f9dec35a9..b62f44115dba 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorCommandListener.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorCommandListener.ts @@ -8,7 +8,6 @@ import * as path from 'path'; import { Uri } from 'vscode'; import { ICommandManager } from '../../common/application/types'; -import { IFileSystem } from '../../common/platform/types'; import { IDisposableRegistry } from '../../common/types'; import { captureTelemetry } from '../../telemetry'; import { CommandSource } from '../../testing/common/constants'; @@ -20,8 +19,7 @@ export class NativeEditorCommandListener implements IDataScienceCommandListener constructor( @inject(IDisposableRegistry) private disposableRegistry: IDisposableRegistry, @inject(INotebookEditorProvider) private provider: INotebookEditorProvider, - @inject(IDataScienceErrorHandler) private dataScienceErrorHandler: IDataScienceErrorHandler, - @inject(IFileSystem) private fileSystem: IFileSystem + @inject(IDataScienceErrorHandler) private dataScienceErrorHandler: IDataScienceErrorHandler ) {} public register(commandManager: ICommandManager): void { @@ -98,9 +96,8 @@ export class NativeEditorCommandListener implements IDataScienceCommandListener private async openNotebook(file?: Uri): Promise { if (file && path.extname(file.fsPath).toLocaleLowerCase() === '.ipynb') { try { - const contents = await this.fileSystem.readFile(file.fsPath); // Then take the contents and load it. - await this.provider.open(file, contents); + await this.provider.open(file); } catch (e) { return this.dataScienceErrorHandler.handleError(e); } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index 04dc1143376b..5ca4eb5d4068 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -6,7 +6,7 @@ import { Disposable, Event, EventEmitter, Uri, WebviewCustomEditorEditingDelegat import { ICommandManager, ICustomEditorService, IWorkspaceService } from '../../common/application/types'; import { IFileSystem } from '../../common/platform/types'; -import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService } from '../../common/types'; +import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../common/types'; import { createDeferred } from '../../common/utils/async'; import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; @@ -16,6 +16,10 @@ import { ILoadableNotebookStorage, INotebookEdit, INotebookEditor, INotebookEdit @injectable() export class NativeEditorProvider implements INotebookEditorProvider, WebviewCustomEditorProvider, WebviewCustomEditorEditingDelegate, IAsyncDisposable { public static readonly customEditorViewType = 'NativeEditorProvider.ipynb'; + public get onDidChangeActiveNotebookEditor(): Event { + return this._onDidChangeActiveNotebookEditor.event; + } + private readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: INotebookEdit }>(); private openedEditors: Set = new Set(); private storage: Map> = new Map>(); @@ -27,11 +31,12 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus constructor( @inject(IServiceContainer) private serviceContainer: IServiceContainer, @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, + @inject(IDisposableRegistry) private disposables: IDisposableRegistry, @inject(IWorkspaceService) workspace: IWorkspaceService, @inject(IConfigurationService) private configuration: IConfigurationService, @inject(IFileSystem) private fileSystem: IFileSystem, @inject(ICommandManager) private cmdManager: ICommandManager, - @inject(ICustomEditorService) customEditorService: ICustomEditorService + @inject(ICustomEditorService) private customEditorService: ICustomEditorService ) { asyncRegistry.push(this); @@ -120,7 +125,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus disposable = this.onDidOpenNotebookEditor(handler); // Send an open command. - this.cmdManager.executeCommand('vscode.open', file); + this.customEditorService.openEditor(file); // Promise should resolve when the file opens. return deferred.promise; @@ -169,11 +174,16 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus if (!this.executedEditors.has(editor.file.fsPath)) { editor.executed(this.onExecuted.bind(this)); } + this.disposables.push(editor.onDidChangeViewState(this.onChangedViewState, this)); this.openedEditors.add(editor); editor.closed.bind(this.closedEditor.bind(this)); this._onDidOpenNotebookEditor.fire(editor); } + private onChangedViewState(): void { + this._onDidChangeActiveNotebookEditor.fire(this.activeEditor); + } + private onExecuted(editor: INotebookEditor): void { if (editor) { this.executedEditors.add(editor.file.fsPath); diff --git a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts index dc56dbd7fb64..9ae7da094417 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts @@ -1,10 +1,11 @@ import { nbformat } from '@jupyterlab/coreutils'; +import { inject, injectable, named } from 'inversify'; import * as path from 'path'; import * as uuid from 'uuid/v4'; import { Event, EventEmitter, Memento, Uri } from 'vscode'; import { concatMultilineStringInput, splitMultilineString } from '../../../datascience-ui/common'; import { createCodeCell } from '../../../datascience-ui/common/cellFactory'; -import { ICommandManager, IWorkspaceService } from '../../common/application/types'; +import { ICommandManager } from '../../common/application/types'; import { traceError } from '../../common/logger'; import { IFileSystem } from '../../common/platform/types'; import { GLOBAL_MEMENTO, ICryptoUtils, IExtensionContext, IMemento, WORKSPACE_MEMENTO } from '../../common/types'; @@ -17,12 +18,8 @@ import { InvalidNotebookFileError } from '../jupyter/invalidNotebookFileError'; import { LiveKernelModel } from '../jupyter/kernels/types'; import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, ILoadableNotebookStorage, INotebookStorage } from '../types'; -// tslint:disable-next-line:no-require-imports no-var-requires -const debounce = require('lodash/debounce') as typeof import('lodash/debounce'); - // tslint:disable-next-line:no-require-imports no-var-requires import detectIndent = require('detect-indent'); -import { inject, injectable, named } from 'inversify'; const KeyPrefix = 'notebook-storage-'; const NotebookTransferKey = 'notebook-transfered'; @@ -54,10 +51,8 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS private _isDirty: boolean = false; private indentAmount: string = ' '; private notebookJson: Partial = {}; - private debouncedWriteToStorage = debounce(this.writeToStorage.bind(this), 250); constructor( - @inject(IWorkspaceService) private workspaceService: IWorkspaceService, @inject(IJupyterExecution) private jupyterExecution: IJupyterExecution, @inject(IFileSystem) private fileSystem: IFileSystem, @inject(ICryptoUtils) private crypto: ICryptoUtils, @@ -406,14 +401,7 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS } private async setDirty(): Promise { - // Update storage if not untitled. Don't wait for results. - if (!this.isUntitled) { - this.generateNotebookContent(this._cells) - .then(c => this.storeContents(c).catch(ex => traceError('Failed to generate notebook content to store in state', ex))) - .ignoreErrors(); - } - - // Then update dirty flag. + // Update dirty flag. if (!this._isDirty) { this._isDirty = true; @@ -421,12 +409,13 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS this._changedEmitter.fire(); } } + private getStorageKey(): string { return `${KeyPrefix}${this._file.toString()}`; } /** - * Gets any unsaved changes to the notebook file. + * Gets any unsaved changes to the notebook file from the old locations. * If the file has been modified since the uncommitted changes were stored, then ignore the uncommitted changes. * * @private @@ -499,10 +488,6 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS // Make sure to clear so we don't use this again. this.localStorage.update(key, undefined); - // Transfer this to a file so we use that next time instead. - const filePath = this.getHashedFileName(key); - await this.writeToStorage(filePath, workspaceData); - return workspaceData; } } @@ -522,14 +507,6 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS const keys = Object.keys((this.globalStorage as any)._value); [...keys].forEach((k: string) => { if (k.startsWith(KeyPrefix)) { - // Write each pair to our alternate storage, but don't bother waiting for each - // to finish. - const filePath = this.getHashedFileName(k); - const contents = this.globalStorage.get(k); - if (contents) { - this.writeToStorage(filePath, JSON.stringify(contents)).ignoreErrors(); - } - // Remove from the map so that global storage does not have this anymore. // Use the real API here as we don't know how the map really gets updated. promises.push(this.globalStorage.update(k, undefined)); @@ -543,46 +520,6 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS return Promise.all(promises); } - /** - * Stores the uncommitted notebook changes into a temporary location. - * Also keep track of the current time. This way we can check whether changes were - * made to the file since the last time uncommitted changes were stored. - * - * @private - * @param {string} [contents] - * @returns {Promise} - * @memberof NativeEditor - */ - private async storeContents(contents?: string): Promise { - // Skip doing this if auto save is enabled. - const filesConfig = this.workspaceService.getConfiguration('files', this.file); - const autoSave = filesConfig.get('autoSave', 'off'); - if (autoSave === 'off') { - const key = this.getStorageKey(); - const filePath = this.getHashedFileName(key); - - // Keep track of the time when this data was saved. - // This way when we retrieve the data we can compare it against last modified date of the file. - const specialContents = contents ? JSON.stringify({ contents, lastModifiedTimeMs: Date.now() }) : undefined; - - // Write but debounced (wait at least 250 ms) - return this.debouncedWriteToStorage(filePath, specialContents); - } - } - - private async writeToStorage(filePath: string, contents?: string): Promise { - try { - if (contents) { - await this.fileSystem.createDirectory(path.dirname(filePath)); - return this.fileSystem.writeFile(filePath, contents); - } else { - return this.fileSystem.deleteFile(filePath); - } - } catch (exc) { - traceError(`Error writing storage for ${filePath}: `, exc); - } - } - private getHashedFileName(key: string): string { const file = `${this.crypto.createHash(key, 'string')}.ipynb`; return path.join(this.context.globalStoragePath, file); diff --git a/src/client/datascience/interactive-window/interactiveWindowCommandListener.ts b/src/client/datascience/interactive-window/interactiveWindowCommandListener.ts index 94baafa60663..aedf93572e64 100644 --- a/src/client/datascience/interactive-window/interactiveWindowCommandListener.ts +++ b/src/client/datascience/interactive-window/interactiveWindowCommandListener.ts @@ -183,7 +183,7 @@ export class InteractiveWindowCommandListener implements IDataScienceCommandList const questions = [openQuestion1, ...(openQuestion2 ? [openQuestion2] : [])]; const selection = await this.applicationShell.showInformationMessage(localize.DataScience.exportDialogComplete().format(uri.fsPath), ...questions); if (selection === openQuestion1) { - await this.ipynbProvider.open(uri, await this.fileSystem.readFile(uri.fsPath)); + await this.ipynbProvider.open(uri); } if (selection === openQuestion2) { // If the user wants to, open the notebook they just generated. @@ -237,7 +237,7 @@ export class InteractiveWindowCommandListener implements IDataScienceCommandList const questions = [openQuestion1, ...(openQuestion2 ? [openQuestion2] : [])]; const selection = await this.applicationShell.showInformationMessage(localize.DataScience.exportDialogComplete().format(output), ...questions); if (selection === openQuestion1) { - await this.ipynbProvider.open(Uri.file(output), await this.fileSystem.readFile(output)); + await this.ipynbProvider.open(Uri.file(output)); } if (selection === openQuestion2) { // If the user wants to, open the notebook they just generated. diff --git a/src/client/datascience/jupyter/jupyterExporter.ts b/src/client/datascience/jupyter/jupyterExporter.ts index 768715eef6eb..b2b3cd35057a 100644 --- a/src/client/datascience/jupyter/jupyterExporter.ts +++ b/src/client/datascience/jupyter/jupyterExporter.ts @@ -58,7 +58,7 @@ export class JupyterExporter implements INotebookExporter { // If the user wants to, open the notebook they just generated. await this.jupyterExecution.spawnNotebook(file); } else if (str === openQuestion1) { - await this.ipynbProvider.open(Uri.file(file), contents); + await this.ipynbProvider.open(Uri.file(file)); } } catch (e) { await this.errorHandler.handleError(e); diff --git a/src/client/datascience/serviceRegistry.ts b/src/client/datascience/serviceRegistry.ts index f5963988e3f9..6aa6a346c9c3 100644 --- a/src/client/datascience/serviceRegistry.ts +++ b/src/client/datascience/serviceRegistry.ts @@ -26,10 +26,10 @@ import { DebugListener } from './interactive-common/debugListener'; import { IntellisenseProvider } from './interactive-common/intellisense/intellisenseProvider'; import { LinkProvider } from './interactive-common/linkProvider'; import { ShowPlotListener } from './interactive-common/showPlotListener'; -import { AutoSaveService } from './interactive-ipynb/autoSaveService'; import { NativeEditor } from './interactive-ipynb/nativeEditor'; import { NativeEditorCommandListener } from './interactive-ipynb/nativeEditorCommandListener'; import { NativeEditorProvider } from './interactive-ipynb/nativeEditorProvider'; +import { NativeEditorStorage } from './interactive-ipynb/nativeEditorStorage'; import { InteractiveWindow } from './interactive-window/interactiveWindow'; import { InteractiveWindowCommandListener } from './interactive-window/interactiveWindowCommandListener'; import { InteractiveWindowProvider } from './interactive-window/interactiveWindowProvider'; @@ -89,6 +89,7 @@ import { IJupyterSessionManagerFactory, IJupyterSubCommandExecutionService, IJupyterVariables, + ILoadableNotebookStorage, INotebookEditor, INotebookEditorProvider, INotebookExecutionLogger, @@ -127,7 +128,6 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.add(IInteractiveWindowListener, ShowPlotListener); serviceManager.add(IInteractiveWindowListener, DebugListener); serviceManager.add(IInteractiveWindowListener, GatherListener); - serviceManager.add(IInteractiveWindowListener, AutoSaveService); serviceManager.addSingleton(IPlotViewerProvider, PlotViewerProvider); serviceManager.add(IPlotViewer, PlotViewer); serviceManager.addSingleton(IJupyterDebugger, JupyterDebugger); @@ -139,6 +139,7 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addBinding(ICellHashProvider, INotebookExecutionLogger); serviceManager.addBinding(IJupyterDebugger, ICellHashListener); serviceManager.addSingleton(INotebookEditorProvider, NativeEditorProvider); + serviceManager.add(ILoadableNotebookStorage, NativeEditorStorage); serviceManager.add(INotebookEditor, NativeEditor); serviceManager.addSingleton(IDataScienceCommandListener, NativeEditorCommandListener); serviceManager.addBinding(ICodeLensFactory, IInteractiveWindowListener); diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index 989043f6d607..4cfcf0953f3b 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -305,6 +305,7 @@ export interface INotebookEditorProvider { readonly activeEditor: INotebookEditor | undefined; readonly editors: INotebookEditor[]; readonly onDidOpenNotebookEditor: Event; + readonly onDidChangeActiveNotebookEditor: Event; open(file: Uri): Promise; show(file: Uri): Promise; createNew(contents?: string): Promise; diff --git a/src/test/datascience/dataScienceIocContainer.ts b/src/test/datascience/dataScienceIocContainer.ts index e62d43b643c9..347dcafb11f4 100644 --- a/src/test/datascience/dataScienceIocContainer.ts +++ b/src/test/datascience/dataScienceIocContainer.ts @@ -146,7 +146,6 @@ import { DataScienceErrorHandler } from '../../client/datascience/errorHandler/e import { GatherExecution } from '../../client/datascience/gather/gather'; import { GatherListener } from '../../client/datascience/gather/gatherListener'; import { IntellisenseProvider } from '../../client/datascience/interactive-common/intellisense/intellisenseProvider'; -import { AutoSaveService } from '../../client/datascience/interactive-ipynb/autoSaveService'; import { NativeEditor } from '../../client/datascience/interactive-ipynb/nativeEditor'; import { NativeEditorCommandListener } from '../../client/datascience/interactive-ipynb/nativeEditorCommandListener'; import { InteractiveWindow } from '../../client/datascience/interactive-window/interactiveWindow'; @@ -469,7 +468,6 @@ export class DataScienceIocContainer extends UnitTestIocContainer { this.serviceManager.add(ILanguageServerManager, NodeLanguageServerManager, LanguageServerType.Node); this.serviceManager.addSingleton(ILanguageServerAnalysisOptions, MockLanguageServerAnalysisOptions); this.serviceManager.add(IInteractiveWindowListener, IntellisenseProvider); - this.serviceManager.add(IInteractiveWindowListener, AutoSaveService); this.serviceManager.add(IProtocolParser, ProtocolParser); this.serviceManager.addSingleton(IDebugService, MockDebuggerService); this.serviceManager.addSingleton(ICellHashProvider, CellHashProvider); diff --git a/src/test/datascience/interactive-ipynb/nativeEditor.unit.test.ts b/src/test/datascience/interactive-ipynb/nativeEditor.unit.test.ts deleted file mode 100644 index 6ad3658ae1e7..000000000000 --- a/src/test/datascience/interactive-ipynb/nativeEditor.unit.test.ts +++ /dev/null @@ -1,639 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -'use strict'; -import { fail } from 'assert'; -import { expect } from 'chai'; -import * as path from 'path'; -import * as sinon from 'sinon'; -import { anything, instance, mock, verify, when } from 'ts-mockito'; -import { Matcher } from 'ts-mockito/lib/matcher/type/Matcher'; -import * as typemoq from 'typemoq'; -import { ConfigurationChangeEvent, ConfigurationTarget, Disposable, EventEmitter, TextEditor, Uri, WorkspaceConfiguration } from 'vscode'; - -import { ApplicationShell } from '../../../client/common/application/applicationShell'; -import { CommandManager } from '../../../client/common/application/commandManager'; -import { DocumentManager } from '../../../client/common/application/documentManager'; -import { - IApplicationShell, - ICommandManager, - IDocumentManager, - ILiveShareApi, - IWebPanelMessageListener, - IWebPanelProvider, - IWorkspaceService -} from '../../../client/common/application/types'; -import { WebPanel } from '../../../client/common/application/webPanels/webPanel'; -import { WebPanelProvider } from '../../../client/common/application/webPanels/webPanelProvider'; -import { WorkspaceService } from '../../../client/common/application/workspace'; -import { PythonSettings } from '../../../client/common/configSettings'; -import { ConfigurationService } from '../../../client/common/configuration/service'; -import { CryptoUtils } from '../../../client/common/crypto'; -import { IFileSystem } from '../../../client/common/platform/types'; -import { IConfigurationService, ICryptoUtils, IExtensionContext } from '../../../client/common/types'; -import { createDeferred } from '../../../client/common/utils/async'; -import { EXTENSION_ROOT_DIR } from '../../../client/constants'; -import { CodeCssGenerator } from '../../../client/datascience/codeCssGenerator'; -import { DataViewerProvider } from '../../../client/datascience/data-viewing/dataViewerProvider'; -import { DataScienceErrorHandler } from '../../../client/datascience/errorHandler/errorHandler'; -import { InteractiveWindowMessages } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; -import { NativeEditor } from '../../../client/datascience/interactive-ipynb/nativeEditor'; -import { NativeEditorProvider } from '../../../client/datascience/interactive-ipynb/nativeEditorProvider'; -import { JupyterDebugger } from '../../../client/datascience/jupyter/jupyterDebugger'; -import { JupyterExecutionFactory } from '../../../client/datascience/jupyter/jupyterExecutionFactory'; -import { JupyterExporter } from '../../../client/datascience/jupyter/jupyterExporter'; -import { JupyterImporter } from '../../../client/datascience/jupyter/jupyterImporter'; -import { JupyterVariables } from '../../../client/datascience/jupyter/jupyterVariables'; -import { LiveShareApi } from '../../../client/datascience/liveshare/liveshare'; -import { ProgressReporter } from '../../../client/datascience/progress/progressReporter'; -import { ThemeFinder } from '../../../client/datascience/themeFinder'; -import { - ICodeCssGenerator, - IDataScienceErrorHandler, - IDataViewerProvider, - IJupyterDebugger, - IJupyterExecution, - IJupyterVariables, - INotebookEditorProvider, - INotebookExporter, - INotebookImporter, - INotebookServerOptions, - IThemeFinder -} from '../../../client/datascience/types'; -import { IInterpreterService } from '../../../client/interpreter/contracts'; -import { InterpreterService } from '../../../client/interpreter/interpreterService'; -import { createEmptyCell } from '../../../datascience-ui/interactive-common/mainState'; -import { waitForCondition } from '../../common'; -import { MockMemento } from '../../mocks/mementos'; -import { MockStatusProvider } from '../mockStatusProvider'; - -// tslint:disable: no-any chai-vague-errors no-unused-expression -class MockWorkspaceConfiguration implements WorkspaceConfiguration { - private map: Map = new Map(); - - // tslint:disable: no-any - public get(key: string): any; - public get(section: string): T | undefined; - public get(section: string, defaultValue: T): T; - public get(section: any, defaultValue?: any): any; - public get(section: string, defaultValue?: any): any { - if (this.map.has(section)) { - return this.map.get(section); - } - return arguments.length > 1 ? defaultValue : (undefined as any); - } - public has(_section: string): boolean { - return false; - } - public inspect( - _section: string - ): { key: string; defaultValue?: T | undefined; globalValue?: T | undefined; workspaceValue?: T | undefined; workspaceFolderValue?: T | undefined } | undefined { - return; - } - public update(section: string, value: any, _configurationTarget?: boolean | ConfigurationTarget | undefined): Promise { - this.map.set(section, value); - return Promise.resolve(); - } -} - -// tslint:disable: max-func-body-length -suite('Data Science - Native Editor', () => { - let workspace: IWorkspaceService; - let configService: IConfigurationService; - let fileSystem: typemoq.IMock; - let docManager: IDocumentManager; - let dsErrorHandler: IDataScienceErrorHandler; - let cmdManager: ICommandManager; - let liveShare: ILiveShareApi; - let applicationShell: IApplicationShell; - let interpreterService: IInterpreterService; - let webPanelProvider: IWebPanelProvider; - const disposables: Disposable[] = []; - let cssGenerator: ICodeCssGenerator; - let themeFinder: IThemeFinder; - let statusProvider: MockStatusProvider; - let executionProvider: IJupyterExecution; - let exportProvider: INotebookExporter; - let editorProvider: INotebookEditorProvider; - let dataExplorerProvider: IDataViewerProvider; - let jupyterVariables: IJupyterVariables; - let jupyterDebugger: IJupyterDebugger; - let importer: INotebookImporter; - let storage: MockMemento; - let localStorage: MockMemento; - let context: typemoq.IMock; - let crypto: ICryptoUtils; - let lastWriteFileValue: any; - let wroteToFileEvent: EventEmitter = new EventEmitter(); - let filesConfig: MockWorkspaceConfiguration | undefined; - let testIndex = 0; - let reporter: ProgressReporter; - const baseFile = `{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "collapsed": true - }, - "outputs": [ - { - "data": { - "text/plain": [ - "1" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "a=1\\n", - "a" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "2" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "b=2\\n", - "b" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "3" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "c=3\\n", - "c" - ] - } - ], - "metadata": { - "file_extension": ".py", - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.7.4" - }, - "mimetype": "text/x-python", - "name": "python", - "npconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": 3 - }, - "nbformat": 4, - "nbformat_minor": 2 -}`; - - setup(() => { - context = typemoq.Mock.ofType(); - crypto = mock(CryptoUtils); - storage = new MockMemento(); - localStorage = new MockMemento(); - configService = mock(ConfigurationService); - fileSystem = typemoq.Mock.ofType(); - docManager = mock(DocumentManager); - dsErrorHandler = mock(DataScienceErrorHandler); - cmdManager = mock(CommandManager); - workspace = mock(WorkspaceService); - liveShare = mock(LiveShareApi); - applicationShell = mock(ApplicationShell); - interpreterService = mock(InterpreterService); - webPanelProvider = mock(WebPanelProvider); - cssGenerator = mock(CodeCssGenerator); - themeFinder = mock(ThemeFinder); - statusProvider = new MockStatusProvider(); - executionProvider = mock(JupyterExecutionFactory); - exportProvider = mock(JupyterExporter); - editorProvider = mock(NativeEditorProvider); - dataExplorerProvider = mock(DataViewerProvider); - jupyterVariables = mock(JupyterVariables); - jupyterDebugger = mock(JupyterDebugger); - importer = mock(JupyterImporter); - reporter = mock(ProgressReporter); - const settings = mock(PythonSettings); - const settingsChangedEvent = new EventEmitter(); - - context.setup(c => c.globalStoragePath).returns(() => path.join(EXTENSION_ROOT_DIR, 'src', 'test', 'datascience', 'WorkspaceDir')); - - when(settings.onDidChange).thenReturn(settingsChangedEvent.event); - when(configService.getSettings()).thenReturn(instance(settings)); - - const configChangeEvent = new EventEmitter(); - when(workspace.onDidChangeConfiguration).thenReturn(configChangeEvent.event); - filesConfig = new MockWorkspaceConfiguration(); - when(workspace.getConfiguration('files', anything())).thenReturn(filesConfig); - - const interprerterChangeEvent = new EventEmitter(); - when(interpreterService.onDidChangeInterpreter).thenReturn(interprerterChangeEvent.event); - - const editorChangeEvent = new EventEmitter(); - when(docManager.onDidChangeActiveTextEditor).thenReturn(editorChangeEvent.event); - - const sessionChangedEvent = new EventEmitter(); - when(executionProvider.sessionChanged).thenReturn(sessionChangedEvent.event); - - const serverStartedEvent = new EventEmitter(); - when(executionProvider.serverStarted).thenReturn(serverStartedEvent.event); - - testIndex += 1; - when(crypto.createHash(anything(), 'string')).thenReturn(`${testIndex}`); - - let listener: IWebPanelMessageListener; - const webPanel = mock(WebPanel); - class WebPanelCreateMatcher extends Matcher { - public match(value: any) { - listener = value.listener; - listener.onMessage(InteractiveWindowMessages.Started, undefined); - return true; - } - public toString() { - return ''; - } - } - const matcher = (): any => { - return new WebPanelCreateMatcher(); - }; - when(webPanelProvider.create(matcher())).thenResolve(instance(webPanel)); - lastWriteFileValue = undefined; - wroteToFileEvent = new EventEmitter(); - fileSystem - .setup(f => f.writeFile(typemoq.It.isAny(), typemoq.It.isAny())) - .returns((a1, a2) => { - if (a1.includes(`${testIndex}.ipynb`)) { - lastWriteFileValue = a2; - wroteToFileEvent.fire(a2); - } - return Promise.resolve(); - }); - fileSystem - .setup(f => f.readFile(typemoq.It.isAny())) - .returns(_a1 => { - return Promise.resolve(lastWriteFileValue); - }); - }); - - teardown(() => { - storage.clear(); - sinon.reset(); - }); - - function createEditor() { - return new NativeEditor( - [], - instance(liveShare), - instance(applicationShell), - instance(docManager), - instance(interpreterService), - instance(webPanelProvider), - disposables, - instance(cssGenerator), - instance(themeFinder), - statusProvider, - instance(executionProvider), - fileSystem.object, // Use typemoq so can save values in returns - instance(configService), - instance(cmdManager), - instance(exportProvider), - instance(workspace), - instance(editorProvider), - instance(dataExplorerProvider), - instance(jupyterVariables), - instance(jupyterDebugger), - instance(importer), - instance(dsErrorHandler), - storage, - localStorage, - instance(crypto), - context.object, - instance(reporter) - ); - } - - test('Create new editor and add some cells', async () => { - const editor = createEditor(); - await editor.load(baseFile, Uri.parse('file:///foo.ipynb')); - expect(await editor.getContents()).to.be.equal(baseFile); - editor.onMessage(InteractiveWindowMessages.InsertCell, { index: 0, cell: createEmptyCell('1', 1) }); - expect(editor.cells).to.be.lengthOf(4); - expect(editor.isDirty).to.be.equal(true, 'Editor should be dirty'); - expect(editor.cells[0].id).to.be.match(/1/); - }); - - test('Move cells around', async () => { - const editor = createEditor(); - await editor.load(baseFile, Uri.parse('file:///foo.ipynb')); - expect(await editor.getContents()).to.be.equal(baseFile); - editor.onMessage(InteractiveWindowMessages.SwapCells, { firstCellId: 'NotebookImport#0', secondCellId: 'NotebookImport#1' }); - expect(editor.cells).to.be.lengthOf(3); - expect(editor.isDirty).to.be.equal(true, 'Editor should be dirty'); - expect(editor.cells[0].id).to.be.match(/NotebookImport#1/); - }); - - test('Edit/delete cells', async () => { - const editor = createEditor(); - await editor.load(baseFile, Uri.parse('file:///foo.ipynb')); - expect(await editor.getContents()).to.be.equal(baseFile); - expect(editor.isDirty).to.be.equal(false, 'Editor should not be dirty'); - editor.onMessage(InteractiveWindowMessages.EditCell, { - changes: [ - { - range: { - startLineNumber: 2, - startColumn: 1, - endLineNumber: 2, - endColumn: 1 - }, - rangeOffset: 4, - rangeLength: 0, - text: 'a' - } - ], - id: 'NotebookImport#1' - }); - expect(editor.cells).to.be.lengthOf(3); - expect(editor.cells[1].id).to.be.match(/NotebookImport#1/); - expect(editor.cells[1].data.source).to.be.equals('b=2\nab'); - expect(editor.isDirty).to.be.equal(true, 'Editor should be dirty'); - editor.onMessage(InteractiveWindowMessages.RemoveCell, { id: 'NotebookImport#0' }); - expect(editor.cells).to.be.lengthOf(2); - expect(editor.cells[0].id).to.be.match(/NotebookImport#1/); - editor.onMessage(InteractiveWindowMessages.DeleteAllCells, {}); - expect(editor.cells).to.be.lengthOf(0); - }); - - async function loadEditorAddCellAndWaitForMementoUpdate(file: Uri) { - const editor = createEditor(); - await editor.load(baseFile, file); - expect(await editor.getContents()).to.be.equal(baseFile); - const savedPromise = createDeferred(); - const disposable = wroteToFileEvent.event((c: string) => { - // Double check our contents are there - const fileContents = JSON.parse(c); - if (fileContents.contents) { - const contents = JSON.parse(fileContents.contents); - if (contents.cells && contents.cells.length === 4) { - savedPromise.resolve(true); - } - } - }); - editor.onMessage(InteractiveWindowMessages.InsertCell, { index: 0, cell: createEmptyCell('1', 1) }); - expect(editor.cells).to.be.lengthOf(4); - - // Wait for contents to be stored in memento. - // Editor will save uncommitted changes into storage, wait for it to be saved. - try { - await waitForCondition(() => savedPromise.promise, 500, 'Storage not updated'); - } finally { - disposable.dispose(); - } - - // Confirm contents were saved. - expect(await editor.getContents()).not.to.be.equal(baseFile); - - return editor; - } - test('Editing a notebook will save uncommitted changes into memento', async () => { - await filesConfig?.update('autoSave', 'off'); - const file = Uri.parse('file:///foo.ipynb'); - - // Initially nothing in memento - expect(storage.get(`notebook-storage-${file.toString()}`)).to.be.undefined; - - const editor = await loadEditorAddCellAndWaitForMementoUpdate(file); - await editor.dispose(); - }); - - test('Editing a notebook will not save uncommitted changes into storage when autoSave is on', async () => { - await filesConfig?.update('autoSave', 'on'); - const file = Uri.parse('file:///foo.ipynb'); - - // Initially nothing in memento - expect(storage.get(`notebook-storage-${file.toString()}`)).to.be.undefined; - - try { - await loadEditorAddCellAndWaitForMementoUpdate(file); - fail('Should have timed out'); - } catch (e) { - expect(e.toString()).to.include('not updated'); - } - }); - - test('Opening a notebook will restore uncommitted changes', async () => { - await filesConfig?.update('autoSave', 'off'); - const file = Uri.parse('file:///foo.ipynb'); - fileSystem.setup(f => f.stat(typemoq.It.isAny())).returns(() => Promise.resolve({ mtime: 1 } as any)); - const editor = await loadEditorAddCellAndWaitForMementoUpdate(file); - - // Close the editor. - await editor.dispose(); - - // Open a new one. - const newEditor = createEditor(); - await newEditor.load(baseFile, file); - - // Verify contents are different. - // Meaning it was not loaded from file, but loaded from our storage. - const contents = await newEditor.getContents(); - expect(contents).not.to.be.equal(baseFile); - const notebook = JSON.parse(contents); - // 4 cells (1 extra for what was added) - expect(notebook.cells).to.be.lengthOf(4); - }); - - test('Opening a notebook will restore uncommitted changes (ignoring contents of file)', async () => { - await filesConfig?.update('autoSave', 'off'); - const file = Uri.parse('file:///foo.ipynb'); - fileSystem.setup(f => f.stat(typemoq.It.isAny())).returns(() => Promise.resolve({ mtime: 1 } as any)); - - const editor = await loadEditorAddCellAndWaitForMementoUpdate(file); - - // Close the editor. - await editor.dispose(); - - // Open a new one with the same file. - const newEditor = createEditor(); - - // However, pass in some bosu content, to confirm it is NOT loaded from file. - await newEditor.load('crap', file); - - // Verify contents are different. - // Meaning it was not loaded from file, but loaded from our storage. - expect(await newEditor.getContents()).not.to.be.equal(baseFile); - const notebook = JSON.parse(await newEditor.getContents()); - // 4 cells (1 extra for what was added) - expect(notebook.cells).to.be.lengthOf(4); - }); - - test('Opening a notebook will NOT restore uncommitted changes if file has been modified since', async () => { - await filesConfig?.update('autoSave', 'off'); - const file = Uri.parse('file:///foo.ipynb'); - const editor = await loadEditorAddCellAndWaitForMementoUpdate(file); - // Close the editor. - await editor.dispose(); - - // Make file appear modified. - fileSystem.setup(f => f.stat(typemoq.It.isAny())).returns(() => Promise.resolve({ mtime: Date.now() } as any)); - - // Open a new one. - const newEditor = createEditor(); - await newEditor.load(baseFile, file); - - // Verify contents are different. - // Meaning it was not loaded from file, but loaded from our storage. - expect(await newEditor.getContents()).to.be.equal(baseFile); - expect(newEditor.cells).to.be.lengthOf(3); - }); - - test('Opening file with local storage but no global will still open with old contents', async () => { - await filesConfig?.update('autoSave', 'off'); - // This test is really for making sure when a user upgrades to a new extension, we still have their old storage - const file = Uri.parse('file:///foo.ipynb'); - - // Initially nothing in memento - expect(storage.get(`notebook-storage-${file.toString()}`)).to.be.undefined; - expect(localStorage.get(`notebook-storage-${file.toString()}`)).to.be.undefined; - - // Put the regular file into the local storage - localStorage.update(`notebook-storage-${file.toString()}`, baseFile); - const editor = createEditor(); - await editor.load('', file); - - // It should load with that value - expect(await editor.getContents()).to.be.equal(baseFile); - expect(editor.cells).to.be.lengthOf(3); - }); - - test('Opening file with global storage but no global file will still open with old contents', async () => { - await filesConfig?.update('autoSave', 'off'); - // This test is really for making sure when a user upgrades to a new extension, we still have their old storage - const file = Uri.parse('file:///foo.ipynb'); - fileSystem.setup(f => f.stat(typemoq.It.isAny())).returns(() => Promise.resolve({ mtime: 1 } as any)); - - // Initially nothing in memento - expect(storage.get(`notebook-storage-${file.toString()}`)).to.be.undefined; - expect(localStorage.get(`notebook-storage-${file.toString()}`)).to.be.undefined; - - // Put the regular file into the global storage - storage.update(`notebook-storage-${file.toString()}`, { contents: baseFile, lastModifiedTimeMs: Date.now() }); - const editor = createEditor(); - await editor.load('', file); - - // It should load with that value - expect(await editor.getContents()).to.be.equal(baseFile); - expect(editor.cells).to.be.lengthOf(3); - }); - - test('Opening file with global storage will clear all global storage', async () => { - await filesConfig?.update('autoSave', 'off'); - - // This test is really for making sure when a user upgrades to a new extension, we still have their old storage - const file = Uri.parse('file:///foo.ipynb'); - fileSystem.setup(f => f.stat(typemoq.It.isAny())).returns(() => Promise.resolve({ mtime: 1 } as any)); - - // Initially nothing in memento - expect(storage.get(`notebook-storage-${file.toString()}`)).to.be.undefined; - expect(localStorage.get(`notebook-storage-${file.toString()}`)).to.be.undefined; - - // Put the regular file into the global storage - storage.update(`notebook-storage-${file.toString()}`, { contents: baseFile, lastModifiedTimeMs: Date.now() }); - - // Put another file into the global storage - storage.update(`notebook-storage-file::///bar.ipynb`, { contents: baseFile, lastModifiedTimeMs: Date.now() }); - - const editor = createEditor(); - await editor.load('', file); - - // It should load with that value - expect(await editor.getContents()).to.be.equal(baseFile); - expect(editor.cells).to.be.lengthOf(3); - - // And global storage should be empty - expect(storage.get(`notebook-storage-${file.toString()}`)).to.be.undefined; - expect(storage.get(`notebook-storage-file::///bar.ipynb`)).to.be.undefined; - expect(localStorage.get(`notebook-storage-${file.toString()}`)).to.be.undefined; - }); - - test('Export to Python script file from notebook.', async () => { - // Temp file location needed for export - const tempFile = { - dispose: () => { - return undefined; - }, - filePath: '/foo/bar.ipynb' - }; - fileSystem.setup(f => f.createTemporaryFile('.ipynb')).returns(() => Promise.resolve(tempFile)); - - // Set up our importer to return file contents, check that we have the correct temp file location and - // original file location - const file = Uri.parse('file:///foo.ipynb'); - when(importer.importFromFile('/foo/bar.ipynb', file.fsPath)).thenResolve('# File Contents'); - - // Just return empty objects here, we don't care about open or show function, just that they were called - when(docManager.openTextDocument({ language: 'python', content: '# File Contents' })).thenResolve({} as any); - when(docManager.showTextDocument(anything(), anything())).thenResolve({} as any); - - const editor = createEditor(); - await editor.load(baseFile, file); - expect(await editor.getContents()).to.be.equal(baseFile); - - // Make our call to actually export - editor.onMessage(InteractiveWindowMessages.Export, editor.cells); - - await waitForCondition( - async () => { - try { - // Wait until showTextDocument has been called, that's the signal that export is done - verify(docManager.showTextDocument(anything(), anything())).atLeast(1); - return true; - } catch { - return false; - } - }, - 1_000, - 'Timeout' - ); - - // Verify that we also opened our text document not exact match as verify doesn't seem to match that - verify(docManager.openTextDocument(anything())).once(); - }); -}); diff --git a/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts b/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts index 2e7d825a69a1..3e103e042aa2 100644 --- a/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts +++ b/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts @@ -8,22 +8,21 @@ import { expect } from 'chai'; import { instance, mock, when } from 'ts-mockito'; import * as typemoq from 'typemoq'; -import { EventEmitter, TextDocument, TextEditor, Uri } from 'vscode'; +import { EventEmitter, TextEditor, Uri } from 'vscode'; import { CommandManager } from '../../../client/common/application/commandManager'; +import { CustomEditorService } from '../../../client/common/application/customEditorService'; import { DocumentManager } from '../../../client/common/application/documentManager'; -import { ICommandManager, IDocumentManager, IWorkspaceService } from '../../../client/common/application/types'; +import { ICommandManager, ICustomEditorService, IDocumentManager, IWorkspaceService } from '../../../client/common/application/types'; import { WorkspaceService } from '../../../client/common/application/workspace'; import { AsyncDisposableRegistry } from '../../../client/common/asyncDisposableRegistry'; import { ConfigurationService } from '../../../client/common/configuration/service'; import { FileSystem } from '../../../client/common/platform/fileSystem'; import { IFileSystem } from '../../../client/common/platform/types'; import { IConfigurationService } from '../../../client/common/types'; -import { DataScienceErrorHandler } from '../../../client/datascience/errorHandler/errorHandler'; import { NativeEditorProvider } from '../../../client/datascience/interactive-ipynb/nativeEditorProvider'; -import { IDataScienceErrorHandler, INotebookEditor } from '../../../client/datascience/types'; +import { INotebookEditor } from '../../../client/datascience/types'; import { ServiceContainer } from '../../../client/ioc/container'; import { IServiceContainer } from '../../../client/ioc/types'; -import { sleep } from '../../core'; // tslint:disable: max-func-body-length suite('Data Science - Native Editor Provider', () => { @@ -31,11 +30,11 @@ suite('Data Science - Native Editor Provider', () => { let configService: IConfigurationService; let fileSystem: IFileSystem; let docManager: IDocumentManager; - let dsErrorHandler: IDataScienceErrorHandler; let cmdManager: ICommandManager; let svcContainer: IServiceContainer; let changeActiveTextEditorEventEmitter: EventEmitter; let editor: typemoq.IMock; + let customEditorService: ICustomEditorService; let file: Uri; setup(() => { @@ -43,10 +42,10 @@ suite('Data Science - Native Editor Provider', () => { configService = mock(ConfigurationService); fileSystem = mock(FileSystem); docManager = mock(DocumentManager); - dsErrorHandler = mock(DataScienceErrorHandler); cmdManager = mock(CommandManager); workspace = mock(WorkspaceService); changeActiveTextEditorEventEmitter = new EventEmitter(); + customEditorService = mock(CustomEditorService); }); function createNotebookProvider(shouldOpenNotebookEditor: boolean) { @@ -81,97 +80,11 @@ suite('Data Science - Native Editor Provider', () => { instance(workspace), instance(configService), instance(fileSystem), - instance(docManager), instance(cmdManager), - instance(dsErrorHandler) + instance(customEditorService) ); } - function createTextDocument(uri: Uri, content: string) { - const textDocument = typemoq.Mock.ofType(); - textDocument.setup(t => t.uri).returns(() => uri); - textDocument.setup(t => t.fileName).returns(() => uri.fsPath); - textDocument.setup(t => t.getText()).returns(() => content); - return textDocument.object; - } - function createTextEditor(doc: TextDocument) { - const textEditor = typemoq.Mock.ofType(); - textEditor.setup(e => e.document).returns(() => doc); - return textEditor.object; - } - async function testAutomaticallyOpeningNotebookEditorWhenOpeningFiles(uri: Uri, shouldOpenNotebookEditor: boolean) { - const notebookEditor = createNotebookProvider(shouldOpenNotebookEditor); - - // Open a text document. - const textDoc = createTextDocument(uri, 'hello'); - const textEditor = createTextEditor(textDoc); - changeActiveTextEditorEventEmitter.fire(textEditor); - - // wait for callbacks to get executed. - await sleep(1); - - // If we're to open the notebook, then there must be 1, else 0. - expect(notebookEditor.editors).to.be.lengthOf(shouldOpenNotebookEditor ? 1 : 0); - editor.verifyAll(); - } - - test('Open the notebook editor when an ipynb file is opened', async () => { - await testAutomaticallyOpeningNotebookEditorWhenOpeningFiles(Uri.file('some file.ipynb'), true); - }); - async function openSameIPynbFile(openAnotherRandomFile: boolean) { - const notebookEditor = createNotebookProvider(true); - - // Open a text document. - const textDoc = createTextDocument(Uri.file('some file.ipynb'), 'hello'); - const textEditor = createTextEditor(textDoc); - changeActiveTextEditorEventEmitter.fire(textEditor); - - // wait for callbacks to get executed. - await sleep(1); - - // If we're to open the notebook, then there must be 1, else 0. - expect(notebookEditor.editors).to.be.lengthOf(1); - editor.verifyAll(); - // Verify we displayed the editor once. - editor.verify(e => e.show(), typemoq.Times.exactly(1)); - if (openAnotherRandomFile) { - // Next, open another file. - const logFile = createTextDocument(Uri.file('some file.log'), 'hello'); - const logEditor = createTextEditor(logFile); - changeActiveTextEditorEventEmitter.fire(logEditor); - // wait for callbacks to get executed. - await sleep(1); - - // Verify we didn't open another native editor. - expect(notebookEditor.editors).to.be.lengthOf(1); - } - - // Re-display the old ipynb file(open it again)' - changeActiveTextEditorEventEmitter.fire(textEditor); - - // wait for callbacks to get executed. - await sleep(1); - - // At this point the notebook should be shown (focused). - editor.verify(e => e.show(), typemoq.Times.exactly(2)); - // Verify we didn't open another native editor (display existing file). - expect(notebookEditor.editors).to.be.lengthOf(1); - } - test('Show the notebook editor when an opening the same ipynb file', async () => openSameIPynbFile(false)); - test('Show the notebook editor when an opening the same ipynb file (after opening some other random file)', async () => openSameIPynbFile(true)); - - test('Do not open the notebook editor when a txt file is opened', async () => { - await testAutomaticallyOpeningNotebookEditorWhenOpeningFiles(Uri.file('some text file.txt'), false); - }); - test('Open the notebook editor when an ipynb file is opened with a file scheme', async () => { - await testAutomaticallyOpeningNotebookEditorWhenOpeningFiles(Uri.parse('file:///some file.ipynb'), true); - }); - test('Open the notebook editor when an ipynb file is opened with a vsls scheme (live share)', async () => { - await testAutomaticallyOpeningNotebookEditorWhenOpeningFiles(Uri.parse('vsls:///some file.ipynb'), true); - }); - test('Do not open the notebook editor when an ipynb file is opened with a git scheme (comparing staged/modified files)', async () => { - await testAutomaticallyOpeningNotebookEditorWhenOpeningFiles(Uri.parse('git://some//text file.txt'), false); - }); test('Multiple new notebooks have new names', async () => { const provider = createNotebookProvider(false); const n1 = await provider.createNew(); diff --git a/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts b/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts new file mode 100644 index 000000000000..1503acc86249 --- /dev/null +++ b/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts @@ -0,0 +1,471 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { expect } from 'chai'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { Matcher } from 'ts-mockito/lib/matcher/type/Matcher'; +import * as typemoq from 'typemoq'; +import { ConfigurationChangeEvent, ConfigurationTarget, EventEmitter, TextEditor, Uri, WorkspaceConfiguration } from 'vscode'; + +import { ICommandNameArgumentTypeMapping } from '../../../client/common/application/commands'; +import { DocumentManager } from '../../../client/common/application/documentManager'; +import { IDocumentManager, IWebPanelMessageListener, IWebPanelProvider, IWorkspaceService } from '../../../client/common/application/types'; +import { WebPanel } from '../../../client/common/application/webPanels/webPanel'; +import { WebPanelProvider } from '../../../client/common/application/webPanels/webPanelProvider'; +import { WorkspaceService } from '../../../client/common/application/workspace'; +import { PythonSettings } from '../../../client/common/configSettings'; +import { ConfigurationService } from '../../../client/common/configuration/service'; +import { CryptoUtils } from '../../../client/common/crypto'; +import { IFileSystem } from '../../../client/common/platform/types'; +import { IConfigurationService, ICryptoUtils, IExtensionContext } from '../../../client/common/types'; +import { EXTENSION_ROOT_DIR } from '../../../client/constants'; +import { Commands } from '../../../client/datascience/constants'; +import { InteractiveWindowMessages } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { NativeEditorStorage } from '../../../client/datascience/interactive-ipynb/nativeEditorStorage'; +import { JupyterExecutionFactory } from '../../../client/datascience/jupyter/jupyterExecutionFactory'; +import { IJupyterExecution, INotebookServerOptions } from '../../../client/datascience/types'; +import { IInterpreterService } from '../../../client/interpreter/contracts'; +import { InterpreterService } from '../../../client/interpreter/interpreterService'; +import { createEmptyCell } from '../../../datascience-ui/interactive-common/mainState'; +import { MockMemento } from '../../mocks/mementos'; +import { MockCommandManager } from '../mockCommandManager'; + +// tslint:disable: no-any chai-vague-errors no-unused-expression +class MockWorkspaceConfiguration implements WorkspaceConfiguration { + private map: Map = new Map(); + + // tslint:disable: no-any + public get(key: string): any; + public get(section: string): T | undefined; + public get(section: string, defaultValue: T): T; + public get(section: any, defaultValue?: any): any; + public get(section: string, defaultValue?: any): any { + if (this.map.has(section)) { + return this.map.get(section); + } + return arguments.length > 1 ? defaultValue : (undefined as any); + } + public has(_section: string): boolean { + return false; + } + public inspect( + _section: string + ): { key: string; defaultValue?: T | undefined; globalValue?: T | undefined; workspaceValue?: T | undefined; workspaceFolderValue?: T | undefined } | undefined { + return; + } + public update(section: string, value: any, _configurationTarget?: boolean | ConfigurationTarget | undefined): Promise { + this.map.set(section, value); + return Promise.resolve(); + } +} + +// tslint:disable: max-func-body-length +suite('Data Science - Native Editor', () => { + let workspace: IWorkspaceService; + let configService: IConfigurationService; + let fileSystem: typemoq.IMock; + let docManager: IDocumentManager; + let cmdManager: MockCommandManager; + let interpreterService: IInterpreterService; + let webPanelProvider: IWebPanelProvider; + let executionProvider: IJupyterExecution; + let globalMemento: MockMemento; + let localMemento: MockMemento; + let context: typemoq.IMock; + let crypto: ICryptoUtils; + let lastWriteFileValue: any; + let wroteToFileEvent: EventEmitter = new EventEmitter(); + let filesConfig: MockWorkspaceConfiguration | undefined; + let testIndex = 0; + const baseUri = Uri.parse('file:///foo.ipynb'); + const baseFile = `{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "collapsed": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "a=1\\n", + "a" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "b=2\\n", + "b" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "3" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "c=3\\n", + "c" + ] + } + ], + "metadata": { + "file_extension": ".py", + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.4" + }, + "mimetype": "text/x-python", + "name": "python", + "npconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": 3 + }, + "nbformat": 4, + "nbformat_minor": 2 +}`; + + const differentFile = `{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "b=2\\n", + "b" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "3" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "c=3\\n", + "c" + ] + } + ], + "metadata": { + "file_extension": ".py", + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.4" + }, + "mimetype": "text/x-python", + "name": "python", + "npconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": 3 + }, + "nbformat": 4, + "nbformat_minor": 2 + }`; + + setup(() => { + context = typemoq.Mock.ofType(); + crypto = mock(CryptoUtils); + globalMemento = new MockMemento(); + localMemento = new MockMemento(); + configService = mock(ConfigurationService); + fileSystem = typemoq.Mock.ofType(); + docManager = mock(DocumentManager); + cmdManager = new MockCommandManager(); + workspace = mock(WorkspaceService); + interpreterService = mock(InterpreterService); + webPanelProvider = mock(WebPanelProvider); + executionProvider = mock(JupyterExecutionFactory); + const settings = mock(PythonSettings); + const settingsChangedEvent = new EventEmitter(); + + context.setup(c => c.globalStoragePath).returns(() => path.join(EXTENSION_ROOT_DIR, 'src', 'test', 'datascience', 'WorkspaceDir')); + + when(settings.onDidChange).thenReturn(settingsChangedEvent.event); + when(configService.getSettings()).thenReturn(instance(settings)); + + const configChangeEvent = new EventEmitter(); + when(workspace.onDidChangeConfiguration).thenReturn(configChangeEvent.event); + filesConfig = new MockWorkspaceConfiguration(); + when(workspace.getConfiguration('files', anything())).thenReturn(filesConfig); + + const interprerterChangeEvent = new EventEmitter(); + when(interpreterService.onDidChangeInterpreter).thenReturn(interprerterChangeEvent.event); + + const editorChangeEvent = new EventEmitter(); + when(docManager.onDidChangeActiveTextEditor).thenReturn(editorChangeEvent.event); + + const sessionChangedEvent = new EventEmitter(); + when(executionProvider.sessionChanged).thenReturn(sessionChangedEvent.event); + + const serverStartedEvent = new EventEmitter(); + when(executionProvider.serverStarted).thenReturn(serverStartedEvent.event); + + testIndex += 1; + when(crypto.createHash(anything(), 'string')).thenReturn(`${testIndex}`); + + let listener: IWebPanelMessageListener; + const webPanel = mock(WebPanel); + class WebPanelCreateMatcher extends Matcher { + public match(value: any) { + listener = value.listener; + listener.onMessage(InteractiveWindowMessages.Started, undefined); + return true; + } + public toString() { + return ''; + } + } + const matcher = (): any => { + return new WebPanelCreateMatcher(); + }; + when(webPanelProvider.create(matcher())).thenResolve(instance(webPanel)); + lastWriteFileValue = baseFile; + wroteToFileEvent = new EventEmitter(); + fileSystem + .setup(f => f.writeFile(typemoq.It.isAny(), typemoq.It.isAny())) + .returns((a1, a2) => { + if (a1.includes(`${testIndex}.ipynb`)) { + lastWriteFileValue = a2; + wroteToFileEvent.fire(a2); + } + return Promise.resolve(); + }); + fileSystem + .setup(f => f.readFile(typemoq.It.isAny())) + .returns(_a1 => { + return Promise.resolve(lastWriteFileValue); + }); + }); + + teardown(() => { + globalMemento.clear(); + sinon.reset(); + }); + + function createStorage() { + return new NativeEditorStorage( + instance(executionProvider), + fileSystem.object, // Use typemoq so can save values in returns + instance(crypto), + context.object, + globalMemento, + globalMemento, + cmdManager + ); + } + + function executeCommand(command: E, ...rest: U) { + return cmdManager.executeCommand(command, ...rest); + } + + test('Create new editor and add some cells', async () => { + const storage = createStorage(); + await storage.load(baseUri); + executeCommand(Commands.NotebookStorage_InsertCell, baseUri, { index: 0, cell: createEmptyCell('1', 1), code: '1', codeCellAboveId: undefined }); + const cells = await storage.getCells(); + expect(cells).to.be.lengthOf(4); + expect(storage.isDirty).to.be.equal(true, 'Editor should be dirty'); + expect(cells[0].id).to.be.match(/1/); + }); + + test('Move cells around', async () => { + const storage = createStorage(); + await storage.load(baseUri); + executeCommand(Commands.NotebookStorage_SwapCells, baseUri, { firstCellId: 'NotebookImport#0', secondCellId: 'NotebookImport#1' }); + const cells = await storage.getCells(); + expect(cells).to.be.lengthOf(3); + expect(storage.isDirty).to.be.equal(true, 'Editor should be dirty'); + expect(cells[0].id).to.be.match(/NotebookImport#1/); + }); + + test('Edit/delete cells', async () => { + const storage = createStorage(); + await storage.load(baseUri); + expect(storage.isDirty).to.be.equal(false, 'Editor should not be dirty'); + executeCommand(Commands.NotebookStorage_EditCell, baseUri, { + changes: [ + { + range: { + startLineNumber: 2, + startColumn: 1, + endLineNumber: 2, + endColumn: 1 + }, + rangeOffset: 4, + rangeLength: 0, + text: 'a' + } + ], + id: 'NotebookImport#1' + }); + let cells = await storage.getCells(); + expect(cells).to.be.lengthOf(3); + expect(cells[1].id).to.be.match(/NotebookImport#1/); + expect(cells[1].data.source).to.be.equals('b=2\nab'); + expect(storage.isDirty).to.be.equal(true, 'Editor should be dirty'); + executeCommand(Commands.NotebookStorage_RemoveCell, baseUri, 'NotebookImport#0'); + cells = await storage.getCells(); + expect(cells).to.be.lengthOf(2); + expect(cells[0].id).to.be.match(/NotebookImport#1/); + executeCommand(Commands.NotebookStorage_DeleteAllCells, baseUri); + cells = await storage.getCells(); + expect(cells).to.be.lengthOf(0); + }); + + test('Opening file with local storage but no global will still open with old contents', async () => { + await filesConfig?.update('autoSave', 'off'); + // This test is really for making sure when a user upgrades to a new extension, we still have their old storage + const file = Uri.parse('file:///foo.ipynb'); + + // Initially nothing in memento + expect(globalMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; + expect(localMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; + + // Put the regular file into the local storage + localMemento.update(`notebook-storage-${file.toString()}`, differentFile); + const editor = createStorage(); + await editor.load(file); + + // It should load with that value + const cells = await editor.getCells(); + expect(cells).to.be.lengthOf(2); + }); + + test('Opening file with global storage but no global file will still open with old contents', async () => { + // This test is really for making sure when a user upgrades to a new extension, we still have their old storage + const file = Uri.parse('file:///foo.ipynb'); + fileSystem.setup(f => f.stat(typemoq.It.isAny())).returns(() => Promise.resolve({ mtime: 1 } as any)); + + // Initially nothing in memento + expect(globalMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; + expect(localMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; + + // Put the regular file into the global storage + globalMemento.update(`notebook-storage-${file.toString()}`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); + const editor = createStorage(); + await editor.load(file); + + // It should load with that value + const cells = await editor.getCells(); + expect(cells).to.be.lengthOf(2); + }); + + test('Opening file with global storage will clear all global storage', async () => { + await filesConfig?.update('autoSave', 'off'); + + // This test is really for making sure when a user upgrades to a new extension, we still have their old storage + const file = Uri.parse('file:///foo.ipynb'); + fileSystem.setup(f => f.stat(typemoq.It.isAny())).returns(() => Promise.resolve({ mtime: 1 } as any)); + + // Initially nothing in memento + expect(globalMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; + expect(localMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; + + // Put the regular file into the global storage + globalMemento.update(`notebook-storage-${file.toString()}`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); + + // Put another file into the global storage + globalMemento.update(`notebook-storage-file::///bar.ipynb`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); + + const editor = createStorage(); + await editor.load(file); + + // It should load with that value + const cells = await editor.getCells(); + expect(cells).to.be.lengthOf(2); + + // And global storage should be empty + expect(globalMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; + expect(globalMemento.get(`notebook-storage-file::///bar.ipynb`)).to.be.undefined; + expect(localMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; + }); +}); diff --git a/src/test/datascience/mockFileSystem.ts b/src/test/datascience/mockFileSystem.ts new file mode 100644 index 000000000000..4081f17401a0 --- /dev/null +++ b/src/test/datascience/mockFileSystem.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +import { FileSystem } from '../../client/common/platform/fileSystem'; +import { IPlatformService } from '../../client/common/platform/types'; + +export class MockFileSystem extends FileSystem { + private contentOverloads = new Map(); + + constructor(platformService: IPlatformService) { + super(platformService); + } + public async readFile(filePath: string): Promise { + const contents = this.contentOverloads.get(filePath); + if (contents) { + return contents; + } + return super.readFile(filePath); + } + public addFileContents(filePath: string, contents: string): void { + this.contentOverloads.set(filePath, contents); + } +} diff --git a/src/test/datascience/nativeEditor.functional.test.tsx b/src/test/datascience/nativeEditor.functional.test.tsx index 715c63aeca2c..24c1b57dc0a5 100644 --- a/src/test/datascience/nativeEditor.functional.test.tsx +++ b/src/test/datascience/nativeEditor.functional.test.tsx @@ -1637,9 +1637,7 @@ for _ in range(50): const editor = notebookProvider.editors[0]; assert.ok(editor, 'No editor when saving'); const savedPromise = createDeferred(); - const metadataUpdatedPromise = createDeferred(); const disposeSaved = editor.saved(() => savedPromise.resolve()); - const disposeMetadataUpdated = editor.metadataUpdated(() => metadataUpdatedPromise.resolve()); // add cells, run them and save await addCell(wrapper, ioc, 'a=1\na'); @@ -1648,10 +1646,6 @@ for _ in range(50): await waitForMessageResponse(ioc, () => runAllButton!.simulate('click')); await threeCellsUpdated; - // Make sure metadata has updated before we save - await metadataUpdatedPromise.promise; - disposeMetadataUpdated.dispose(); - simulateKeyPressOnCell(1, { code: 's', ctrlKey: true }); await savedPromise.promise; diff --git a/src/test/datascience/nativeEditorTestHelpers.tsx b/src/test/datascience/nativeEditorTestHelpers.tsx index 076fb0a85ed6..3cc8c96effbd 100644 --- a/src/test/datascience/nativeEditorTestHelpers.tsx +++ b/src/test/datascience/nativeEditorTestHelpers.tsx @@ -16,12 +16,12 @@ import { addMockData, getCellResults, getNativeFocusedEditor, injectCode, mountW // tslint:disable: no-any -async function getOrCreateNativeEditor(ioc: DataScienceIocContainer, uri?: Uri, contents?: string): Promise { +async function getOrCreateNativeEditor(ioc: DataScienceIocContainer, uri?: Uri): Promise { const notebookProvider = ioc.get(INotebookEditorProvider); let editor: INotebookEditor | undefined; const messageWaiter = waitForMessage(ioc, InteractiveWindowMessages.LoadAllCellsComplete); - if (uri && contents) { - editor = await notebookProvider.open(uri, contents); + if (uri) { + editor = await notebookProvider.open(uri); } else { editor = await notebookProvider.createNew(); } @@ -38,7 +38,8 @@ export async function createNewEditor(ioc: DataScienceIocContainer): Promise { const uri = Uri.file(filePath); - return getOrCreateNativeEditor(ioc, uri, contents); + ioc.setFileContents(uri, contents); + return getOrCreateNativeEditor(ioc, uri); } // tslint:disable-next-line: no-any diff --git a/src/test/datascience/testNativeEditorProvider.ts b/src/test/datascience/testNativeEditorProvider.ts index 6012ac67d81c..78570574cc87 100644 --- a/src/test/datascience/testNativeEditorProvider.ts +++ b/src/test/datascience/testNativeEditorProvider.ts @@ -4,14 +4,14 @@ import { inject, injectable } from 'inversify'; import { Event, Uri } from 'vscode'; -import { ICommandManager, IDocumentManager, IWorkspaceService } from '../../client/common/application/types'; +import { ICommandManager, ICustomEditorService, IWorkspaceService } from '../../client/common/application/types'; import { IFileSystem } from '../../client/common/platform/types'; import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../client/common/types'; import { InteractiveWindowMessageListener } from '../../client/datascience/interactive-common/interactiveWindowMessageListener'; import { InteractiveWindowMessages } from '../../client/datascience/interactive-common/interactiveWindowTypes'; import { NativeEditor } from '../../client/datascience/interactive-ipynb/nativeEditor'; import { NativeEditorProvider } from '../../client/datascience/interactive-ipynb/nativeEditorProvider'; -import { IDataScienceErrorHandler, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../../client/datascience/types'; +import { INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../../client/datascience/types'; import { IServiceContainer } from '../../client/ioc/types'; @injectable() @@ -31,21 +31,10 @@ export class TestNativeEditorProvider implements INotebookEditorProvider { @inject(IWorkspaceService) workspace: IWorkspaceService, @inject(IConfigurationService) configuration: IConfigurationService, @inject(IFileSystem) fileSystem: IFileSystem, - @inject(IDocumentManager) documentManager: IDocumentManager, @inject(ICommandManager) cmdManager: ICommandManager, - @inject(IDataScienceErrorHandler) dataScienceErrorHandler: IDataScienceErrorHandler + @inject(ICustomEditorService) customEditorService: ICustomEditorService ) { - this.realProvider = new NativeEditorProvider( - serviceContainer, - asyncRegistry, - disposables, - workspace, - configuration, - fileSystem, - documentManager, - cmdManager, - dataScienceErrorHandler - ); + this.realProvider = new NativeEditorProvider(serviceContainer, asyncRegistry, disposables, workspace, configuration, fileSystem, cmdManager, customEditorService); } public get activeEditor(): INotebookEditor | undefined { @@ -56,8 +45,8 @@ export class TestNativeEditorProvider implements INotebookEditorProvider { return this.realProvider.editors; } - public async open(file: Uri, contents: string): Promise { - const result = await this.realProvider.open(file, contents); + public async open(file: Uri): Promise { + const result = await this.realProvider.open(file); // During testing the MainPanel sends the init message before our interactive window is created. // Pretend like it's happening now diff --git a/src/test/serviceRegistry.ts b/src/test/serviceRegistry.ts index a9635b02b4e4..7651d7325446 100644 --- a/src/test/serviceRegistry.ts +++ b/src/test/serviceRegistry.ts @@ -4,11 +4,10 @@ import { Container } from 'inversify'; import { anything, instance, mock, when } from 'ts-mockito'; import * as TypeMoq from 'typemoq'; -import { Disposable, Memento, OutputChannel } from 'vscode'; +import { Disposable, Memento, OutputChannel, Uri } from 'vscode'; import { STANDARD_OUTPUT_CHANNEL } from '../client/common/constants'; import { Logger } from '../client/common/logger'; import { IS_WINDOWS } from '../client/common/platform/constants'; -import { FileSystem } from '../client/common/platform/fileSystem'; import { PathUtils } from '../client/common/platform/pathUtils'; import { PlatformService } from '../client/common/platform/platformService'; import { RegistryImplementation } from '../client/common/platform/registry'; @@ -66,6 +65,7 @@ import { IServiceContainer, IServiceManager } from '../client/ioc/types'; import { registerTypes as lintersRegisterTypes } from '../client/linters/serviceRegistry'; import { TEST_OUTPUT_CHANNEL } from '../client/testing/common/constants'; import { registerTypes as unittestsRegisterTypes } from '../client/testing/serviceRegistry'; +import { MockFileSystem } from './datascience/mockFileSystem'; import { MockOutputChannel } from './mockClasses'; import { MockAutoSelectionService } from './mocks/autoSelector'; import { MockMemento } from './mocks/mementos'; @@ -117,9 +117,13 @@ export class IocContainer { this.registerFileSystemTypes(); } } + public setFileContents(uri: Uri, contents: string) { + const fileSystem = this.serviceManager.get(IFileSystem) as MockFileSystem; + fileSystem.addFileContents(uri.fsPath, contents); + } public registerFileSystemTypes() { this.serviceManager.addSingleton(IPlatformService, PlatformService); - this.serviceManager.addSingleton(IFileSystem, FileSystem); + this.serviceManager.addSingletonInstance(IFileSystem, new MockFileSystem(this.serviceManager.get(IPlatformService))); } public registerProcessTypes() { processRegisterTypes(this.serviceManager); From e1801d4ece0f4e008b9e9908f1f2e132a38d7e85 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Fri, 24 Jan 2020 17:38:53 -0800 Subject: [PATCH 06/18] Enable proposed api --- .vscode/launch.json | 6 ++++-- package.json | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 451f2f9d3c67..e972d37c7bad 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,9 @@ "request": "launch", "runtimeExecutable": "${execPath}", "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" + "--extensionDevelopmentPath=${workspaceFolder}", + "--enable-proposed-api", + "ms-python.python" ], "stopOnEntry": false, "smartStep": true, @@ -19,7 +21,7 @@ "preLaunchTask": "Compile", "skipFiles": [ "/**" - ] + ], }, { "name": "Extension inside container", diff --git a/package.json b/package.json index 3602765d1a05..ac9f93654e5c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "2020.2.0-dev", "languageServerVersion": "0.4.114", "publisher": "ms-python", - "enabledProposedApi": true, + "enableProposedApi": true, "author": { "name": "Microsoft Corporation" }, @@ -83,7 +83,8 @@ "onCommand:python.datascience.exportfileasnotebook", "onCommand:python.datascience.exportfileandoutputasnotebook", "onCommand:python.datascience.selectJupyterInterpreter", - "onCommand:python.enableSourceMapSupport" + "onCommand:python.enableSourceMapSupport", + "onWebviewEditor:NativeEditorProvider.ipynb" ], "main": "./out/client/extension", "contributes": { @@ -2768,7 +2769,18 @@ "when": "testsDiscovered" } ] - } + }, + "webviewEditors": [ + { + "viewType": "NativeEditorProvider.ipynb", + "displayName": "Jupyter Notebook Editor", + "selector": [ + { + "filenamePattern": "*.ipynb" + } + ] + } + ] }, "scripts": { "package": "gulp clean && gulp prePublishBundle && vsce package -o ms-python-insiders.vsix", From 2535be8ed478b95fdbe37487a4393d7a414bb13a Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Fri, 24 Jan 2020 18:00:35 -0800 Subject: [PATCH 07/18] Still not working but setting more options. --- .../common/application/webPanels/webPanel.ts | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/client/common/application/webPanels/webPanel.ts b/src/client/common/application/webPanels/webPanel.ts index a4c4134b1aa5..1afad4f69546 100644 --- a/src/client/common/application/webPanels/webPanel.ts +++ b/src/client/common/application/webPanels/webPanel.ts @@ -4,7 +4,7 @@ import '../../extensions'; import * as uuid from 'uuid/v4'; -import { Uri, Webview, WebviewPanel, window } from 'vscode'; +import { Uri, Webview, WebviewOptions, WebviewPanel, window } from 'vscode'; import { Identifiers } from '../../../datascience/constants'; import { InteractiveWindowMessages } from '../../../datascience/interactive-common/interactiveWindowTypes'; @@ -31,20 +31,26 @@ export class WebPanel implements IWebPanel { private token: string | undefined, private options: IWebPanelOptions ) { - this.panel = options.webViewPanel - ? options.webViewPanel - : window.createWebviewPanel( - options.title.toLowerCase().replace(' ', ''), - options.title, - { viewColumn: options.viewColumn, preserveFocus: true }, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [Uri.file(this.options.rootPath), Uri.file(this.options.cwd)], - enableFindWidget: true, - portMapping: port ? [{ webviewPort: RemappedPort, extensionHostPort: port }] : undefined - } - ); + const webViewOptions: WebviewOptions = { + enableScripts: true, + localResourceRoots: [Uri.file(this.options.rootPath), Uri.file(this.options.cwd)], + portMapping: port ? [{ webviewPort: RemappedPort, extensionHostPort: port }] : undefined + }; + if (options.webViewPanel) { + this.panel = options.webViewPanel; + this.panel.webview.options = webViewOptions; + } else { + this.panel = window.createWebviewPanel( + options.title.toLowerCase().replace(' ', ''), + options.title, + { viewColumn: options.viewColumn, preserveFocus: true }, + { + retainContextWhenHidden: true, + enableFindWidget: true, + ...webViewOptions + } + ); + } this.loadPromise = this.load(); } From 448c772ae6ea8f4b22b9bf646212e5a8843476e4 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Mon, 27 Jan 2020 13:30:31 -0800 Subject: [PATCH 08/18] Get web views to load --- .../interactive-ipynb/nativeEditor.ts | 11 +++------- .../interactive-ipynb/nativeEditorProvider.ts | 20 ++++++++++--------- src/client/datascience/webViewHost.ts | 2 ++ 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index ac6e049267df..dae1cfeb691b 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -10,6 +10,7 @@ import { Event, EventEmitter, Memento, Uri, ViewColumn, WebviewPanel } from 'vsc import { createCodeCell, createErrorOutput } from '../../../datascience-ui/common/cellFactory'; import { IApplicationShell, ICommandManager, IDocumentManager, ILiveShareApi, IWebPanelProvider, IWorkspaceService } from '../../common/application/types'; import { ContextKey } from '../../common/contextKey'; +import { traceError } from '../../common/logger'; import { IFileSystem, TemporaryFile } from '../../common/platform/types'; import { GLOBAL_MEMENTO, IConfigurationService, IDisposableRegistry, IMemento } from '../../common/types'; import { createDeferred, Deferred } from '../../common/utils/async'; @@ -160,17 +161,11 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // relative files next to the notebook. await super.loadWebPanel(path.dirname(this.file.fsPath), webViewPanel); - // Update our title to match - this.setTitle(path.basename(this.file.fsPath)); - - // Show ourselves - await this.show(); - // Sign up for dirty events storage.changed(this.contentsChanged.bind(this)); - // Load our cells - await this.loadCells(await storage.getCells()); + // Load our cells, but don't wait for this to finish, otherwise the window won't load. + this.loadCells(await storage.getCells()).catch(exc => traceError('Error loading cells: ', exc)); } public get closed(): Event { diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index 5ca4eb5d4068..e7e5a000307c 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -66,18 +66,20 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus public undoEdits(_resource: Uri, _edits: readonly INotebookEdit[]): Thenable { throw new Error('Method not implemented.'); } - public resolveWebviewEditor(resource: Uri, webview: WebviewPanel): Thenable { + public async resolveWebviewEditor(resource: Uri, panel: WebviewPanel) { // Get the storage - return this.getStorage(resource).then(s => { - // Create a new editor - const editor = this.serviceContainer.get(INotebookEditor); + const storage = await this.getStorage(resource); - // Indicate opened - this.openedEditor(editor); + panel.webview.html = 'TestHere is some text'; - // Load it (should already be visible) - return editor.load(s, webview); - }); + // Create a new editor + const editor = this.serviceContainer.get(INotebookEditor); + + // // Indicate opened + this.openedEditor(editor); + + // // Load it (should already be visible) + return editor.load(storage, panel); } public get editingDelegate(): WebviewCustomEditorEditingDelegate | undefined { return this; diff --git a/src/client/datascience/webViewHost.ts b/src/client/datascience/webViewHost.ts index ccf09bb4d92c..d5f00b0fc033 100644 --- a/src/client/datascience/webViewHost.ts +++ b/src/client/datascience/webViewHost.ts @@ -288,6 +288,8 @@ export class WebViewHost implements IDisposable { // Resolve our started promise. This means the webpanel is ready to go. this.webPanelInit.resolve(); + + traceInfo('Web view react rendered'); } } From d22061c745acb03dfd338f2d19a24c49fd844709 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Mon, 27 Jan 2020 17:18:02 -0800 Subject: [PATCH 09/18] Fix save and save as to use VS code commands --- src/client/common/application/commands.ts | 4 +- src/client/datascience/constants.ts | 2 - .../interactive-ipynb/nativeEditor.ts | 41 ++- .../interactive-ipynb/nativeEditorProvider.ts | 89 ++++--- .../interactive-ipynb/nativeEditorStorage.ts | 249 ++++++++++-------- src/client/datascience/types.ts | 18 +- .../nativeEditorProvider.unit.test.ts | 6 - .../datascience/testNativeEditorProvider.ts | 7 +- 8 files changed, 245 insertions(+), 171 deletions(-) diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index 9b684052b1bb..6b1af336ce4e 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -91,6 +91,8 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu ['python.SelectAndInsertDebugConfiguration']: [TextDocument, Position, CancellationToken]; ['python.viewLanguageServerOutput']: []; ['vscode.open']: [Uri]; + ['workbench.action.files.saveAs']: [Uri]; + ['workbench.action.files.save']: [Uri]; [Commands.Build_Workspace_Symbols]: [boolean, CancellationToken]; [Commands.Sort_Imports]: [undefined, Uri]; [Commands.Exec_In_Terminal]: [undefined, Uri]; @@ -153,8 +155,6 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu [DSCommands.NotebookStorage_InsertCell]: [Uri, IInsertCell]; [DSCommands.NotebookStorage_RemoveCell]: [Uri, string]; [DSCommands.NotebookStorage_SwapCells]: [Uri, ISwapCells]; - [DSCommands.NotebookStorage_Save]: [Uri, ICell[] | undefined]; [DSCommands.NotebookStorage_ClearCellOutputs]: [Uri]; - [DSCommands.NotebookStorage_SaveAs]: [Uri, Uri, ICell[] | undefined]; [DSCommands.NotebookStorage_UpdateVersion]: [Uri, PythonInterpreter | undefined, IJupyterKernelSpec | LiveKernelModel | undefined]; } diff --git a/src/client/datascience/constants.ts b/src/client/datascience/constants.ts index a7e4967cb2a1..d4f93b1a7a2b 100644 --- a/src/client/datascience/constants.ts +++ b/src/client/datascience/constants.ts @@ -71,9 +71,7 @@ export namespace Commands { export const NotebookStorage_InsertCell = 'python.datascience.notebook.insertcell'; export const NotebookStorage_RemoveCell = 'python.datascience.notebook.removecell'; export const NotebookStorage_SwapCells = 'python.datascience.notebook.swapcells'; - export const NotebookStorage_Save = 'python.datascience.notebook.save'; export const NotebookStorage_ClearCellOutputs = 'python.datascience.notebook.clearoutputs'; - export const NotebookStorage_SaveAs = 'python.datascience.notebook.saveas'; export const NotebookStorage_UpdateVersion = 'python.datascience.notebook.updateversion'; } diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index dae1cfeb691b..0ccac57c0cd0 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -50,6 +50,7 @@ import { INotebookImporter, INotebookServerOptions, INotebookStorage, + INotebookStorageChange, IStatusProvider, IThemeFinder } from '../types'; @@ -142,8 +143,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } public get isUntitled(): boolean { - const baseName = path.basename(this.file.fsPath); - return baseName.includes(localize.DataScience.untitledNotebookFileName()); + return this._storage ? this._storage.isUntitled : false; } public dispose(): Promise { super.dispose(); @@ -165,7 +165,14 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { storage.changed(this.contentsChanged.bind(this)); // Load our cells, but don't wait for this to finish, otherwise the window won't load. - this.loadCells(await storage.getCells()).catch(exc => traceError('Error loading cells: ', exc)); + this.loadCells(await storage.getCells()) + .then(() => { + // May alread be dirty, if so send a message + if (storage.isDirty) { + this.postMessage(InteractiveWindowMessages.NotebookDirty).ignoreErrors(); + } + }) + .catch(exc => traceError('Error loading cells: ', exc)); } public get closed(): Event { @@ -419,13 +426,15 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Actually don't close, just let the error bubble out } - private contentsChanged(): Promise { - this.modifiedEvent.fire(); - const dirty = !this._storage || this._storage.isDirty; - if (dirty) { - return this.postMessage(InteractiveWindowMessages.NotebookDirty); - } else { - return this.postMessage(InteractiveWindowMessages.NotebookClean); + private contentsChanged(change: INotebookStorageChange) { + if (change.isDirty !== undefined) { + this.modifiedEvent.fire(); + const dirty = !this._storage || this._storage.isDirty; + if (dirty) { + return this.postMessage(InteractiveWindowMessages.NotebookDirty); + } else { + return this.postMessage(InteractiveWindowMessages.NotebookClean); + } } } @@ -480,7 +489,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { tempFile = await this.fileSystem.createTemporaryFile('.ipynb'); // Translate the cells into a notebook - await this.commandManager.executeCommand(Commands.NotebookStorage_SaveAs, this.file, Uri.file(tempFile.filePath), cells); + const content = this._storage ? await this._storage.getContent(cells) : ''; + await this.fileSystem.writeFile(tempFile.filePath, content, 'utf-8'); // Import this file and show it const contents = await this.importer.importFromFile(tempFile.filePath, this.file.fsPath); @@ -502,8 +512,13 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { await this.documentManager.showTextDocument(doc, ViewColumn.One); } - private saveAll(args: ISaveAll) { - this.commandManager.executeCommand(Commands.NotebookStorage_Save, this.file, args.cells); + private async saveAll(_args: ISaveAll) { + // Ask user for a save as dialog if no title + if (this.isUntitled) { + this.commandManager.executeCommand('workbench.action.files.saveAs', this.file); + } else { + this.commandManager.executeCommand('workbench.action.files.save', this.file); + } } private logNativeCommand(args: INativeCommand) { diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index e7e5a000307c..b0680ceb8bba 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -4,14 +4,14 @@ import { inject, injectable } from 'inversify'; import { Disposable, Event, EventEmitter, Uri, WebviewCustomEditorEditingDelegate, WebviewCustomEditorProvider, WebviewPanel } from 'vscode'; -import { ICommandManager, ICustomEditorService, IWorkspaceService } from '../../common/application/types'; -import { IFileSystem } from '../../common/platform/types'; +import { ICustomEditorService, IWorkspaceService } from '../../common/application/types'; import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../common/types'; import { createDeferred } from '../../common/utils/async'; +import * as localize from '../../common/utils/localize'; import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; -import { Commands, Identifiers, Settings, Telemetry } from '../constants'; -import { ILoadableNotebookStorage, INotebookEdit, INotebookEditor, INotebookEditorProvider, INotebookServerOptions } from '../types'; +import { Identifiers, Settings, Telemetry } from '../constants'; +import { ILoadableNotebookStorage, INotebookEdit, INotebookEditor, INotebookEditorProvider, INotebookServerOptions, INotebookStorageChange } from '../types'; @injectable() export class NativeEditorProvider implements INotebookEditorProvider, WebviewCustomEditorProvider, WebviewCustomEditorEditingDelegate, IAsyncDisposable { @@ -34,8 +34,6 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus @inject(IDisposableRegistry) private disposables: IDisposableRegistry, @inject(IWorkspaceService) workspace: IWorkspaceService, @inject(IConfigurationService) private configuration: IConfigurationService, - @inject(IFileSystem) private fileSystem: IFileSystem, - @inject(ICommandManager) private cmdManager: ICommandManager, @inject(ICustomEditorService) private customEditorService: ICustomEditorService ) { asyncRegistry.push(this); @@ -52,33 +50,39 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } public save(resource: Uri): Thenable { - return this.cmdManager.executeCommand(Commands.NotebookStorage_Save, resource, undefined); + return this.getStorage(resource).then(async s => { + if (s) { + await s.save(); + } + }); } public saveAs(resource: Uri, targetResource: Uri): Thenable { - return this.cmdManager.executeCommand(Commands.NotebookStorage_SaveAs, resource, targetResource, undefined); + return this.getStorage(resource).then(async s => { + if (s) { + await s.saveAs(targetResource); + } + }); } public get onEdit(): Event<{ readonly resource: Uri; readonly edit: INotebookEdit }> { return this._editEventEmitter.event; } public applyEdits(_resource: Uri, _edits: readonly INotebookEdit[]): Thenable { - throw new Error('Method not implemented.'); + return Promise.resolve(); } public undoEdits(_resource: Uri, _edits: readonly INotebookEdit[]): Thenable { - throw new Error('Method not implemented.'); + return Promise.resolve(); } public async resolveWebviewEditor(resource: Uri, panel: WebviewPanel) { // Get the storage const storage = await this.getStorage(resource); - panel.webview.html = 'TestHere is some text'; - // Create a new editor const editor = this.serviceContainer.get(INotebookEditor); - // // Indicate opened + // Indicate opened this.openedEditor(editor); - // // Load it (should already be visible) + // Load it (should already be visible) return editor.load(storage, panel); } public get editingDelegate(): WebviewCustomEditorEditingDelegate | undefined { @@ -139,14 +143,16 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus @captureTelemetry(Telemetry.CreateNewNotebook, undefined, false) public async createNew(contents?: string): Promise { - // Create a temporary file on disk to hold the contents - const tempFile = await this.fileSystem.createTemporaryFile('ipynb'); - if (contents) { - await this.fileSystem.writeFile(tempFile.filePath, contents, 'utf-8'); - } + // Create a new URI for the dummy file using our root workspace path + const uri = await this.getNextNewNotebookUri(); - // Use an 'untitled' URI - return this.open(Uri.parse(`untitled://${tempFile.filePath}`)); + // Update number of notebooks in the workspace + this.notebookCount += 1; + + // Set these contents into the storage before the file opens + await this.getStorage(uri, contents); + + return this.open(uri); } public async getNotebookOptions(): Promise { @@ -192,24 +198,45 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } } - private async storageChanged(file: Uri): Promise { - // When the storage changes, tell VS code about the edit - const storage = await this.getStorage(file); - const cells = await storage.getCells(); - this._editEventEmitter.fire({ resource: file, edit: { contents: cells } }); + private async storageChanged(file: Uri, change: INotebookStorageChange): Promise { + // If the file changes, update our storage + if (change.oldFile && change.newFile) { + this.storage.delete(change.oldFile.toString()); + this.storage.set(change.newFile.toString(), Promise.resolve(change.storage as ILoadableNotebookStorage)); + } + // If the cells change, tell VS code about it + if (change.newCells && change.isDirty) { + this._editEventEmitter.fire({ resource: file, edit: { contents: change.newCells } }); + } } - private getStorage(file: Uri): Promise { - let storagePromise = this.storage.get(file.fsPath); + private getStorage(file: Uri, contents?: string): Promise { + const key = file.toString(); + let storagePromise = this.storage.get(key); if (!storagePromise) { const storage = this.serviceContainer.get(ILoadableNotebookStorage); - storagePromise = storage.load(file).then(_v => { - this.storageChangedHandlers.set(file.fsPath, storage.changed(this.storageChanged.bind(this, file))); + if (!this.storageChangedHandlers.has(key)) { + this.storageChangedHandlers.set(key, storage.changed(this.storageChanged.bind(this, file))); + } + storagePromise = storage.load(file, contents).then(_v => { return storage; }); - this.storage.set(file.fsPath, storagePromise); + this.storage.set(key, storagePromise); } return storagePromise; } + + private async getNextNewNotebookUri(): Promise { + // See if we have any untitled storage already + const untitledStorage = [...this.storage.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. + const fileName = `${localize.DataScience.untitledNotebookFileName()}-${untitledStorage.length + 1}.ipynb`; + const fileUri = Uri.file(fileName); + + // Turn this back into an untitled + return fileUri.with({ scheme: 'untitled', path: fileName }); + } } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts index 9ae7da094417..7560d260e05f 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts @@ -1,4 +1,5 @@ import { nbformat } from '@jupyterlab/coreutils'; +import * as fastDeepEqual from 'fast-deep-equal'; import { inject, injectable, named } from 'inversify'; import * as path from 'path'; import * as uuid from 'uuid/v4'; @@ -9,14 +10,13 @@ import { ICommandManager } from '../../common/application/types'; import { traceError } from '../../common/logger'; import { IFileSystem } from '../../common/platform/types'; import { GLOBAL_MEMENTO, ICryptoUtils, IExtensionContext, IMemento, WORKSPACE_MEMENTO } from '../../common/types'; -import * as localize from '../../common/utils/localize'; import { noop } from '../../common/utils/misc'; import { PythonInterpreter } from '../../interpreter/contracts'; import { Commands, Identifiers } from '../constants'; import { IEditCell, IInsertCell, ISwapCells } from '../interactive-common/interactiveWindowTypes'; import { InvalidNotebookFileError } from '../jupyter/invalidNotebookFileError'; import { LiveKernelModel } from '../jupyter/kernels/types'; -import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, ILoadableNotebookStorage, INotebookStorage } from '../types'; +import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, ILoadableNotebookStorage, INotebookStorage, INotebookStorageChange } from '../types'; // tslint:disable-next-line:no-require-imports no-var-requires import detectIndent = require('detect-indent'); @@ -24,33 +24,36 @@ import detectIndent = require('detect-indent'); const KeyPrefix = 'notebook-storage-'; const NotebookTransferKey = 'notebook-transfered'; +interface INativeEditorStorageState { + file: Uri; + cells: ICell[]; + isDirty: boolean; + notebookJson: Partial; +} + @injectable() export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookStorage { public get isDirty(): boolean { - return this._isDirty; + return this._state.isDirty; } - public get changed(): Event { + public get changed(): Event { return this._changedEmitter.event; } public get file(): Uri { - return this._file; + return this._state.file; } public get isUntitled(): boolean { - const baseName = path.basename(this.file.fsPath); - return baseName.includes(localize.DataScience.untitledNotebookFileName()); + return this.file.scheme === 'untitled'; } private static signedUpForCommands = false; private static storageMap = new Map(); - private _changedEmitter = new EventEmitter(); - private _cells: ICell[] = []; + private _changedEmitter = new EventEmitter(); + private _state: INativeEditorStorageState = { file: Uri.file(''), isDirty: false, cells: [], notebookJson: {} }; private _loadPromise: Promise | undefined; private _loaded = false; - private _file: Uri = Uri.file(''); - private _isDirty: boolean = false; private indentAmount: string = ' '; - private notebookJson: Partial = {}; constructor( @inject(IJupyterExecution) private jupyterExecution: IJupyterExecution, @@ -77,13 +80,11 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS commandManager.registerCommand(Commands.NotebookStorage_ModifyCells, NativeEditorStorage.handleModifyCells); commandManager.registerCommand(Commands.NotebookStorage_RemoveCell, NativeEditorStorage.handleRemoveCell); commandManager.registerCommand(Commands.NotebookStorage_SwapCells, NativeEditorStorage.handleSwapCells); - commandManager.registerCommand(Commands.NotebookStorage_Save, NativeEditorStorage.handleSave); - commandManager.registerCommand(Commands.NotebookStorage_SaveAs, NativeEditorStorage.handleSaveAs); commandManager.registerCommand(Commands.NotebookStorage_UpdateVersion, NativeEditorStorage.handleUpdateVersionInfo); } private static async getStorage(resource: Uri): Promise { - const storage = NativeEditorStorage.storageMap.get(resource.fsPath); + const storage = NativeEditorStorage.storageMap.get(resource.toString()); if (storage && storage._loadPromise) { await storage._loadPromise; return storage; @@ -108,16 +109,18 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS const normalized = change.text.replace(/\r/g, ''); // Figure out which cell we're editing. - const cell = s._cells.find(c => c.id === request.id); - if (cell) { + const index = s._state.cells.findIndex(c => c.id === request.id); + if (index >= 0) { // This is an actual edit. - const contents = concatMultilineStringInput(cell.data.source); + const contents = concatMultilineStringInput(s._state.cells[index].data.source); const before = contents.substr(0, change.rangeOffset); const after = contents.substr(change.rangeOffset + change.rangeLength); const newContents = `${before}${normalized}${after}`; if (contents !== newContents) { - cell.data.source = newContents; - return s.setDirty(); + const newCells = [...s._state.cells]; + const newCell = { ...newCells[index], data: { ...newCells[index].data, source: newContents } }; + newCells[index] = newCell; + return s.setState({ cells: newCells }); } } } @@ -127,73 +130,61 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS private static async handleInsert(resource: Uri, request: IInsertCell): Promise { return NativeEditorStorage.handleCallback(resource, async s => { // Insert a cell into our visible list based on the index. They should be in sync - s._cells.splice(request.index, 0, request.cell); - return s.setDirty(); + const newCells = [...s._state.cells]; + newCells.splice(request.index, 0, request.cell); + return s.setState({ cells: newCells }); }); } private static async handleRemoveCell(resource: Uri, id: string): Promise { // Filter our list return NativeEditorStorage.handleCallback(resource, async s => { - s._cells = s._cells.filter(v => v.id !== id); - return s.setDirty(); + const newCells = [...s._state.cells].filter(v => v.id !== id); + return s.setState({ cells: newCells }); }); } private static async handleSwapCells(resource: Uri, request: ISwapCells): Promise { // Swap two cells in our list return NativeEditorStorage.handleCallback(resource, async s => { - const first = s._cells.findIndex(v => v.id === request.firstCellId); - const second = s._cells.findIndex(v => v.id === request.secondCellId); + const first = s._state.cells.findIndex(v => v.id === request.firstCellId); + const second = s._state.cells.findIndex(v => v.id === request.secondCellId); if (first >= 0 && second >= 0) { - const temp = { ...s._cells[first] }; - s._cells[first] = s._cells[second]; - s._cells[second] = temp; - return s.setDirty(); + const newCells = [...s._state.cells]; + const temp = { ...newCells[first] }; + newCells[first] = newCells[second]; + newCells[second] = temp; + return s.setState({ cells: newCells }); } }); } private static handleDeleteAllCells(resource: Uri): Promise { return NativeEditorStorage.handleCallback(resource, async s => { - s._cells = []; - return s.setDirty(); + return s.setState({ cells: [] }); }); } private static handleClearAllOutputs(resource: Uri): Promise { return NativeEditorStorage.handleCallback(resource, async s => { - s._cells.forEach(cell => { - cell.data.execution_count = null; - cell.data.outputs = []; + const newCells = s._state.cells.map(c => { + return { ...c, data: { ...c.data, execution_count: null, outputs: [] } }; }); - return s.setDirty(); + return s.setState({ cells: newCells }); }); } private static async handleModifyCells(resource: Uri, cells: ICell[]): Promise { return NativeEditorStorage.handleCallback(resource, async s => { + const newCells = [...s._state.cells]; // Update these cells in our list cells.forEach(c => { - const index = s._cells.findIndex(v => v.id === c.id); - s._cells[index] = c; + const index = newCells.findIndex(v => v.id === c.id); + newCells[index] = c; }); // Indicate dirty - return s.setDirty(); - }); - } - - private static async handleSave(resource: Uri, cells: ICell[] | undefined): Promise { - return NativeEditorStorage.handleCallback(resource, async s => { - return NativeEditorStorage.handleSaveAs(s._file, s._file, cells); - }); - } - - private static async handleSaveAs(resource: Uri, newFile: Uri, cells: ICell[] | undefined): Promise { - return NativeEditorStorage.handleCallback(resource, async s => { - const actualCells = cells ? cells : s._cells; - return s.fileSystem.writeFile(newFile.fsPath, await s.generateNotebookContent(actualCells), { encoding: 'utf-8' }); + return s.setState({ cells: newCells, isDirty: true }); }); } @@ -208,68 +199,97 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS ): Promise { return NativeEditorStorage.handleCallback(resource, async s => { // Get our kernel_info and language_info from the current notebook - if (interpreter && interpreter.version && s.notebookJson.metadata && s.notebookJson.metadata.language_info) { - s.notebookJson.metadata.language_info.version = interpreter.version.raw; + if (interpreter && interpreter.version && s._state.notebookJson.metadata && s._state.notebookJson.metadata.language_info) { + s._state.notebookJson.metadata.language_info.version = interpreter.version.raw; } - if (kernelSpec && s.notebookJson.metadata && !s.notebookJson.metadata.kernelspec) { + if (kernelSpec && s._state.notebookJson.metadata && !s._state.notebookJson.metadata.kernelspec) { // Add a new spec in this case - s.notebookJson.metadata.kernelspec = { + s._state.notebookJson.metadata.kernelspec = { name: kernelSpec.name || kernelSpec.display_name || '', display_name: kernelSpec.display_name || kernelSpec.name || '' }; - } else if (kernelSpec && s.notebookJson.metadata && s.notebookJson.metadata.kernelspec) { + } else if (kernelSpec && s._state.notebookJson.metadata && s._state.notebookJson.metadata.kernelspec) { // Spec exists, just update name and display_name - s.notebookJson.metadata.kernelspec.name = kernelSpec.name || kernelSpec.display_name || ''; - s.notebookJson.metadata.kernelspec.display_name = kernelSpec.display_name || kernelSpec.name || ''; + s._state.notebookJson.metadata.kernelspec.name = kernelSpec.name || kernelSpec.display_name || ''; + s._state.notebookJson.metadata.kernelspec.display_name = kernelSpec.display_name || kernelSpec.name || ''; } }); } - public async load(file: Uri): Promise { + public async load(file: Uri, possibleContents?: string): Promise { // Reset the load promise and reload our cells this._loaded = false; - this._loadPromise = this.loadFromFile(file); + this._loadPromise = this.loadFromFile(file, possibleContents); await this._loadPromise; } + public save(): Promise { + return this.saveAs(this.file); + } + + public async saveAs(file: Uri): Promise { + const contents = await this.getContent(); + await this.fileSystem.writeFile(file.fsPath, contents, 'utf-8'); + this.setState({ isDirty: false, file }); + } + public getCells(): Promise { if (!this._loaded && this._loadPromise) { return this._loadPromise; } // If already loaded, return the updated cell values - return Promise.resolve(this._cells); + return Promise.resolve(this._state.cells); } public async getJson(): Promise> { await this.ensureNotebookJson(); - return this.notebookJson; + return this._state.notebookJson; } - private async loadFromFile(file: Uri): Promise { + public getContent(cells?: ICell[]): Promise { + return this.generateNotebookContent(cells ? cells : this._state.cells); + } + + private async loadFromFile(file: Uri, possibleContents?: string): Promise { // Save file - this._file = file; + this.setState({ file }); - // Attempt to read the contents - const contents = await this.fileSystem.readFile(this._file.fsPath); + try { + // Attempt to read the contents if a viable file + const contents = file.scheme === 'untitled' ? possibleContents : await this.fileSystem.readFile(this.file.fsPath); - // Clear out old global storage the first time somebody opens - // a notebook - if (!this.globalStorage.get(NotebookTransferKey)) { - await this.transferStorage(); - } + // Clear out old global storage the first time somebody opens + // a notebook + if (!this.globalStorage.get(NotebookTransferKey)) { + await this.transferStorage(); + } - // See if this file was stored in storage prior to shutdown - const dirtyContents = await this.getStoredContents(); - if (dirtyContents) { - // This means we're dirty. Indicate dirty and load from this content - return this.loadContents(dirtyContents, true); - } else { - // Load without setting dirty - return this.loadContents(contents, false); + // See if this file was stored in storage prior to shutdown + const dirtyContents = await this.getStoredContents(); + if (dirtyContents) { + // This means we're dirty. Indicate dirty and load from this content + return this.loadContents(dirtyContents, true); + } else { + // Load without setting dirty + return this.loadContents(contents, false); + } + } catch { + // May not exist at this time. Should always have a single cell though + return [this.createEmptyCell()]; } } + private createEmptyCell() { + return { + id: uuid(), + line: 0, + file: Identifiers.EmptyFileName, + state: CellState.finished, + data: createCodeCell() + }; + } + private async loadContents(contents: string | undefined, forceDirty: boolean): Promise { // tslint:disable-next-line: no-any const json = contents ? (JSON.parse(contents) as any) : undefined; @@ -286,7 +306,7 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS // Then save the contents. We'll stick our cells back into this format when we save if (json) { - this.notebookJson = json; + this._state.notebookJson = json; } // Extract cells from the json @@ -303,32 +323,19 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS }; }); - // Turn this into our cell list + // Make sure at least one if (remapped.length === 0) { - const defaultCell: ICell = { - id: uuid(), - line: 0, - file: Identifiers.EmptyFileName, - state: CellState.finished, - data: createCodeCell() - }; - // tslint:disable-next-line: no-any - remapped.splice(0, 0, defaultCell as any); + remapped.splice(0, 0, this.createEmptyCell()); forceDirty = true; } // Save as our visible list - this._cells = remapped; - - // Make dirty if necessary - if (forceDirty) { - await this.setDirty(); - } + this.setState({ cells: remapped, isDirty: forceDirty }); // Indicate loaded this._loaded = true; - return this._cells; + return this._state.cells; } private async extractPythonMainVersion(notebookData: Partial): Promise { @@ -349,8 +356,8 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS } private async ensureNotebookJson(): Promise { - if (!this.notebookJson || !this.notebookJson.metadata) { - const pythonNumber = await this.extractPythonMainVersion(this.notebookJson); + if (!this._state.notebookJson || !this._state.notebookJson.metadata) { + const pythonNumber = await this.extractPythonMainVersion(this._state.notebookJson); // Use this to build our metadata object // Use these as the defaults unless we have been given some in the options. const metadata: nbformat.INotebookMetadata = { @@ -371,7 +378,7 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS }; // Default notebook data. - this.notebookJson = { + this._state.notebookJson = { nbformat: 4, nbformat_minor: 2, metadata: metadata @@ -385,7 +392,7 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS // Reuse our original json except for the cells. const json = { - ...(this.notebookJson as nbformat.INotebookContent), + ...(this._state.notebookJson as nbformat.INotebookContent), cells: cells.map(c => this.fixupCell(c.data)) }; return JSON.stringify(json, null, this.indentAmount); @@ -400,18 +407,40 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS } as any) as nbformat.ICell; // nyc (code coverage) barfs on this so just trick it. } - private async setDirty(): Promise { - // Update dirty flag. - if (!this._isDirty) { - this._isDirty = true; - - // Tell listeners we're dirty - this._changedEmitter.fire(); + private setState(newState: Partial) { + let changed = false; + const change: INotebookStorageChange = { storage: this }; + if (newState.file && newState.file !== this.file) { + change.newFile = newState.file; + change.oldFile = this.file; + this._state.file = change.newFile; + NativeEditorStorage.storageMap.delete(this.file.toString()); + NativeEditorStorage.storageMap.set(newState.file.toString(), this); + changed = true; + } + if (newState.cells && !fastDeepEqual(newState.cells, this._state.cells)) { + change.oldCells = this._state.cells; + change.newCells = newState.cells; + this._state.cells = newState.cells; + + // Force dirty on a cell change + this._state.isDirty = true; + change.isDirty = true; + changed = true; + } + if (newState.isDirty !== undefined && newState.isDirty !== this._state.isDirty) { + // This should happen on save all (to put back the dirty cell change) + change.isDirty = newState.isDirty; + this._state.isDirty = newState.isDirty; + changed = true; + } + if (changed) { + this._changedEmitter.fire(change); } } private getStorageKey(): string { - return `${KeyPrefix}${this._file.toString()}`; + return `${KeyPrefix}${this.file.toString()}`; } /** diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index 4cfcf0953f3b..60630fc96425 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -730,16 +730,30 @@ export interface INotebookEdit { readonly contents: ICell[]; } +export interface INotebookStorageChange { + storage: INotebookStorage; + newFile?: Uri; + oldFile?: Uri; + isDirty?: boolean; + isUntitled?: boolean; + newCells?: ICell[]; + oldCells?: ICell[]; +} + export interface INotebookStorage { readonly file: Uri; readonly isDirty: boolean; - readonly changed: Event; + readonly isUntitled: boolean; + readonly changed: Event; getCells(): Promise; getJson(): Promise>; + getContent(cells?: ICell[]): Promise; } export const ILoadableNotebookStorage = Symbol('ILoadableNotebookStorage'); export interface ILoadableNotebookStorage extends INotebookStorage { - load(file: Uri): Promise; + load(file: Uri, contents?: string): Promise; + save(): Promise; + saveAs(file: Uri): Promise; } diff --git a/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts b/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts index 3e103e042aa2..8016b380b9e0 100644 --- a/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts +++ b/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts @@ -16,8 +16,6 @@ import { ICommandManager, ICustomEditorService, IDocumentManager, IWorkspaceServ import { WorkspaceService } from '../../../client/common/application/workspace'; import { AsyncDisposableRegistry } from '../../../client/common/asyncDisposableRegistry'; import { ConfigurationService } from '../../../client/common/configuration/service'; -import { FileSystem } from '../../../client/common/platform/fileSystem'; -import { IFileSystem } from '../../../client/common/platform/types'; import { IConfigurationService } from '../../../client/common/types'; import { NativeEditorProvider } from '../../../client/datascience/interactive-ipynb/nativeEditorProvider'; import { INotebookEditor } from '../../../client/datascience/types'; @@ -28,7 +26,6 @@ import { IServiceContainer } from '../../../client/ioc/types'; suite('Data Science - Native Editor Provider', () => { let workspace: IWorkspaceService; let configService: IConfigurationService; - let fileSystem: IFileSystem; let docManager: IDocumentManager; let cmdManager: ICommandManager; let svcContainer: IServiceContainer; @@ -40,7 +37,6 @@ suite('Data Science - Native Editor Provider', () => { setup(() => { svcContainer = mock(ServiceContainer); configService = mock(ConfigurationService); - fileSystem = mock(FileSystem); docManager = mock(DocumentManager); cmdManager = mock(CommandManager); workspace = mock(WorkspaceService); @@ -79,8 +75,6 @@ suite('Data Science - Native Editor Provider', () => { [], instance(workspace), instance(configService), - instance(fileSystem), - instance(cmdManager), instance(customEditorService) ); } diff --git a/src/test/datascience/testNativeEditorProvider.ts b/src/test/datascience/testNativeEditorProvider.ts index 78570574cc87..66c5e8631976 100644 --- a/src/test/datascience/testNativeEditorProvider.ts +++ b/src/test/datascience/testNativeEditorProvider.ts @@ -4,8 +4,7 @@ import { inject, injectable } from 'inversify'; import { Event, Uri } from 'vscode'; -import { ICommandManager, ICustomEditorService, IWorkspaceService } from '../../client/common/application/types'; -import { IFileSystem } from '../../client/common/platform/types'; +import { ICustomEditorService, IWorkspaceService } from '../../client/common/application/types'; import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../client/common/types'; import { InteractiveWindowMessageListener } from '../../client/datascience/interactive-common/interactiveWindowMessageListener'; import { InteractiveWindowMessages } from '../../client/datascience/interactive-common/interactiveWindowTypes'; @@ -30,11 +29,9 @@ export class TestNativeEditorProvider implements INotebookEditorProvider { @inject(IDisposableRegistry) disposables: IDisposableRegistry, @inject(IWorkspaceService) workspace: IWorkspaceService, @inject(IConfigurationService) configuration: IConfigurationService, - @inject(IFileSystem) fileSystem: IFileSystem, - @inject(ICommandManager) cmdManager: ICommandManager, @inject(ICustomEditorService) customEditorService: ICustomEditorService ) { - this.realProvider = new NativeEditorProvider(serviceContainer, asyncRegistry, disposables, workspace, configuration, fileSystem, cmdManager, customEditorService); + this.realProvider = new NativeEditorProvider(serviceContainer, asyncRegistry, disposables, workspace, configuration, customEditorService); } public get activeEditor(): INotebookEditor | undefined { From a12b9460695d031fcb3e11a6131795ff8a9016c9 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Tue, 28 Jan 2020 11:31:33 -0800 Subject: [PATCH 10/18] Fix unit tests --- .../interactive-ipynb/nativeEditorProvider.ts | 15 ++-- .../interactive-ipynb/nativeEditorStorage.ts | 10 +-- .../nativeEditorProvider.unit.test.ts | 79 ++++++++++++------- .../nativeEditorStorage.unit.test.ts | 58 ++++++-------- 4 files changed, 87 insertions(+), 75 deletions(-) diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index b0680ceb8bba..5b74336c5d78 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -2,9 +2,11 @@ // Licensed under the MIT License. 'use strict'; import { inject, injectable } from 'inversify'; +import * as uuid from 'uuid/v4'; import { Disposable, Event, EventEmitter, Uri, WebviewCustomEditorEditingDelegate, WebviewCustomEditorProvider, WebviewPanel } from 'vscode'; - +import { arePathsSame } from '../../../datascience-ui/react-common/arePathsSame'; import { ICustomEditorService, IWorkspaceService } from '../../common/application/types'; +import { traceInfo } from '../../common/logger'; import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../common/types'; import { createDeferred } from '../../common/utils/async'; import * as localize from '../../common/utils/localize'; @@ -28,6 +30,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus private executedEditors: Set = new Set(); private notebookCount: number = 0; private openedNotebookCount: number = 0; + private _id = uuid(); constructor( @inject(IServiceContainer) private serviceContainer: IServiceContainer, @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, @@ -36,6 +39,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus @inject(IConfigurationService) private configuration: IConfigurationService, @inject(ICustomEditorService) private customEditorService: ICustomEditorService ) { + traceInfo(`id is ${this._id}`); asyncRegistry.push(this); // Look through the file system for ipynb files to see how many we have in the workspace. Don't wait @@ -79,11 +83,8 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus // Create a new editor const editor = this.serviceContainer.get(INotebookEditor); - // Indicate opened - this.openedEditor(editor); - // Load it (should already be visible) - return editor.load(storage, panel); + return editor.load(storage, panel).then(() => this.openedEditor(editor)); } public get editingDelegate(): WebviewCustomEditorEditingDelegate | undefined { return this; @@ -121,14 +122,14 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus // Sign up for open event once it does open let disposable: Disposable | undefined; const handler = (e: INotebookEditor) => { - if (e.file === file) { + if (arePathsSame(e.file.fsPath, file.fsPath)) { if (disposable) { disposable.dispose(); } deferred.resolve(e); } }; - disposable = this.onDidOpenNotebookEditor(handler); + disposable = this._onDidOpenNotebookEditor.event(handler); // Send an open command. this.customEditorService.openEditor(file); diff --git a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts index 7560d260e05f..e594af6cdc6b 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts @@ -70,6 +70,10 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS } } + public static unregister(): void { + NativeEditorStorage.signedUpForCommands = false; + } + private static registerCommands(commandManager: ICommandManager): void { NativeEditorStorage.signedUpForCommands = true; commandManager.registerCommand(Commands.NotebookStorage_ClearCellOutputs, NativeEditorStorage.handleClearAllOutputs); @@ -259,12 +263,6 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS // Attempt to read the contents if a viable file const contents = file.scheme === 'untitled' ? possibleContents : await this.fileSystem.readFile(this.file.fsPath); - // Clear out old global storage the first time somebody opens - // a notebook - if (!this.globalStorage.get(NotebookTransferKey)) { - await this.transferStorage(); - } - // See if this file was stored in storage prior to shutdown const dirtyContents = await this.getStoredContents(); if (dirtyContents) { diff --git a/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts b/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts index 8016b380b9e0..61aa58c74393 100644 --- a/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts +++ b/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts @@ -8,17 +8,15 @@ import { expect } from 'chai'; import { instance, mock, when } from 'ts-mockito'; import * as typemoq from 'typemoq'; -import { EventEmitter, TextEditor, Uri } from 'vscode'; -import { CommandManager } from '../../../client/common/application/commandManager'; -import { CustomEditorService } from '../../../client/common/application/customEditorService'; -import { DocumentManager } from '../../../client/common/application/documentManager'; -import { ICommandManager, ICustomEditorService, IDocumentManager, IWorkspaceService } from '../../../client/common/application/types'; +import { EventEmitter, Uri, WebviewPanel } from 'vscode'; +import { ICustomEditorService, IWorkspaceService } from '../../../client/common/application/types'; import { WorkspaceService } from '../../../client/common/application/workspace'; import { AsyncDisposableRegistry } from '../../../client/common/asyncDisposableRegistry'; import { ConfigurationService } from '../../../client/common/configuration/service'; import { IConfigurationService } from '../../../client/common/types'; +import { noop } from '../../../client/common/utils/misc'; import { NativeEditorProvider } from '../../../client/datascience/interactive-ipynb/nativeEditorProvider'; -import { INotebookEditor } from '../../../client/datascience/types'; +import { ILoadableNotebookStorage, INotebookEditor } from '../../../client/datascience/types'; import { ServiceContainer } from '../../../client/ioc/container'; import { IServiceContainer } from '../../../client/ioc/types'; @@ -26,61 +24,82 @@ import { IServiceContainer } from '../../../client/ioc/types'; suite('Data Science - Native Editor Provider', () => { let workspace: IWorkspaceService; let configService: IConfigurationService; - let docManager: IDocumentManager; - let cmdManager: ICommandManager; let svcContainer: IServiceContainer; - let changeActiveTextEditorEventEmitter: EventEmitter; let editor: typemoq.IMock; - let customEditorService: ICustomEditorService; + let storage: typemoq.IMock; + let customEditorService: typemoq.IMock; let file: Uri; + let storageFile: Uri; + let registeredProvider: NativeEditorProvider; + let panel: typemoq.IMock; setup(() => { svcContainer = mock(ServiceContainer); configService = mock(ConfigurationService); - docManager = mock(DocumentManager); - cmdManager = mock(CommandManager); workspace = mock(WorkspaceService); - changeActiveTextEditorEventEmitter = new EventEmitter(); - customEditorService = mock(CustomEditorService); + customEditorService = typemoq.Mock.ofType(); + panel = typemoq.Mock.ofType(); + panel.setup(e => (e as any).then).returns(() => undefined); }); - function createNotebookProvider(shouldOpenNotebookEditor: boolean) { + function createNotebookProvider() { editor = typemoq.Mock.ofType(); + storage = typemoq.Mock.ofType(); when(configService.getSettings()).thenReturn({ datascience: { useNotebookEditor: true } } as any); - when(docManager.onDidChangeActiveTextEditor).thenReturn(changeActiveTextEditorEventEmitter.event); - when(docManager.visibleTextEditors).thenReturn([]); editor.setup(e => e.closed).returns(() => new EventEmitter().event); editor.setup(e => e.executed).returns(() => new EventEmitter().event); editor.setup(e => (e as any).then).returns(() => undefined); + storage.setup(e => (e as any).then).returns(() => undefined); + storage + .setup(s => s.load(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(f => { + storageFile = f; + return Promise.resolve(); + }); + storage.setup(s => s.file).returns(() => storageFile); when(svcContainer.get(INotebookEditor)).thenReturn(editor.object); + when(svcContainer.get(ILoadableNotebookStorage)).thenReturn(storage.object); + customEditorService.setup(e => (e as any).then).returns(() => undefined); + customEditorService + .setup(c => c.registerWebviewCustomEditorProvider(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns((_a1, _a2, _a3) => { + return { dispose: noop }; + }); + customEditorService + .setup(c => c.openEditor(typemoq.It.isAny())) + .returns(async f => { + return registeredProvider.resolveWebviewEditor(f, panel.object); + }); - // Ensure the editor is created and the load and show methods are invoked. - const invocationCount = shouldOpenNotebookEditor ? 1 : 0; editor .setup(e => e.load(typemoq.It.isAny(), typemoq.It.isAny())) - .returns((_a1: string, f: Uri) => { - file = f; + .returns((s, _p) => { + file = s.file; return Promise.resolve(); - }) - .verifiable(typemoq.Times.exactly(invocationCount)); - editor - .setup(e => e.show()) - .returns(() => Promise.resolve()) - .verifiable(typemoq.Times.exactly(invocationCount)); + }); + editor.setup(e => e.show()).returns(() => Promise.resolve()); editor.setup(e => e.file).returns(() => file); - return new NativeEditorProvider( + registeredProvider = new NativeEditorProvider( instance(svcContainer), instance(mock(AsyncDisposableRegistry)), [], instance(workspace), instance(configService), - instance(customEditorService) + customEditorService.object ); + + return registeredProvider; } + test('Opening a notebook', async () => { + const provider = createNotebookProvider(); + const n = await provider.open(Uri.file('foo.ipynb')); + expect(n.file.fsPath).to.be.include('foo.ipynb'); + }); + test('Multiple new notebooks have new names', async () => { - const provider = createNotebookProvider(false); + const provider = createNotebookProvider(); const n1 = await provider.createNew(); expect(n1.file.fsPath).to.be.include('Untitled-1'); const n2 = await provider.createNew(); diff --git a/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts b/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts index 1503acc86249..a6a1e3b335e0 100644 --- a/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts +++ b/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts @@ -62,7 +62,7 @@ class MockWorkspaceConfiguration implements WorkspaceConfiguration { } // tslint:disable: max-func-body-length -suite('Data Science - Native Editor', () => { +suite('Data Science - Native Editor Storage', () => { let workspace: IWorkspaceService; let configService: IConfigurationService; let fileSystem: typemoq.IMock; @@ -79,6 +79,7 @@ suite('Data Science - Native Editor', () => { let wroteToFileEvent: EventEmitter = new EventEmitter(); let filesConfig: MockWorkspaceConfiguration | undefined; let testIndex = 0; + let storage: NativeEditorStorage; const baseUri = Uri.parse('file:///foo.ipynb'); const baseFile = `{ "cells": [ @@ -324,33 +325,31 @@ suite('Data Science - Native Editor', () => { .returns(_a1 => { return Promise.resolve(lastWriteFileValue); }); - }); - teardown(() => { - globalMemento.clear(); - sinon.reset(); - }); - - function createStorage() { - return new NativeEditorStorage( + storage = new NativeEditorStorage( instance(executionProvider), fileSystem.object, // Use typemoq so can save values in returns instance(crypto), context.object, globalMemento, - globalMemento, + localMemento, cmdManager ); - } + }); + + teardown(() => { + globalMemento.clear(); + sinon.reset(); + NativeEditorStorage.unregister(); + }); function executeCommand(command: E, ...rest: U) { return cmdManager.executeCommand(command, ...rest); } test('Create new editor and add some cells', async () => { - const storage = createStorage(); await storage.load(baseUri); - executeCommand(Commands.NotebookStorage_InsertCell, baseUri, { index: 0, cell: createEmptyCell('1', 1), code: '1', codeCellAboveId: undefined }); + await executeCommand(Commands.NotebookStorage_InsertCell, baseUri, { index: 0, cell: createEmptyCell('1', 1), code: '1', codeCellAboveId: undefined }); const cells = await storage.getCells(); expect(cells).to.be.lengthOf(4); expect(storage.isDirty).to.be.equal(true, 'Editor should be dirty'); @@ -358,9 +357,8 @@ suite('Data Science - Native Editor', () => { }); test('Move cells around', async () => { - const storage = createStorage(); await storage.load(baseUri); - executeCommand(Commands.NotebookStorage_SwapCells, baseUri, { firstCellId: 'NotebookImport#0', secondCellId: 'NotebookImport#1' }); + await executeCommand(Commands.NotebookStorage_SwapCells, baseUri, { firstCellId: 'NotebookImport#0', secondCellId: 'NotebookImport#1' }); const cells = await storage.getCells(); expect(cells).to.be.lengthOf(3); expect(storage.isDirty).to.be.equal(true, 'Editor should be dirty'); @@ -368,10 +366,9 @@ suite('Data Science - Native Editor', () => { }); test('Edit/delete cells', async () => { - const storage = createStorage(); await storage.load(baseUri); expect(storage.isDirty).to.be.equal(false, 'Editor should not be dirty'); - executeCommand(Commands.NotebookStorage_EditCell, baseUri, { + await executeCommand(Commands.NotebookStorage_EditCell, baseUri, { changes: [ { range: { @@ -392,11 +389,11 @@ suite('Data Science - Native Editor', () => { expect(cells[1].id).to.be.match(/NotebookImport#1/); expect(cells[1].data.source).to.be.equals('b=2\nab'); expect(storage.isDirty).to.be.equal(true, 'Editor should be dirty'); - executeCommand(Commands.NotebookStorage_RemoveCell, baseUri, 'NotebookImport#0'); + await executeCommand(Commands.NotebookStorage_RemoveCell, baseUri, 'NotebookImport#0'); cells = await storage.getCells(); expect(cells).to.be.lengthOf(2); expect(cells[0].id).to.be.match(/NotebookImport#1/); - executeCommand(Commands.NotebookStorage_DeleteAllCells, baseUri); + await executeCommand(Commands.NotebookStorage_DeleteAllCells, baseUri); cells = await storage.getCells(); expect(cells).to.be.lengthOf(0); }); @@ -411,12 +408,11 @@ suite('Data Science - Native Editor', () => { expect(localMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; // Put the regular file into the local storage - localMemento.update(`notebook-storage-${file.toString()}`, differentFile); - const editor = createStorage(); - await editor.load(file); + await localMemento.update(`notebook-storage-${file.toString()}`, differentFile); + await storage.load(file); // It should load with that value - const cells = await editor.getCells(); + const cells = await storage.getCells(); expect(cells).to.be.lengthOf(2); }); @@ -430,12 +426,11 @@ suite('Data Science - Native Editor', () => { expect(localMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; // Put the regular file into the global storage - globalMemento.update(`notebook-storage-${file.toString()}`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); - const editor = createStorage(); - await editor.load(file); + await globalMemento.update(`notebook-storage-${file.toString()}`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); + await storage.load(file); // It should load with that value - const cells = await editor.getCells(); + const cells = await storage.getCells(); expect(cells).to.be.lengthOf(2); }); @@ -451,16 +446,15 @@ suite('Data Science - Native Editor', () => { expect(localMemento.get(`notebook-storage-${file.toString()}`)).to.be.undefined; // Put the regular file into the global storage - globalMemento.update(`notebook-storage-${file.toString()}`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); + await globalMemento.update(`notebook-storage-${file.toString()}`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); // Put another file into the global storage - globalMemento.update(`notebook-storage-file::///bar.ipynb`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); + await globalMemento.update(`notebook-storage-file::///bar.ipynb`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); - const editor = createStorage(); - await editor.load(file); + await storage.load(file); // It should load with that value - const cells = await editor.getCells(); + const cells = await storage.getCells(); expect(cells).to.be.lengthOf(2); // And global storage should be empty From 7b27fe7efa01e87e997e5496473255cfad0ab2ba Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Tue, 28 Jan 2020 15:28:23 -0800 Subject: [PATCH 11/18] Fix a number of functional tests --- .../interactive-common/interactiveBase.ts | 1 + .../interactive-ipynb/nativeEditor.ts | 4 ++ .../interactive-ipynb/nativeEditorProvider.ts | 7 +- .../interactive-ipynb/nativeEditorStorage.ts | 42 +++++++----- src/client/datascience/types.ts | 1 + .../datascience/dataScienceIocContainer.ts | 45 ++++++++----- .../nativeEditorStorage.unit.test.ts | 6 +- .../datascience/mockCustomEditorService.ts | 66 +++++++++++++++++++ .../nativeEditor.functional.test.tsx | 16 +++-- .../datascience/testNativeEditorProvider.ts | 3 + 10 files changed, 148 insertions(+), 43 deletions(-) create mode 100644 src/test/datascience/mockCustomEditorService.ts diff --git a/src/client/datascience/interactive-common/interactiveBase.ts b/src/client/datascience/interactive-common/interactiveBase.ts index 3db8736d1aaf..05b687e8af64 100644 --- a/src/client/datascience/interactive-common/interactiveBase.ts +++ b/src/client/datascience/interactive-common/interactiveBase.ts @@ -433,6 +433,7 @@ export abstract class InteractiveBase extends WebViewHost; protected async clearResult(id: string): Promise { + await this.ensureServerAndNotebook(); if (this._notebook) { this._notebook.clear(id); } diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index 0ccac57c0cd0..34142d7ab3e1 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -433,6 +433,10 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { if (dirty) { return this.postMessage(InteractiveWindowMessages.NotebookDirty); } else { + // Going clean should only happen on a save (for now. Undo might do this too) + this.savedEvent.fire(this); + + // Then tell the UI return this.postMessage(InteractiveWindowMessages.NotebookClean); } } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index 5b74336c5d78..7e1ec9d5281d 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -21,7 +21,11 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus public get onDidChangeActiveNotebookEditor(): Event { return this._onDidChangeActiveNotebookEditor.event; } + public get onDidCloseNotebookEditor(): Event { + return this._onDidCloseNotebookEditor.event; + } private readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); + private readonly _onDidCloseNotebookEditor = new EventEmitter(); private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: INotebookEdit }>(); private openedEditors: Set = new Set(); private storage: Map> = new Map>(); @@ -176,6 +180,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus private closedEditor(editor: INotebookEditor): void { this.openedEditors.delete(editor); + this._onDidCloseNotebookEditor.fire(editor); } private openedEditor(editor: INotebookEditor): void { @@ -185,7 +190,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } this.disposables.push(editor.onDidChangeViewState(this.onChangedViewState, this)); this.openedEditors.add(editor); - editor.closed.bind(this.closedEditor.bind(this)); + editor.closed(this.closedEditor.bind(this)); this._onDidOpenNotebookEditor.fire(editor); } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts index e594af6cdc6b..c6701dff04dd 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts @@ -9,7 +9,7 @@ import { createCodeCell } from '../../../datascience-ui/common/cellFactory'; import { ICommandManager } from '../../common/application/types'; import { traceError } from '../../common/logger'; import { IFileSystem } from '../../common/platform/types'; -import { GLOBAL_MEMENTO, ICryptoUtils, IExtensionContext, IMemento, WORKSPACE_MEMENTO } from '../../common/types'; +import { GLOBAL_MEMENTO, ICryptoUtils, IDisposable, IDisposableRegistry, IExtensionContext, IMemento, WORKSPACE_MEMENTO } from '../../common/types'; import { noop } from '../../common/utils/misc'; import { PythonInterpreter } from '../../interpreter/contracts'; import { Commands, Identifiers } from '../constants'; @@ -32,7 +32,7 @@ interface INativeEditorStorageState { } @injectable() -export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookStorage { +export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookStorage, IDisposable { public get isDirty(): boolean { return this._state.isDirty; } @@ -56,6 +56,7 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS private indentAmount: string = ' '; constructor( + @inject(IDisposableRegistry) disposables: IDisposableRegistry, @inject(IJupyterExecution) private jupyterExecution: IJupyterExecution, @inject(IFileSystem) private fileSystem: IFileSystem, @inject(ICryptoUtils) private crypto: ICryptoUtils, @@ -66,25 +67,27 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS ) { // Sign up for commands if this is the first storage created. if (!NativeEditorStorage.signedUpForCommands) { - NativeEditorStorage.registerCommands(cmdManager); + NativeEditorStorage.registerCommands(cmdManager, disposables); } + disposables.push(this); } - public static unregister(): void { - NativeEditorStorage.signedUpForCommands = false; - } - - private static registerCommands(commandManager: ICommandManager): void { + private static registerCommands(commandManager: ICommandManager, disposableRegistry: IDisposableRegistry): void { NativeEditorStorage.signedUpForCommands = true; - commandManager.registerCommand(Commands.NotebookStorage_ClearCellOutputs, NativeEditorStorage.handleClearAllOutputs); - commandManager.registerCommand(Commands.NotebookStorage_Close, NativeEditorStorage.handleClose); - commandManager.registerCommand(Commands.NotebookStorage_DeleteAllCells, NativeEditorStorage.handleDeleteAllCells); - commandManager.registerCommand(Commands.NotebookStorage_EditCell, NativeEditorStorage.handleEdit); - commandManager.registerCommand(Commands.NotebookStorage_InsertCell, NativeEditorStorage.handleInsert); - commandManager.registerCommand(Commands.NotebookStorage_ModifyCells, NativeEditorStorage.handleModifyCells); - commandManager.registerCommand(Commands.NotebookStorage_RemoveCell, NativeEditorStorage.handleRemoveCell); - commandManager.registerCommand(Commands.NotebookStorage_SwapCells, NativeEditorStorage.handleSwapCells); - commandManager.registerCommand(Commands.NotebookStorage_UpdateVersion, NativeEditorStorage.handleUpdateVersionInfo); + disposableRegistry.push({ + dispose: () => { + NativeEditorStorage.signedUpForCommands = false; + } + }); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_ClearCellOutputs, NativeEditorStorage.handleClearAllOutputs)); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_Close, NativeEditorStorage.handleClose)); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_DeleteAllCells, NativeEditorStorage.handleDeleteAllCells)); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_EditCell, NativeEditorStorage.handleEdit)); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_InsertCell, NativeEditorStorage.handleInsert)); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_ModifyCells, NativeEditorStorage.handleModifyCells)); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_RemoveCell, NativeEditorStorage.handleRemoveCell)); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_SwapCells, NativeEditorStorage.handleSwapCells)); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_UpdateVersion, NativeEditorStorage.handleUpdateVersionInfo)); } private static async getStorage(resource: Uri): Promise { @@ -220,6 +223,11 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS } }); } + + public dispose(): void { + NativeEditorStorage.storageMap.delete(this.file.toString()); + } + public async load(file: Uri, possibleContents?: string): Promise { // Reset the load promise and reload our cells this._loaded = false; diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index 60630fc96425..de88291a3c11 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -306,6 +306,7 @@ export interface INotebookEditorProvider { readonly editors: INotebookEditor[]; readonly onDidOpenNotebookEditor: Event; readonly onDidChangeActiveNotebookEditor: Event; + readonly onDidCloseNotebookEditor: Event; open(file: Uri): Promise; show(file: Uri): Promise; createNew(contents?: string): Promise; diff --git a/src/test/datascience/dataScienceIocContainer.ts b/src/test/datascience/dataScienceIocContainer.ts index 347dcafb11f4..2d6223e40fb7 100644 --- a/src/test/datascience/dataScienceIocContainer.ts +++ b/src/test/datascience/dataScienceIocContainer.ts @@ -54,6 +54,7 @@ import { TerminalManager } from '../../client/common/application/terminalManager import { IApplicationShell, ICommandManager, + ICustomEditorService, IDebugService, IDocumentManager, ILiveShareApi, @@ -148,6 +149,7 @@ import { GatherListener } from '../../client/datascience/gather/gatherListener'; import { IntellisenseProvider } from '../../client/datascience/interactive-common/intellisense/intellisenseProvider'; import { NativeEditor } from '../../client/datascience/interactive-ipynb/nativeEditor'; import { NativeEditorCommandListener } from '../../client/datascience/interactive-ipynb/nativeEditorCommandListener'; +import { NativeEditorStorage } from '../../client/datascience/interactive-ipynb/nativeEditorStorage'; import { InteractiveWindow } from '../../client/datascience/interactive-window/interactiveWindow'; import { InteractiveWindowCommandListener } from '../../client/datascience/interactive-window/interactiveWindowCommandListener'; import { JupyterCommandFactory } from '../../client/datascience/jupyter/interpreter/jupyterCommand'; @@ -204,6 +206,7 @@ import { IJupyterSessionManagerFactory, IJupyterSubCommandExecutionService, IJupyterVariables, + ILoadableNotebookStorage, INotebookEditor, INotebookEditorProvider, INotebookExecutionLogger, @@ -282,6 +285,7 @@ import { MockOutputChannel } from '../mockClasses'; import { MockAutoSelectionService } from '../mocks/autoSelector'; import { UnitTestIocContainer } from '../testing/serviceRegistry'; import { MockCommandManager } from './mockCommandManager'; +import { MockCustomEditorService } from './mockCustomEditorService'; import { MockDebuggerService } from './mockDebugService'; import { MockDocumentManager } from './mockDocumentManager'; import { MockExtensions } from './mockExtensions'; @@ -435,6 +439,8 @@ export class DataScienceIocContainer extends UnitTestIocContainer { this.serviceManager.addSingleton(IDebugLocationTracker, DebugLocationTrackerFactory); this.serviceManager.addSingleton(INotebookEditorProvider, TestNativeEditorProvider); this.serviceManager.add(INotebookEditor, NativeEditor); + this.serviceManager.add(ILoadableNotebookStorage, NativeEditorStorage); + this.serviceManager.addSingletonInstance(ICustomEditorService, new MockCustomEditorService(this.asyncRegistry, this.commandManager)); this.serviceManager.addSingleton(IDataScienceCommandListener, NativeEditorCommandListener); this.serviceManager.addSingletonInstance(IOutputChannel, mock(MockOutputChannel), JUPYTER_OUTPUT_CHANNEL); this.serviceManager.addSingleton(ICryptoUtils, CryptoUtils); @@ -763,6 +769,26 @@ export class DataScienceIocContainer extends UnitTestIocContainer { await Promise.all(activationServices.map(a => a.activate())); } + public createWebPanel(): IWebPanel { + const webPanel = TypeMoq.Mock.ofType(); + webPanel + .setup(p => p.postMessage(TypeMoq.It.isAny())) + .callback((m: WebPanelMessage) => { + const message = createMessageEvent(m); + if (this.postMessage) { + this.postMessage(message); + } else { + throw new Error('postMessage callback not defined'); + } + }); + webPanel.setup(p => p.show(TypeMoq.It.isAny())).returns(() => Promise.resolve()); + + // See https://github.com/florinn/typemoq/issues/67 for why this is necessary + webPanel.setup((p: any) => p.then).returns(() => undefined); + + return webPanel.object; + } + // tslint:disable:any public createWebView(mount: () => ReactWrapper, React.Component>, role: vsls.Role = vsls.Role.None) { // Force the container to mock actual live share if necessary @@ -777,7 +803,7 @@ export class DataScienceIocContainer extends UnitTestIocContainer { } else { this.webPanelProvider.reset(); } - const webPanel = TypeMoq.Mock.ofType(); + const webPanel = this.createWebPanel(); // Setup the webpanel provider so that it returns our dummy web panel. It will have to talk to our global JSDOM window so that the react components can link into it this.webPanelProvider @@ -802,23 +828,8 @@ export class DataScienceIocContainer extends UnitTestIocContainer { } // Return our dummy web panel - return Promise.resolve(webPanel.object); + return Promise.resolve(webPanel); }); - webPanel - .setup(p => p.postMessage(TypeMoq.It.isAny())) - .callback((m: WebPanelMessage) => { - const message = createMessageEvent(m); - if (this.postMessage) { - this.postMessage(message); - } else { - throw new Error('postMessage callback not defined'); - } - }); - webPanel.setup(p => p.show(TypeMoq.It.isAny())).returns(() => Promise.resolve()); - - // See https://github.com/florinn/typemoq/issues/67 for why this is necessary - webPanel.setup((p: any) => p.then).returns(() => undefined); - // We need to mount the react control before we even create an interactive window object. Otherwise the mount will miss rendering some parts this.mountReactControl(mount); } diff --git a/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts b/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts index a6a1e3b335e0..b0610d3c18bf 100644 --- a/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts +++ b/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts @@ -19,7 +19,7 @@ import { PythonSettings } from '../../../client/common/configSettings'; import { ConfigurationService } from '../../../client/common/configuration/service'; import { CryptoUtils } from '../../../client/common/crypto'; import { IFileSystem } from '../../../client/common/platform/types'; -import { IConfigurationService, ICryptoUtils, IExtensionContext } from '../../../client/common/types'; +import { IConfigurationService, ICryptoUtils, IDisposable, IExtensionContext } from '../../../client/common/types'; import { EXTENSION_ROOT_DIR } from '../../../client/constants'; import { Commands } from '../../../client/datascience/constants'; import { InteractiveWindowMessages } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; @@ -80,6 +80,7 @@ suite('Data Science - Native Editor Storage', () => { let filesConfig: MockWorkspaceConfiguration | undefined; let testIndex = 0; let storage: NativeEditorStorage; + const disposables: IDisposable[] = []; const baseUri = Uri.parse('file:///foo.ipynb'); const baseFile = `{ "cells": [ @@ -327,6 +328,7 @@ suite('Data Science - Native Editor Storage', () => { }); storage = new NativeEditorStorage( + disposables, instance(executionProvider), fileSystem.object, // Use typemoq so can save values in returns instance(crypto), @@ -340,7 +342,7 @@ suite('Data Science - Native Editor Storage', () => { teardown(() => { globalMemento.clear(); sinon.reset(); - NativeEditorStorage.unregister(); + disposables.forEach(d => d.dispose()); }); function executeCommand(command: E, ...rest: U) { diff --git a/src/test/datascience/mockCustomEditorService.ts b/src/test/datascience/mockCustomEditorService.ts new file mode 100644 index 000000000000..34c9b3d78a0e --- /dev/null +++ b/src/test/datascience/mockCustomEditorService.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { Disposable, Uri, WebviewCustomEditorEditingDelegate, WebviewCustomEditorProvider, WebviewPanel, WebviewPanelOptions } from 'vscode'; +import { ICommandManager, ICustomEditorService } from '../../client/common/application/types'; +import { IDisposableRegistry } from '../../client/common/types'; +import { noop } from '../../client/common/utils/misc'; +import { INotebookEdit, INotebookEditor, INotebookEditorProvider } from '../../client/datascience/types'; + +export class MockCustomEditorService implements ICustomEditorService { + private provider: WebviewCustomEditorProvider | undefined; + private resolvedList = new Map>(); + + constructor(disposableRegistry: IDisposableRegistry, commandManager: ICommandManager) { + disposableRegistry.push(commandManager.registerCommand('workbench.action.files.save', this.onFileSave.bind(this))); + disposableRegistry.push(commandManager.registerCommand('workbench.action.files.saveAs', this.onFileSaveAs.bind(this))); + } + + public get supportsCustomEditors(): boolean { + return true; + } + public registerWebviewCustomEditorProvider(_viewType: string, provider: WebviewCustomEditorProvider, _options?: WebviewPanelOptions | undefined): Disposable { + // Only support one view type, so just save the provider + this.provider = provider; + + // Sign up for close so we can clear our resolved map + // tslint:disable-next-line: no-any + ((this.provider as any) as INotebookEditorProvider).onDidCloseNotebookEditor(this.closedEditor.bind(this)); + + return { dispose: noop }; + } + public openEditor(file: Uri): Thenable { + if (!this.provider) { + throw new Error('Opening before registering'); + } + + // Make sure not to resolve more than once for the same file. At least in testing. + let resolved = this.resolvedList.get(file.toString()); + if (!resolved) { + // Pass undefined as the webview panel. This will make the editor create a new one + // tslint:disable-next-line: no-any + resolved = this.provider.resolveWebviewEditor(file, (undefined as any) as WebviewPanel); + this.resolvedList.set(file.toString(), resolved); + } + + return resolved; + } + + private onFileSave(file: Uri) { + const nativeProvider = (this.provider as unknown) as WebviewCustomEditorEditingDelegate; + if (nativeProvider) { + nativeProvider.save(file); + } + } + + private onFileSaveAs(file: Uri) { + const nativeProvider = (this.provider as unknown) as WebviewCustomEditorEditingDelegate; + if (nativeProvider) { + // Just make up a new URI + nativeProvider.saveAs(file, Uri.file('bar.ipynb')); + } + } + + private closedEditor(editor: INotebookEditor) { + this.resolvedList.delete(editor.file.toString()); + } +} diff --git a/src/test/datascience/nativeEditor.functional.test.tsx b/src/test/datascience/nativeEditor.functional.test.tsx index 24c1b57dc0a5..5efe96fe02f9 100644 --- a/src/test/datascience/nativeEditor.functional.test.tsx +++ b/src/test/datascience/nativeEditor.functional.test.tsx @@ -369,7 +369,6 @@ for _ in range(50): return; } }; - let saveCalled = false; const appShell = TypeMoq.Mock.ofType(); appShell .setup(a => a.showErrorMessage(TypeMoq.It.isAnyString())) @@ -380,20 +379,23 @@ for _ in range(50): appShell .setup(a => a.showSaveDialog(TypeMoq.It.isAny())) .returns(() => { - saveCalled = true; return Promise.resolve(undefined); }); appShell.setup(a => a.setStatusBarMessage(TypeMoq.It.isAny())).returns(() => dummyDisposable); ioc.serviceManager.rebindInstance(IApplicationShell, appShell.object); // Make sure to create the interactive window after the rebind or it gets the wrong application shell. - await createNewEditor(ioc); + const saveCalled = createDeferred(); + const editor = await createNewEditor(ioc); + editor.saved(() => { + saveCalled.resolve(true); + }); await addCell(wrapper, ioc, 'a=1\na'); // Export should cause exportCalled to change to true const saveButton = findButton(wrapper, NativeEditor, 8); await waitForMessageResponse(ioc, () => saveButton!.simulate('click')); - assert.equal(saveCalled, true, 'Save should have been called'); + await waitForPromise(saveCalled.promise, 5_000); // Click export and wait for a document to change const activeTextEditorChange = createDeferred(); @@ -473,9 +475,10 @@ for _ in range(50): let editor = await openEditor(ioc, JSON.stringify(notebook)); // Run everything + let renderAll = waitForMessage(ioc, InteractiveWindowMessages.ExecutionRendered, { numberOfTimes: 3 }); let runAllButton = findButton(wrapper, NativeEditor, 0); await waitForMessageResponse(ioc, () => runAllButton!.simulate('click')); - await waitForUpdate(wrapper, NativeEditor, 15); + await renderAll; // Close editor. Should still have the server up await closeNotebook(editor, wrapper); @@ -489,8 +492,9 @@ for _ in range(50): assert.ok(newWrapper, 'Could not mount a second time'); editor = await openEditor(ioc, JSON.stringify(notebook)); runAllButton = findButton(newWrapper!, NativeEditor, 0); + renderAll = waitForMessage(ioc, InteractiveWindowMessages.ExecutionRendered, { numberOfTimes: 3 }); await waitForMessageResponse(ioc, () => runAllButton!.simulate('click')); - await waitForUpdate(newWrapper!, NativeEditor, 15); + await renderAll; verifyHtmlOnCell(newWrapper!, 'NativeCell', `1`, 0); }, () => { diff --git a/src/test/datascience/testNativeEditorProvider.ts b/src/test/datascience/testNativeEditorProvider.ts index 66c5e8631976..0401309a273d 100644 --- a/src/test/datascience/testNativeEditorProvider.ts +++ b/src/test/datascience/testNativeEditorProvider.ts @@ -18,6 +18,9 @@ export class TestNativeEditorProvider implements INotebookEditorProvider { public get onDidChangeActiveNotebookEditor() { return this.realProvider.onDidChangeActiveNotebookEditor; } + public get onDidCloseNotebookEditor() { + return this.realProvider.onDidCloseNotebookEditor; + } private realProvider: NativeEditorProvider; public get onDidOpenNotebookEditor(): Event { return this.realProvider.onDidOpenNotebookEditor; From 5f1faf7e6233d60a1b321ea73ea8e2f47aa5619e Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Tue, 28 Jan 2020 15:45:11 -0800 Subject: [PATCH 12/18] REmove autoSave tests as not needed anymore --- .../nativeEditor.functional.test.tsx | 212 ------------------ 1 file changed, 212 deletions(-) diff --git a/src/test/datascience/nativeEditor.functional.test.tsx b/src/test/datascience/nativeEditor.functional.test.tsx index 5efe96fe02f9..dbeb14f89edf 100644 --- a/src/test/datascience/nativeEditor.functional.test.tsx +++ b/src/test/datascience/nativeEditor.functional.test.tsx @@ -1348,218 +1348,6 @@ for _ in range(50): }); }); - suite('Auto Save', () => { - let windowStateChangeHandlers: ((e: WindowState) => any)[] = []; - setup(async function() { - initIoc(); - - windowStateChangeHandlers = []; - // Keep track of all handlers for the onDidChangeWindowState event. - ioc.applicationShell.setup(app => app.onDidChangeWindowState(TypeMoq.It.isAny())).callback(cb => windowStateChangeHandlers.push(cb)); - - // tslint:disable-next-line: no-invalid-this - await setupFunction.call(this); - }); - teardown(() => sinon.restore()); - - /** - * Make some kind of a change to the notebook. - * - * @param {number} cellIndex - */ - async function modifyNotebook() { - // (Add a cell into the UI) - await addCell(wrapper, ioc, 'a', false); - } - - test('Auto save notebook every 1s', async () => { - // Configure notebook to save automatically ever 1s. - when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('afterDelay'); - when(ioc.mockedWorkspaceConfig.get('autoSaveDelay', anything())).thenReturn(1_000); - ioc.forceSettingsChanged(ioc.getSettings().pythonPath); - - /** - * Make some changes to a cell of a notebook, then verify the notebook is auto saved. - * - * @param {number} cellIndex - */ - async function makeChangesAndConfirmFileIsUpdated() { - const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); - const dirtyPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookDirty); - const cleanPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookClean); - - await modifyNotebook(); - await dirtyPromise; - - // At this point a message should be sent to extension asking it to save. - // After the save, the extension should send a message to react letting it know that it was saved successfully. - await cleanPromise; - - // Confirm file has been updated as well. - const newFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); - assert.notEqual(newFileContents, notebookFileContents); - } - - // Make changes & validate (try a couple of times). - await makeChangesAndConfirmFileIsUpdated(); - await makeChangesAndConfirmFileIsUpdated(); - await makeChangesAndConfirmFileIsUpdated(); - }); - - test('File saved with same format', async () => { - // Configure notebook to save automatically ever 1s. - when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('afterDelay'); - when(ioc.mockedWorkspaceConfig.get('autoSaveDelay', anything())).thenReturn(2_000); - ioc.forceSettingsChanged(ioc.getSettings().pythonPath); - const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); - const dirtyPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookDirty); - const cleanPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookClean); - - await modifyNotebook(); - await dirtyPromise; - - // At this point a message should be sent to extension asking it to save. - // After the save, the extension should send a message to react letting it know that it was saved successfully. - await cleanPromise; - - // Confirm file is not the same. There should be a single cell that's been added - const newFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); - assert.notEqual(newFileContents, notebookFileContents); - assert.equal(newFileContents, addedJSONFile); - }); - - test('Should not auto save notebook, ever', async () => { - const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); - - // Configure notebook to to never save. - when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('off'); - when(ioc.mockedWorkspaceConfig.get('autoSaveDelay', anything())).thenReturn(1000); - // Update the settings and wait for the component to receive it and process it. - const promise = waitForMessage(ioc, InteractiveWindowMessages.SettingsUpdated); - ioc.forceSettingsChanged(ioc.getSettings().pythonPath, { ...defaultDataScienceSettings(), showCellInputCode: false }); - await promise; - - const dirtyPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookDirty); - const cleanPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookClean, { timeoutMs: 5_000 }); - - await modifyNotebook(); - await dirtyPromise; - - // Now that the notebook is dirty, change the active editor. - const docManager = ioc.get(IDocumentManager) as MockDocumentManager; - docManager.didChangeActiveTextEditorEmitter.fire(); - // Also, send notification about changes to window state. - windowStateChangeHandlers.forEach(item => item({ focused: false })); - windowStateChangeHandlers.forEach(item => item({ focused: true })); - - // Confirm the message is not clean, trying to wait for it to get saved will timeout (i.e. rejected). - await expect(cleanPromise).to.eventually.be.rejected; - // Confirm file has not been updated as well. - assert.equal(await fs.readFile(notebookFile.filePath, 'utf8'), notebookFileContents); - }).timeout(10_000); - - async function testAutoSavingWhenEditorFocusChanges(newEditor: TextEditor | undefined) { - const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); - const dirtyPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookDirty); - const cleanPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookClean); - - await modifyNotebook(); - await dirtyPromise; - - // Configure notebook to save when active editor changes. - when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('onFocusChange'); - ioc.forceSettingsChanged(ioc.getSettings().pythonPath); - - // Now that the notebook is dirty, change the active editor. - const docManager = ioc.get(IDocumentManager) as MockDocumentManager; - docManager.didChangeActiveTextEditorEmitter.fire(newEditor); - - // At this point a message should be sent to extension asking it to save. - // After the save, the extension should send a message to react letting it know that it was saved successfully. - await cleanPromise; - - // Confirm file has been updated as well. - assert.notEqual(await fs.readFile(notebookFile.filePath, 'utf8'), notebookFileContents); - } - - test('Auto save notebook when focus changes from active editor to none', () => testAutoSavingWhenEditorFocusChanges(undefined)); - - test('Auto save notebook when focus changes from active editor to something else', () => - testAutoSavingWhenEditorFocusChanges(TypeMoq.Mock.ofType().object)); - - test('Should not auto save notebook when active editor changes', async () => { - const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); - const dirtyPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookDirty); - const cleanPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookClean, { timeoutMs: 5_000 }); - - await modifyNotebook(); - await dirtyPromise; - - // Configure notebook to save when window state changes. - when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('onWindowChange'); - ioc.forceSettingsChanged(ioc.getSettings().pythonPath); - - // Now that the notebook is dirty, change the active editor. - // This should not trigger a save of notebook (as its configured to save only when window state changes). - const docManager = ioc.get(IDocumentManager) as MockDocumentManager; - docManager.didChangeActiveTextEditorEmitter.fire(); - - // Confirm the message is not clean, trying to wait for it to get saved will timeout (i.e. rejected). - await expect(cleanPromise).to.eventually.be.rejected; - // Confirm file has not been updated as well. - assert.equal(await fs.readFile(notebookFile.filePath, 'utf8'), notebookFileContents); - }).timeout(10_000); - - async function testAutoSavingWithChangesToWindowState(focused: boolean) { - const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); - const dirtyPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookDirty); - const cleanPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookClean); - - await modifyNotebook(); - await dirtyPromise; - - // Configure notebook to save when active editor changes. - when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('onWindowChange'); - ioc.forceSettingsChanged(ioc.getSettings().pythonPath); - - // Now that the notebook is dirty, send notification about changes to window state. - windowStateChangeHandlers.forEach(item => item({ focused })); - - // At this point a message should be sent to extension asking it to save. - // After the save, the extension should send a message to react letting it know that it was saved successfully. - await cleanPromise; - - // Confirm file has been updated as well. - assert.notEqual(await fs.readFile(notebookFile.filePath, 'utf8'), notebookFileContents); - } - - test('Auto save notebook when window state changes to being not focused', async () => testAutoSavingWithChangesToWindowState(false)); - test('Auto save notebook when window state changes to being focused', async () => testAutoSavingWithChangesToWindowState(true)); - - test('Should not auto save notebook when window state changes', async () => { - const notebookFileContents = await fs.readFile(notebookFile.filePath, 'utf8'); - const dirtyPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookDirty); - const cleanPromise = waitForMessage(ioc, InteractiveWindowMessages.NotebookClean, { timeoutMs: 5_000 }); - - await modifyNotebook(); - await dirtyPromise; - - // Configure notebook to save when active editor changes. - when(ioc.mockedWorkspaceConfig.get('autoSave', 'off')).thenReturn('onFocusChange'); - ioc.forceSettingsChanged(ioc.getSettings().pythonPath); - - // Now that the notebook is dirty, change window state. - // This should not trigger a save of notebook (as its configured to save only when focus is changed). - windowStateChangeHandlers.forEach(item => item({ focused: false })); - windowStateChangeHandlers.forEach(item => item({ focused: true })); - - // Confirm the message is not clean, trying to wait for it to get saved will timeout (i.e. rejected). - await expect(cleanPromise).to.eventually.be.rejected; - // Confirm file has not been updated as well. - assert.equal(await fs.readFile(notebookFile.filePath, 'utf8'), notebookFileContents); - }).timeout(10_000); - }); - suite('Update Metadata', () => { setup(async function() { initIoc(); From f0bb9cd99da41f686a8a9ab48048b9dbed8b7a2d Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Tue, 28 Jan 2020 15:47:44 -0800 Subject: [PATCH 13/18] Add news entry --- news/1 Enhancements/9255.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/1 Enhancements/9255.md diff --git a/news/1 Enhancements/9255.md b/news/1 Enhancements/9255.md new file mode 100644 index 000000000000..be69ac64944d --- /dev/null +++ b/news/1 Enhancements/9255.md @@ -0,0 +1 @@ +Implement VS code's custom editor for opening notebooks. \ No newline at end of file From 24c03fd7a2c8c540e820ddb3d5de08df822fde1b Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Wed, 29 Jan 2020 08:42:23 -0800 Subject: [PATCH 14/18] Review comments --- src/client/common/application/customEditorService.ts | 4 ++-- src/client/common/application/types.ts | 2 +- .../interactive-ipynb/nativeEditorProvider.ts | 12 ++++++------ src/test/datascience/mockCustomEditorService.ts | 4 ++-- .../datascience/nativeEditor.functional.test.tsx | 5 +---- 5 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/client/common/application/customEditorService.ts b/src/client/common/application/customEditorService.ts index 97d369de9f4c..4960cd84e1d9 100644 --- a/src/client/common/application/customEditorService.ts +++ b/src/client/common/application/customEditorService.ts @@ -22,7 +22,7 @@ export class CustomEditorService implements ICustomEditorService { return vscode.window.registerWebviewCustomEditorProvider(viewType, provider, options); } - public openEditor(file: vscode.Uri): Thenable { - return this.commandManager.executeCommand('vscode.open', file); + public async openEditor(file: vscode.Uri): Promise { + await this.commandManager.executeCommand('vscode.open', file); } } diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 57d36ff31b92..407456fb3cb5 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -1067,5 +1067,5 @@ export interface ICustomEditorService { /** * Opens a file with a custom editor */ - openEditor(file: Uri): Thenable; + openEditor(file: Uri): Promise; } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index 7e1ec9d5281d..1da8f58fdf1b 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -58,14 +58,14 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } public save(resource: Uri): Thenable { - return this.getStorage(resource).then(async s => { + return this.loadStorage(resource).then(async s => { if (s) { await s.save(); } }); } public saveAs(resource: Uri, targetResource: Uri): Thenable { - return this.getStorage(resource).then(async s => { + return this.loadStorage(resource).then(async s => { if (s) { await s.saveAs(targetResource); } @@ -82,7 +82,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } public async resolveWebviewEditor(resource: Uri, panel: WebviewPanel) { // Get the storage - const storage = await this.getStorage(resource); + const storage = await this.loadStorage(resource); // Create a new editor const editor = this.serviceContainer.get(INotebookEditor); @@ -136,7 +136,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus disposable = this._onDidOpenNotebookEditor.event(handler); // Send an open command. - this.customEditorService.openEditor(file); + this.customEditorService.openEditor(file).ignoreErrors(); // Promise should resolve when the file opens. return deferred.promise; @@ -155,7 +155,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus this.notebookCount += 1; // Set these contents into the storage before the file opens - await this.getStorage(uri, contents); + await this.loadStorage(uri, contents); return this.open(uri); } @@ -216,7 +216,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } } - private getStorage(file: Uri, contents?: string): Promise { + private loadStorage(file: Uri, contents?: string): Promise { const key = file.toString(); let storagePromise = this.storage.get(key); if (!storagePromise) { diff --git a/src/test/datascience/mockCustomEditorService.ts b/src/test/datascience/mockCustomEditorService.ts index 34c9b3d78a0e..6d40f9da442c 100644 --- a/src/test/datascience/mockCustomEditorService.ts +++ b/src/test/datascience/mockCustomEditorService.ts @@ -28,7 +28,7 @@ export class MockCustomEditorService implements ICustomEditorService { return { dispose: noop }; } - public openEditor(file: Uri): Thenable { + public async openEditor(file: Uri): Promise { if (!this.provider) { throw new Error('Opening before registering'); } @@ -42,7 +42,7 @@ export class MockCustomEditorService implements ICustomEditorService { this.resolvedList.set(file.toString(), resolved); } - return resolved; + await resolved; } private onFileSave(file: Uri) { diff --git a/src/test/datascience/nativeEditor.functional.test.tsx b/src/test/datascience/nativeEditor.functional.test.tsx index dbeb14f89edf..03c309e68d20 100644 --- a/src/test/datascience/nativeEditor.functional.test.tsx +++ b/src/test/datascience/nativeEditor.functional.test.tsx @@ -9,9 +9,8 @@ import { EventEmitter } from 'events'; import * as fs from 'fs-extra'; import * as path from 'path'; import * as sinon from 'sinon'; -import { anything, when } from 'ts-mockito'; import * as TypeMoq from 'typemoq'; -import { Disposable, TextDocument, TextEditor, Uri, WindowState } from 'vscode'; +import { Disposable, TextDocument, TextEditor, Uri } from 'vscode'; import { IApplicationShell, IDocumentManager } from '../../client/common/application/types'; import { IFileSystem } from '../../client/common/platform/types'; import { createDeferred, sleep, waitForPromise } from '../../client/common/utils/async'; @@ -31,7 +30,6 @@ import { IMonacoEditorState, MonacoEditor } from '../../datascience-ui/react-com import { waitForCondition } from '../common'; import { createTemporaryFile } from '../utils/fs'; import { DataScienceIocContainer } from './dataScienceIocContainer'; -import { defaultDataScienceSettings } from './helpers'; import { MockDocumentManager } from './mockDocumentManager'; import { addCell, closeNotebook, createNewEditor, getNativeCellResults, mountNativeWebView, openEditor, runMountedTest, setupWebview } from './nativeEditorTestHelpers'; import { waitForUpdate } from './reactHelpers'; @@ -657,7 +655,6 @@ for _ in range(50): outputs: [], source: [] }); - const addedJSONFile = JSON.stringify(addedJSON, null, ' '); let notebookFile: { filePath: string; From d0a39734a3c7f70a2269cfc1952c7cf7b7474513 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Wed, 29 Jan 2020 11:29:36 -0800 Subject: [PATCH 15/18] Code review comments --- package.json | 2 +- src/client/common/application/commands.ts | 1 - src/client/datascience/constants.ts | 4 +- .../interactive-ipynb/nativeEditor.ts | 42 ++- .../interactive-ipynb/nativeEditorProvider.ts | 69 +++-- .../interactive-ipynb/nativeEditorStorage.ts | 278 +++++++++--------- src/client/datascience/serviceRegistry.ts | 4 +- src/client/datascience/types.ts | 22 +- src/client/telemetry/index.ts | 5 + .../datascience/dataScienceIocContainer.ts | 4 +- .../nativeEditorProvider.unit.test.ts | 10 +- .../nativeEditorStorage.unit.test.ts | 16 +- 12 files changed, 233 insertions(+), 224 deletions(-) diff --git a/package.json b/package.json index ac9f93654e5c..28de1d8c4145 100644 --- a/package.json +++ b/package.json @@ -2773,7 +2773,7 @@ "webviewEditors": [ { "viewType": "NativeEditorProvider.ipynb", - "displayName": "Jupyter Notebook Editor", + "displayName": "Jupyter Notebook", "selector": [ { "filenamePattern": "*.ipynb" diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index 6b1af336ce4e..d12d54546284 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -149,7 +149,6 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu [DSCommands.ViewJupyterOutput]: []; [DSCommands.SwitchJupyterKernel]: [INotebook | undefined]; [DSCommands.NotebookStorage_DeleteAllCells]: [Uri]; - [DSCommands.NotebookStorage_Close]: [Uri]; [DSCommands.NotebookStorage_ModifyCells]: [Uri, ICell[]]; [DSCommands.NotebookStorage_EditCell]: [Uri, IEditCell]; [DSCommands.NotebookStorage_InsertCell]: [Uri, IInsertCell]; diff --git a/src/client/datascience/constants.ts b/src/client/datascience/constants.ts index d4f93b1a7a2b..23117fff8311 100644 --- a/src/client/datascience/constants.ts +++ b/src/client/datascience/constants.ts @@ -65,7 +65,6 @@ export namespace Commands { // Make sure to put these into the package .json export const NotebookStorage_DeleteAllCells = 'python.datascience.notebook.deleteall'; - export const NotebookStorage_Close = 'python.datascience.notebook.close'; export const NotebookStorage_ModifyCells = 'python.datascience.notebook.modifycells'; export const NotebookStorage_EditCell = 'python.datascience.notebook.editcell'; export const NotebookStorage_InsertCell = 'python.datascience.notebook.insertcell'; @@ -249,7 +248,8 @@ export enum Telemetry { UserInstalledJupyter = 'DATASCIENCE.USER_INSTALLED_JUPYTER', UserDidNotInstallJupyter = 'DATASCIENCE.USER_DID_NOT_INSTALL_JUPYTER', OpenedInteractiveWindow = 'DATASCIENCE.OPENED_INTERACTIVE', - FindKernelForLocalConnection = 'DATASCIENCE.FIND_KERNEL_FOR_LOCAL_CONNECTION' + FindKernelForLocalConnection = 'DATASCIENCE.FIND_KERNEL_FOR_LOCAL_CONNECTION', + OpenNotebookFailure = 'DS_INTERNAL.NATIVE.OPEN_NOTEBOOK_FAILURE' } export enum NativeKeyboardCommandTelemetry { diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index 34142d7ab3e1..f3d9cccac58b 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -48,9 +48,9 @@ import { INotebookEditorProvider, INotebookExporter, INotebookImporter, + INotebookModel, + INotebookModelChange, INotebookServerOptions, - INotebookStorage, - INotebookStorageChange, IStatusProvider, IThemeFinder } from '../types'; @@ -69,7 +69,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { private loadedPromise: Deferred = createDeferred(); private startupTimer: StopWatch = new StopWatch(); private loadedAllCells: boolean = false; - private _storage: INotebookStorage | undefined; + private _model: INotebookModel | undefined; constructor( @multiInject(IInteractiveWindowListener) listeners: IInteractiveWindowListener[], @@ -136,23 +136,23 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } public get file(): Uri { - if (this._storage) { - return this._storage.file; + if (this._model) { + return this._model.file; } return Uri.file(''); } public get isUntitled(): boolean { - return this._storage ? this._storage.isUntitled : false; + return this._model ? this._model.isUntitled : false; } public dispose(): Promise { super.dispose(); return this.close(); } - public async load(storage: INotebookStorage, webViewPanel: WebviewPanel): Promise { - // Save the storage we're using - this._storage = storage; + public async load(model: INotebookModel, webViewPanel: WebviewPanel): Promise { + // Save the model we're using + this._model = model; // Indicate we have our identity this.loadedPromise.resolve(); @@ -162,13 +162,13 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { await super.loadWebPanel(path.dirname(this.file.fsPath), webViewPanel); // Sign up for dirty events - storage.changed(this.contentsChanged.bind(this)); + model.changed(this.modelChanged.bind(this)); // Load our cells, but don't wait for this to finish, otherwise the window won't load. - this.loadCells(await storage.getCells()) + this.sendInitialCellsToWebView(model.cells) .then(() => { // May alread be dirty, if so send a message - if (storage.isDirty) { + if (model.isDirty) { this.postMessage(InteractiveWindowMessages.NotebookDirty).ignoreErrors(); } }) @@ -192,7 +192,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } public get isDirty(): boolean { - return this._storage ? this._storage.isDirty : false; + return this._model ? this._model.isDirty : false; } // tslint:disable-next-line: no-any @@ -251,8 +251,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { public async getNotebookOptions(): Promise { const options = await this.editorProvider.getNotebookOptions(); - if (this._storage) { - const metadata = (await this._storage.getJson()).metadata; + if (this._model) { + const metadata = (await this._model.getJson()).metadata; return { ...options, metadata @@ -426,11 +426,10 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Actually don't close, just let the error bubble out } - private contentsChanged(change: INotebookStorageChange) { + private modelChanged(change: INotebookModelChange) { if (change.isDirty !== undefined) { this.modifiedEvent.fire(); - const dirty = !this._storage || this._storage.isDirty; - if (dirty) { + if (change.model.isDirty) { return this.postMessage(InteractiveWindowMessages.NotebookDirty); } else { // Going clean should only happen on a save (for now. Undo might do this too) @@ -442,15 +441,12 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } } - private async loadCells(cells: ICell[]): Promise { + private async sendInitialCellsToWebView(cells: ICell[]): Promise { sendTelemetryEvent(Telemetry.CellCount, undefined, { count: cells.length }); return this.postMessage(InteractiveWindowMessages.LoadAllCells, { cells }); } private async close(): Promise { - // Send a close command to our model - await this.commandManager.executeCommand(Commands.NotebookStorage_Close, this.file); - // Fire our event this.closedEvent.fire(this); @@ -493,7 +489,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { tempFile = await this.fileSystem.createTemporaryFile('.ipynb'); // Translate the cells into a notebook - const content = this._storage ? await this._storage.getContent(cells) : ''; + const content = this._model ? await this._model.getContent(cells) : ''; await this.fileSystem.writeFile(tempFile.filePath, content, 'utf-8'); // Import this file and show it diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index 1da8f58fdf1b..0325d2453985 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -13,10 +13,13 @@ import * as localize from '../../common/utils/localize'; import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { Identifiers, Settings, Telemetry } from '../constants'; -import { ILoadableNotebookStorage, INotebookEdit, INotebookEditor, INotebookEditorProvider, INotebookServerOptions, INotebookStorageChange } from '../types'; +import { INotebookEdit, INotebookEditor, INotebookEditorProvider, INotebookModel, INotebookModelChange, INotebookServerOptions, INotebookStorage } from '../types'; +// 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, WebviewCustomEditorProvider, WebviewCustomEditorEditingDelegate, IAsyncDisposable { + // Note, this constant has to match the value used in the package.json to register the webview custom editor. public static readonly customEditorViewType = 'NativeEditorProvider.ipynb'; public get onDidChangeActiveNotebookEditor(): Event { return this._onDidChangeActiveNotebookEditor.event; @@ -28,8 +31,8 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus private readonly _onDidCloseNotebookEditor = new EventEmitter(); private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: INotebookEdit }>(); private openedEditors: Set = new Set(); - private storage: Map> = new Map>(); - private storageChangedHandlers: Map = new Map(); + private models = new Map>(); + private modelChangedHandlers: Map = new Map(); private _onDidOpenNotebookEditor = new EventEmitter(); private executedEditors: Set = new Set(); private notebookCount: number = 0; @@ -81,14 +84,19 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus return Promise.resolve(); } public async resolveWebviewEditor(resource: Uri, panel: WebviewPanel) { - // Get the storage - const storage = await this.loadStorage(resource); + try { + // Get the model + const model = await this.loadModel(resource); - // Create a new editor - const editor = this.serviceContainer.get(INotebookEditor); + // Create a new editor + const editor = this.serviceContainer.get(INotebookEditor); - // Load it (should already be visible) - return editor.load(storage, panel).then(() => this.openedEditor(editor)); + // Load it (should already be visible) + return editor.load(model, panel).then(() => this.openedEditor(editor)); + } catch (exc) { + // Send telemetry indicating a failure + sendTelemetryEvent(Telemetry.OpenNotebookFailure, { message: exc.message }); + } } public get editingDelegate(): WebviewCustomEditorEditingDelegate | undefined { return this; @@ -204,11 +212,12 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } } - private async storageChanged(file: Uri, change: INotebookStorageChange): Promise { + private async modelChanged(file: Uri, change: INotebookModelChange): Promise { // If the file changes, update our storage - if (change.oldFile && change.newFile) { - this.storage.delete(change.oldFile.toString()); - this.storage.set(change.newFile.toString(), Promise.resolve(change.storage as ILoadableNotebookStorage)); + if (change.oldFile && change.newFile && this.models.has(change.oldFile.toString())) { + const promise = this.models.get(change.oldFile.toString()); + this.models.delete(change.oldFile.toString()); + this.models.set(change.newFile.toString(), promise!); } // If the cells change, tell VS code about it if (change.newCells && change.isDirty) { @@ -216,26 +225,36 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } } - private loadStorage(file: Uri, contents?: string): Promise { + private async loadModel(file: Uri, contents?: string): Promise { + const modelAndStorage = await this.loadModelAndStorage(file, contents); + return modelAndStorage.model; + } + + private async loadStorage(file: Uri, contents?: string): Promise { + const modelAndStorage = await this.loadModelAndStorage(file, contents); + return modelAndStorage.storage; + } + + private loadModelAndStorage(file: Uri, contents?: string) { const key = file.toString(); - let storagePromise = this.storage.get(key); - if (!storagePromise) { - const storage = this.serviceContainer.get(ILoadableNotebookStorage); - if (!this.storageChangedHandlers.has(key)) { - this.storageChangedHandlers.set(key, storage.changed(this.storageChanged.bind(this, file))); - } - storagePromise = storage.load(file, contents).then(_v => { - return storage; + let modelPromise = this.models.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, file))); + } + return { model: m, storage }; }); - this.storage.set(key, storagePromise); + this.models.set(key, modelPromise); } - return storagePromise; + return modelPromise; } private async getNextNewNotebookUri(): Promise { // See if we have any untitled storage already - const untitledStorage = [...this.storage.keys()].filter(k => Uri.parse(k).scheme === 'untitled'); + const untitledStorage = [...this.models.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/nativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts index c6701dff04dd..887adb7e5039 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts @@ -16,7 +16,7 @@ import { Commands, Identifiers } from '../constants'; import { IEditCell, IInsertCell, ISwapCells } from '../interactive-common/interactiveWindowTypes'; import { InvalidNotebookFileError } from '../jupyter/invalidNotebookFileError'; import { LiveKernelModel } from '../jupyter/kernels/types'; -import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, ILoadableNotebookStorage, INotebookStorage, INotebookStorageChange } from '../types'; +import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, INotebookModel, INotebookModelChange, INotebookStorage } from '../types'; // tslint:disable-next-line:no-require-imports no-var-requires import detectIndent = require('detect-indent'); @@ -32,11 +32,11 @@ interface INativeEditorStorageState { } @injectable() -export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookStorage, IDisposable { +export class NativeEditorStorage implements INotebookModel, INotebookStorage, IDisposable { public get isDirty(): boolean { return this._state.isDirty; } - public get changed(): Event { + public get changed(): Event { return this._changedEmitter.event; } public get file(): Uri { @@ -46,13 +46,15 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS public get isUntitled(): boolean { return this.file.scheme === 'untitled'; } + public get cells(): ICell[] { + return this._state.cells; + } private static signedUpForCommands = false; private static storageMap = new Map(); - private _changedEmitter = new EventEmitter(); + private _changedEmitter = new EventEmitter(); private _state: INativeEditorStorageState = { file: Uri.file(''), isDirty: false, cells: [], notebookJson: {} }; private _loadPromise: Promise | undefined; - private _loaded = false; private indentAmount: string = ' '; constructor( @@ -67,29 +69,11 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS ) { // Sign up for commands if this is the first storage created. if (!NativeEditorStorage.signedUpForCommands) { - NativeEditorStorage.registerCommands(cmdManager, disposables); + this.registerCommands(cmdManager, disposables); } disposables.push(this); } - private static registerCommands(commandManager: ICommandManager, disposableRegistry: IDisposableRegistry): void { - NativeEditorStorage.signedUpForCommands = true; - disposableRegistry.push({ - dispose: () => { - NativeEditorStorage.signedUpForCommands = false; - } - }); - disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_ClearCellOutputs, NativeEditorStorage.handleClearAllOutputs)); - disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_Close, NativeEditorStorage.handleClose)); - disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_DeleteAllCells, NativeEditorStorage.handleDeleteAllCells)); - disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_EditCell, NativeEditorStorage.handleEdit)); - disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_InsertCell, NativeEditorStorage.handleInsert)); - disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_ModifyCells, NativeEditorStorage.handleModifyCells)); - disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_RemoveCell, NativeEditorStorage.handleRemoveCell)); - disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_SwapCells, NativeEditorStorage.handleSwapCells)); - disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_UpdateVersion, NativeEditorStorage.handleUpdateVersionInfo)); - } - private static async getStorage(resource: Uri): Promise { const storage = NativeEditorStorage.storageMap.get(resource.toString()); if (storage && storage._loadPromise) { @@ -99,159 +83,136 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS return undefined; } - private static handleCallback(resource: Uri, callback: (storage: NativeEditorStorage) => Promise): Promise { - return NativeEditorStorage.getStorage(resource).then(s => { - if (s) { - return callback(s); - } - }); - } - - private static async handleEdit(resource: Uri, request: IEditCell): Promise { - return NativeEditorStorage.handleCallback(resource, async s => { - // Apply the changes to the visible cell list. We won't get an update until - // submission otherwise - if (request.changes && request.changes.length) { - const change = request.changes[0]; - const normalized = change.text.replace(/\r/g, ''); - - // Figure out which cell we're editing. - const index = s._state.cells.findIndex(c => c.id === request.id); - if (index >= 0) { - // This is an actual edit. - const contents = concatMultilineStringInput(s._state.cells[index].data.source); - const before = contents.substr(0, change.rangeOffset); - const after = contents.substr(change.rangeOffset + change.rangeLength); - const newContents = `${before}${normalized}${after}`; - if (contents !== newContents) { - const newCells = [...s._state.cells]; - const newCell = { ...newCells[index], data: { ...newCells[index].data, source: newContents } }; - newCells[index] = newCell; - return s.setState({ cells: newCells }); - } + private static async handleEdit(s: NativeEditorStorage, request: IEditCell): Promise { + // Apply the changes to the visible cell list. We won't get an update until + // submission otherwise + if (request.changes && request.changes.length) { + const change = request.changes[0]; + const normalized = change.text.replace(/\r/g, ''); + + // Figure out which cell we're editing. + const index = s.cells.findIndex(c => c.id === request.id); + if (index >= 0) { + // This is an actual edit. + const contents = concatMultilineStringInput(s.cells[index].data.source); + const before = contents.substr(0, change.rangeOffset); + const after = contents.substr(change.rangeOffset + change.rangeLength); + const newContents = `${before}${normalized}${after}`; + if (contents !== newContents) { + const newCells = [...s.cells]; + const newCell = { ...newCells[index], data: { ...newCells[index].data, source: newContents } }; + newCells[index] = newCell; + s.setState({ cells: newCells }); } } - }); + } } - private static async handleInsert(resource: Uri, request: IInsertCell): Promise { - return NativeEditorStorage.handleCallback(resource, async s => { - // Insert a cell into our visible list based on the index. They should be in sync - const newCells = [...s._state.cells]; - newCells.splice(request.index, 0, request.cell); - return s.setState({ cells: newCells }); - }); + private static async handleInsert(s: NativeEditorStorage, request: IInsertCell): Promise { + // Insert a cell into our visible list based on the index. They should be in sync + const newCells = [...s.cells]; + newCells.splice(request.index, 0, request.cell); + s.setState({ cells: newCells }); } - private static async handleRemoveCell(resource: Uri, id: string): Promise { + private static async handleRemoveCell(s: NativeEditorStorage, id: string): Promise { // Filter our list - return NativeEditorStorage.handleCallback(resource, async s => { - const newCells = [...s._state.cells].filter(v => v.id !== id); - return s.setState({ cells: newCells }); - }); + const newCells = [...s.cells].filter(v => v.id !== id); + if (newCells.length !== s.cells.length) { + s.setState({ cells: newCells }); + } } - private static async handleSwapCells(resource: Uri, request: ISwapCells): Promise { + private static async handleSwapCells(s: NativeEditorStorage, request: ISwapCells): Promise { // Swap two cells in our list - return NativeEditorStorage.handleCallback(resource, async s => { - const first = s._state.cells.findIndex(v => v.id === request.firstCellId); - const second = s._state.cells.findIndex(v => v.id === request.secondCellId); - if (first >= 0 && second >= 0) { - const newCells = [...s._state.cells]; - const temp = { ...newCells[first] }; - newCells[first] = newCells[second]; - newCells[second] = temp; - return s.setState({ cells: newCells }); - } - }); + const first = s.cells.findIndex(v => v.id === request.firstCellId); + const second = s.cells.findIndex(v => v.id === request.secondCellId); + if (first >= 0 && second >= 0) { + const newCells = [...s.cells]; + const temp = { ...newCells[first] }; + newCells[first] = newCells[second]; + newCells[second] = temp; + s.setState({ cells: newCells }); + } } - private static handleDeleteAllCells(resource: Uri): Promise { - return NativeEditorStorage.handleCallback(resource, async s => { - return s.setState({ cells: [] }); - }); + private static async handleDeleteAllCells(s: NativeEditorStorage): Promise { + if (s.cells.length !== 0) { + s.setState({ cells: [] }); + } } - private static handleClearAllOutputs(resource: Uri): Promise { - return NativeEditorStorage.handleCallback(resource, async s => { - const newCells = s._state.cells.map(c => { - return { ...c, data: { ...c.data, execution_count: null, outputs: [] } }; - }); - return s.setState({ cells: newCells }); + private static async handleClearAllOutputs(s: NativeEditorStorage): Promise { + const newCells = s.cells.map(c => { + return { ...c, data: { ...c.data, execution_count: null, outputs: [] } }; }); - } - private static async handleModifyCells(resource: Uri, cells: ICell[]): Promise { - return NativeEditorStorage.handleCallback(resource, async s => { - const newCells = [...s._state.cells]; - // Update these cells in our list - cells.forEach(c => { - const index = newCells.findIndex(v => v.id === c.id); - newCells[index] = c; - }); + // Do our check here to see if any changes happened. We don't want + // to fire an unnecessary change if we can help it. + if (!fastDeepEqual(s.cells, newCells)) { + s.setState({ cells: newCells }); + } + } - // Indicate dirty - return s.setState({ cells: newCells, isDirty: true }); + private static async handleModifyCells(s: NativeEditorStorage, cells: ICell[]): Promise { + const newCells = [...s.cells]; + // Update these cells in our list + cells.forEach(c => { + const index = newCells.findIndex(v => v.id === c.id); + newCells[index] = c; }); - } - private static async handleClose(_resource: Uri): Promise { - // Don't care about close (used to) + // Indicate dirty + if (!fastDeepEqual(newCells, s.cells)) { + s.setState({ cells: newCells, isDirty: true }); + } } private static async handleUpdateVersionInfo( - resource: Uri, + s: NativeEditorStorage, interpreter: PythonInterpreter | undefined, kernelSpec: IJupyterKernelSpec | LiveKernelModel | undefined ): Promise { - return NativeEditorStorage.handleCallback(resource, async s => { - // Get our kernel_info and language_info from the current notebook - if (interpreter && interpreter.version && s._state.notebookJson.metadata && s._state.notebookJson.metadata.language_info) { - s._state.notebookJson.metadata.language_info.version = interpreter.version.raw; - } + // Get our kernel_info and language_info from the current notebook + if (interpreter && interpreter.version && s._state.notebookJson.metadata && s._state.notebookJson.metadata.language_info) { + s._state.notebookJson.metadata.language_info.version = interpreter.version.raw; + } - if (kernelSpec && s._state.notebookJson.metadata && !s._state.notebookJson.metadata.kernelspec) { - // Add a new spec in this case - s._state.notebookJson.metadata.kernelspec = { - name: kernelSpec.name || kernelSpec.display_name || '', - display_name: kernelSpec.display_name || kernelSpec.name || '' - }; - } else if (kernelSpec && s._state.notebookJson.metadata && s._state.notebookJson.metadata.kernelspec) { - // Spec exists, just update name and display_name - s._state.notebookJson.metadata.kernelspec.name = kernelSpec.name || kernelSpec.display_name || ''; - s._state.notebookJson.metadata.kernelspec.display_name = kernelSpec.display_name || kernelSpec.name || ''; - } - }); + if (kernelSpec && s._state.notebookJson.metadata && !s._state.notebookJson.metadata.kernelspec) { + // Add a new spec in this case + s._state.notebookJson.metadata.kernelspec = { + name: kernelSpec.name || kernelSpec.display_name || '', + display_name: kernelSpec.display_name || kernelSpec.name || '' + }; + } else if (kernelSpec && s._state.notebookJson.metadata && s._state.notebookJson.metadata.kernelspec) { + // Spec exists, just update name and display_name + s._state.notebookJson.metadata.kernelspec.name = kernelSpec.name || kernelSpec.display_name || ''; + s._state.notebookJson.metadata.kernelspec.display_name = kernelSpec.display_name || kernelSpec.name || ''; + } } public dispose(): void { NativeEditorStorage.storageMap.delete(this.file.toString()); } - public async load(file: Uri, possibleContents?: string): Promise { + public async load(file: Uri, possibleContents?: string): Promise { // Reset the load promise and reload our cells - this._loaded = false; this._loadPromise = this.loadFromFile(file, possibleContents); await this._loadPromise; + return this; } - public save(): Promise { + public save(): Promise { return this.saveAs(this.file); } - public async saveAs(file: Uri): Promise { + public async saveAs(file: Uri): Promise { const contents = await this.getContent(); await this.fileSystem.writeFile(file.fsPath, contents, 'utf-8'); - this.setState({ isDirty: false, file }); - } - - public getCells(): Promise { - if (!this._loaded && this._loadPromise) { - return this._loadPromise; + if (this.isDirty || file.fsPath !== this.file.fsPath) { + this.setState({ isDirty: false, file }); } - - // If already loaded, return the updated cell values - return Promise.resolve(this._state.cells); + return this; } public async getJson(): Promise> { @@ -260,7 +221,39 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS } public getContent(cells?: ICell[]): Promise { - return this.generateNotebookContent(cells ? cells : this._state.cells); + return this.generateNotebookContent(cells ? cells : this.cells); + } + + // tslint:disable-next-line: no-any + private async commandCallback(handler: (...any: [NativeEditorStorage, ...any[]]) => Promise, resource: Uri) { + const args = Array.prototype.slice.call(arguments).slice(2); + const storage = await NativeEditorStorage.getStorage(resource); + if (storage) { + return handler(storage, ...args); + } + } + + private registerCommands(commandManager: ICommandManager, disposableRegistry: IDisposableRegistry): void { + NativeEditorStorage.signedUpForCommands = true; + disposableRegistry.push({ + dispose: () => { + NativeEditorStorage.signedUpForCommands = false; + } + }); + disposableRegistry.push( + commandManager.registerCommand(Commands.NotebookStorage_ClearCellOutputs, this.commandCallback.bind(undefined, NativeEditorStorage.handleClearAllOutputs)) + ); + disposableRegistry.push( + commandManager.registerCommand(Commands.NotebookStorage_DeleteAllCells, this.commandCallback.bind(undefined, NativeEditorStorage.handleDeleteAllCells)) + ); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_EditCell, this.commandCallback.bind(undefined, NativeEditorStorage.handleEdit))); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_InsertCell, this.commandCallback.bind(undefined, NativeEditorStorage.handleInsert))); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_ModifyCells, this.commandCallback.bind(undefined, NativeEditorStorage.handleModifyCells))); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_RemoveCell, this.commandCallback.bind(undefined, NativeEditorStorage.handleRemoveCell))); + disposableRegistry.push(commandManager.registerCommand(Commands.NotebookStorage_SwapCells, this.commandCallback.bind(undefined, NativeEditorStorage.handleSwapCells))); + disposableRegistry.push( + commandManager.registerCommand(Commands.NotebookStorage_UpdateVersion, this.commandCallback.bind(undefined, NativeEditorStorage.handleUpdateVersionInfo)) + ); } private async loadFromFile(file: Uri, possibleContents?: string): Promise { @@ -298,7 +291,7 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS private async loadContents(contents: string | undefined, forceDirty: boolean): Promise { // tslint:disable-next-line: no-any - const json = contents ? (JSON.parse(contents) as any) : undefined; + const json = contents ? (JSON.parse(contents) as Partial) : undefined; // Double check json (if we have any) if (json && !json.cells) { @@ -316,7 +309,7 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS } // Extract cells from the json - const cells = contents ? (json.cells as (nbformat.ICodeCell | nbformat.IRawCell | nbformat.IMarkdownCell)[]) : []; + const cells = json ? (json.cells as (nbformat.ICodeCell | nbformat.IRawCell | nbformat.IMarkdownCell)[]) : []; // Remap the ids const remapped = cells.map((c, index) => { @@ -338,10 +331,7 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS // Save as our visible list this.setState({ cells: remapped, isDirty: forceDirty }); - // Indicate loaded - this._loaded = true; - - return this._state.cells; + return this.cells; } private async extractPythonMainVersion(notebookData: Partial): Promise { @@ -415,8 +405,8 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS private setState(newState: Partial) { let changed = false; - const change: INotebookStorageChange = { storage: this }; - if (newState.file && newState.file !== this.file) { + const change: INotebookModelChange = { model: this }; + if (newState.file) { change.newFile = newState.file; change.oldFile = this.file; this._state.file = change.newFile; @@ -424,7 +414,7 @@ export class NativeEditorStorage implements INotebookStorage, ILoadableNotebookS NativeEditorStorage.storageMap.set(newState.file.toString(), this); changed = true; } - if (newState.cells && !fastDeepEqual(newState.cells, this._state.cells)) { + if (newState.cells) { change.oldCells = this._state.cells; change.newCells = newState.cells; this._state.cells = newState.cells; diff --git a/src/client/datascience/serviceRegistry.ts b/src/client/datascience/serviceRegistry.ts index 6aa6a346c9c3..dc188eff7140 100644 --- a/src/client/datascience/serviceRegistry.ts +++ b/src/client/datascience/serviceRegistry.ts @@ -89,13 +89,13 @@ import { IJupyterSessionManagerFactory, IJupyterSubCommandExecutionService, IJupyterVariables, - ILoadableNotebookStorage, INotebookEditor, INotebookEditorProvider, INotebookExecutionLogger, INotebookExporter, INotebookImporter, INotebookServer, + INotebookStorage, IPlotViewer, IPlotViewerProvider, IStatusProvider, @@ -139,7 +139,7 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addBinding(ICellHashProvider, INotebookExecutionLogger); serviceManager.addBinding(IJupyterDebugger, ICellHashListener); serviceManager.addSingleton(INotebookEditorProvider, NativeEditorProvider); - serviceManager.add(ILoadableNotebookStorage, NativeEditorStorage); + serviceManager.add(INotebookStorage, NativeEditorStorage); serviceManager.add(INotebookEditor, NativeEditor); serviceManager.addSingleton(IDataScienceCommandListener, NativeEditorCommandListener); serviceManager.addBinding(ICodeLensFactory, IInteractiveWindowListener); diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index de88291a3c11..55381efe822e 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -332,7 +332,7 @@ export interface INotebookEditor extends IInteractiveBase { readonly file: Uri; readonly visible: boolean; readonly active: boolean; - load(storage: INotebookStorage, webViewPanel: WebviewPanel): Promise; + load(storage: INotebookModel, webViewPanel: WebviewPanel): Promise; runAllCells(): void; runSelectedCell(): void; addCellBelow(): void; @@ -731,8 +731,8 @@ export interface INotebookEdit { readonly contents: ICell[]; } -export interface INotebookStorageChange { - storage: INotebookStorage; +export interface INotebookModelChange { + model: INotebookModel; newFile?: Uri; oldFile?: Uri; isDirty?: boolean; @@ -741,20 +741,20 @@ export interface INotebookStorageChange { oldCells?: ICell[]; } -export interface INotebookStorage { +export interface INotebookModel { readonly file: Uri; readonly isDirty: boolean; readonly isUntitled: boolean; - readonly changed: Event; - getCells(): Promise; + readonly changed: Event; + readonly cells: ICell[]; getJson(): Promise>; getContent(cells?: ICell[]): Promise; } -export const ILoadableNotebookStorage = Symbol('ILoadableNotebookStorage'); +export const INotebookStorage = Symbol('INotebookStorage'); -export interface ILoadableNotebookStorage extends INotebookStorage { - load(file: Uri, contents?: string): Promise; - save(): Promise; - saveAs(file: Uri): Promise; +export interface INotebookStorage { + load(file: Uri, contents?: string): Promise; + save(): Promise; + saveAs(file: Uri): Promise; } diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index 05c3f6005faa..de42a5dd7a45 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -1694,4 +1694,9 @@ export interface IEventNamePropertyMapping { * @memberof IEventNamePropertyMapping */ [Telemetry.StartSessionFailedJupyter]: undefined | never; + /** + * Telemetry event fired if a failure occurs loading a notebook + * message param is the exception message string. + */ + [Telemetry.OpenNotebookFailure]: { message: string }; } diff --git a/src/test/datascience/dataScienceIocContainer.ts b/src/test/datascience/dataScienceIocContainer.ts index 2d6223e40fb7..43bee462dced 100644 --- a/src/test/datascience/dataScienceIocContainer.ts +++ b/src/test/datascience/dataScienceIocContainer.ts @@ -206,13 +206,13 @@ import { IJupyterSessionManagerFactory, IJupyterSubCommandExecutionService, IJupyterVariables, - ILoadableNotebookStorage, INotebookEditor, INotebookEditorProvider, INotebookExecutionLogger, INotebookExporter, INotebookImporter, INotebookServer, + INotebookStorage, IPlotViewer, IPlotViewerProvider, IStatusProvider, @@ -439,7 +439,7 @@ export class DataScienceIocContainer extends UnitTestIocContainer { this.serviceManager.addSingleton(IDebugLocationTracker, DebugLocationTrackerFactory); this.serviceManager.addSingleton(INotebookEditorProvider, TestNativeEditorProvider); this.serviceManager.add(INotebookEditor, NativeEditor); - this.serviceManager.add(ILoadableNotebookStorage, NativeEditorStorage); + this.serviceManager.add(INotebookStorage, NativeEditorStorage); this.serviceManager.addSingletonInstance(ICustomEditorService, new MockCustomEditorService(this.asyncRegistry, this.commandManager)); this.serviceManager.addSingleton(IDataScienceCommandListener, NativeEditorCommandListener); this.serviceManager.addSingletonInstance(IOutputChannel, mock(MockOutputChannel), JUPYTER_OUTPUT_CHANNEL); diff --git a/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts b/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts index 61aa58c74393..acafed51f0b9 100644 --- a/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts +++ b/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts @@ -16,7 +16,7 @@ import { ConfigurationService } from '../../../client/common/configuration/servi import { IConfigurationService } from '../../../client/common/types'; import { noop } from '../../../client/common/utils/misc'; import { NativeEditorProvider } from '../../../client/datascience/interactive-ipynb/nativeEditorProvider'; -import { ILoadableNotebookStorage, INotebookEditor } from '../../../client/datascience/types'; +import { INotebookEditor, INotebookModel, INotebookStorage } from '../../../client/datascience/types'; import { ServiceContainer } from '../../../client/ioc/container'; import { IServiceContainer } from '../../../client/ioc/types'; @@ -26,7 +26,7 @@ suite('Data Science - Native Editor Provider', () => { let configService: IConfigurationService; let svcContainer: IServiceContainer; let editor: typemoq.IMock; - let storage: typemoq.IMock; + let storage: typemoq.IMock; let customEditorService: typemoq.IMock; let file: Uri; let storageFile: Uri; @@ -44,7 +44,7 @@ suite('Data Science - Native Editor Provider', () => { function createNotebookProvider() { editor = typemoq.Mock.ofType(); - storage = typemoq.Mock.ofType(); + storage = typemoq.Mock.ofType(); when(configService.getSettings()).thenReturn({ datascience: { useNotebookEditor: true } } as any); editor.setup(e => e.closed).returns(() => new EventEmitter().event); editor.setup(e => e.executed).returns(() => new EventEmitter().event); @@ -54,11 +54,11 @@ suite('Data Science - Native Editor Provider', () => { .setup(s => s.load(typemoq.It.isAny(), typemoq.It.isAny())) .returns(f => { storageFile = f; - return Promise.resolve(); + return Promise.resolve(storage.object); }); storage.setup(s => s.file).returns(() => storageFile); when(svcContainer.get(INotebookEditor)).thenReturn(editor.object); - when(svcContainer.get(ILoadableNotebookStorage)).thenReturn(storage.object); + when(svcContainer.get(INotebookStorage)).thenReturn(storage.object); customEditorService.setup(e => (e as any).then).returns(() => undefined); customEditorService .setup(c => c.registerWebviewCustomEditorProvider(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) diff --git a/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts b/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts index b0610d3c18bf..8fa146938ff2 100644 --- a/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts +++ b/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts @@ -352,7 +352,7 @@ suite('Data Science - Native Editor Storage', () => { test('Create new editor and add some cells', async () => { await storage.load(baseUri); await executeCommand(Commands.NotebookStorage_InsertCell, baseUri, { index: 0, cell: createEmptyCell('1', 1), code: '1', codeCellAboveId: undefined }); - const cells = await storage.getCells(); + const cells = storage.cells; expect(cells).to.be.lengthOf(4); expect(storage.isDirty).to.be.equal(true, 'Editor should be dirty'); expect(cells[0].id).to.be.match(/1/); @@ -361,7 +361,7 @@ suite('Data Science - Native Editor Storage', () => { test('Move cells around', async () => { await storage.load(baseUri); await executeCommand(Commands.NotebookStorage_SwapCells, baseUri, { firstCellId: 'NotebookImport#0', secondCellId: 'NotebookImport#1' }); - const cells = await storage.getCells(); + const cells = storage.cells; expect(cells).to.be.lengthOf(3); expect(storage.isDirty).to.be.equal(true, 'Editor should be dirty'); expect(cells[0].id).to.be.match(/NotebookImport#1/); @@ -386,17 +386,17 @@ suite('Data Science - Native Editor Storage', () => { ], id: 'NotebookImport#1' }); - let cells = await storage.getCells(); + let cells = storage.cells; expect(cells).to.be.lengthOf(3); expect(cells[1].id).to.be.match(/NotebookImport#1/); expect(cells[1].data.source).to.be.equals('b=2\nab'); expect(storage.isDirty).to.be.equal(true, 'Editor should be dirty'); await executeCommand(Commands.NotebookStorage_RemoveCell, baseUri, 'NotebookImport#0'); - cells = await storage.getCells(); + cells = storage.cells; expect(cells).to.be.lengthOf(2); expect(cells[0].id).to.be.match(/NotebookImport#1/); await executeCommand(Commands.NotebookStorage_DeleteAllCells, baseUri); - cells = await storage.getCells(); + cells = storage.cells; expect(cells).to.be.lengthOf(0); }); @@ -414,7 +414,7 @@ suite('Data Science - Native Editor Storage', () => { await storage.load(file); // It should load with that value - const cells = await storage.getCells(); + const cells = storage.cells; expect(cells).to.be.lengthOf(2); }); @@ -432,7 +432,7 @@ suite('Data Science - Native Editor Storage', () => { await storage.load(file); // It should load with that value - const cells = await storage.getCells(); + const cells = storage.cells; expect(cells).to.be.lengthOf(2); }); @@ -456,7 +456,7 @@ suite('Data Science - Native Editor Storage', () => { await storage.load(file); // It should load with that value - const cells = await storage.getCells(); + const cells = storage.cells; expect(cells).to.be.lengthOf(2); // And global storage should be empty From 1d429278fd74364a847dcf4ee1e1f901419bd579 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Wed, 29 Jan 2020 14:13:32 -0800 Subject: [PATCH 16/18] Update PR validation list --- build/ci/vscode-python-pr-validation.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/ci/vscode-python-pr-validation.yaml b/build/ci/vscode-python-pr-validation.yaml index 5c71e2c6ecfd..0c24a440df33 100644 --- a/build/ci/vscode-python-pr-validation.yaml +++ b/build/ci/vscode-python-pr-validation.yaml @@ -6,7 +6,7 @@ name: '$(Year:yyyy).$(Month).0.$(BuildID)-pr' pr: autoCancel: true branches: - include: ["master", "release*"] + include: ["master", "release*", "ds*"] paths: exclude: ["/news/1 Enhancements", "/news/2 Fixes", "/news/3 Code Health", "/.vscode"] From 4418a7944fb6f096d4ea10df8dea765459f3444e Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Wed, 29 Jan 2020 16:14:55 -0800 Subject: [PATCH 17/18] Fix nyc compiler problems. --- .../common/application/customEditorService.ts | 15 +-- src/client/common/application/types.ts | 86 ++++++++++++- .../interactive-ipynb/nativeEditorProvider.ts | 4 +- .../interactive-ipynb/nativeEditorStorage.ts | 15 ++- .../datascience/mockCustomEditorService.ts | 7 +- types/vscode.proposed.d.ts | 119 ------------------ 6 files changed, 99 insertions(+), 147 deletions(-) delete mode 100644 types/vscode.proposed.d.ts diff --git a/src/client/common/application/customEditorService.ts b/src/client/common/application/customEditorService.ts index 4960cd84e1d9..f21c2e47713f 100644 --- a/src/client/common/application/customEditorService.ts +++ b/src/client/common/application/customEditorService.ts @@ -4,22 +4,15 @@ import { inject, injectable } from 'inversify'; import * as vscode from 'vscode'; -import { ICommandManager, ICustomEditorService } from './types'; +import { ICommandManager, ICustomEditorService, WebviewCustomEditorProvider } from './types'; @injectable() export class CustomEditorService implements ICustomEditorService { constructor(@inject(ICommandManager) private commandManager: ICommandManager) {} - public get supportsCustomEditors(): boolean { - try { - return vscode.window.registerWebviewCustomEditorProvider !== undefined; - } catch { - return false; - } - } - - public registerWebviewCustomEditorProvider(viewType: string, provider: vscode.WebviewCustomEditorProvider, options?: vscode.WebviewPanelOptions): vscode.Disposable { - return vscode.window.registerWebviewCustomEditorProvider(viewType, provider, options); + public registerWebviewCustomEditorProvider(viewType: string, provider: WebviewCustomEditorProvider, options?: vscode.WebviewPanelOptions): vscode.Disposable { + // tslint:disable-next-line: no-any + return (vscode.window as any).registerWebviewCustomEditorProvider(viewType, provider, options); } public async openEditor(file: vscode.Uri): Promise { diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 407456fb3cb5..5632731f0619 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -49,7 +49,6 @@ import { TreeViewOptions, Uri, ViewColumn, - WebviewCustomEditorProvider, WebviewPanel, WebviewPanelOptions, WindowState, @@ -1048,12 +1047,89 @@ export interface IActiveResourceService { getActiveResource(): Resource; } -export const ICustomEditorService = Symbol('ICustomEditorService'); -export interface ICustomEditorService { +// Temporary hack to get the nyc compiler to find these types. vscode.proposed.d.ts doesn't work for some reason. +/** + * Defines the editing functionality of a webview editor. This allows the webview editor to hook into standard + * editor events such as `undo` or `save`. + * + * @param EditType Type of edits. Edit objects must be json serializable. + */ +// tslint:disable-next-line: interface-name +export interface WebviewCustomEditorEditingDelegate { + /** + * Event triggered by extensions to signal to VS Code that an edit has occurred. + */ + readonly onEdit: Event<{ readonly resource: Uri; readonly edit: EditType }>; + /** + * Save a resource. + * + * @param resource Resource being saved. + * + * @return Thenable signaling that the save has completed. + */ + save(resource: Uri): Thenable; + + /** + * Save an existing resource at a new path. + * + * @param resource Resource being saved. + * @param targetResource Location to save to. + * + * @return Thenable signaling that the save has completed. + */ + saveAs(resource: Uri, targetResource: Uri): Thenable; + /** - * Returns a boolean indicating if custom editors are supported or not. + * Apply a set of edits. + * + * Note that is not invoked when `onEdit` is called as `onEdit` implies also updating the view to reflect the edit. + * + * @param resource Resource being edited. + * @param edit Array of edits. Sorted from oldest to most recent. + * + * @return Thenable signaling that the change has completed. */ - readonly supportsCustomEditors: boolean; + applyEdits(resource: Uri, 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. + * + * @param resource Resource being edited. + * @param edit Array of edits. Sorted from most recent to oldest. + * + * @return Thenable signaling that the change has completed. + */ + undoEdits(resource: Uri, edits: readonly EditType[]): Thenable; +} + +// tslint:disable-next-line: interface-name +export interface WebviewCustomEditorProvider { + /** + * Controls the editing functionality of a webview editor. This allows the webview editor to hook into standard + * editor events such as `undo` or `save`. + * + * WebviewEditors that do not have `editingCapability` are considered to be readonly. Users can still interact + * with readonly editors, but these editors will not integrate with VS Code's standard editor functionality. + */ + readonly editingDelegate?: WebviewCustomEditorEditingDelegate; + /** + * Resolve a webview editor for a given resource. + * + * To resolve a webview editor, a 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`. + * + * @param resource Resource being resolved. + * @param webview Webview being resolved. The provider should take ownership of this webview. + * + * @return Thenable indicating that the webview editor has been resolved. + */ + resolveWebviewEditor(resource: Uri, webview: WebviewPanel): Thenable; +} + +export const ICustomEditorService = Symbol('ICustomEditorService'); +export interface ICustomEditorService { /** * Register a new provider for webview editors of a given type. * diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index 0325d2453985..99ddb356b6ca 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -3,9 +3,9 @@ 'use strict'; import { inject, injectable } from 'inversify'; import * as uuid from 'uuid/v4'; -import { Disposable, Event, EventEmitter, Uri, WebviewCustomEditorEditingDelegate, WebviewCustomEditorProvider, WebviewPanel } from 'vscode'; +import { Disposable, Event, EventEmitter, Uri, WebviewPanel } from 'vscode'; import { arePathsSame } from '../../../datascience-ui/react-common/arePathsSame'; -import { ICustomEditorService, IWorkspaceService } from '../../common/application/types'; +import { ICustomEditorService, IWorkspaceService, WebviewCustomEditorEditingDelegate, WebviewCustomEditorProvider } from '../../common/application/types'; import { traceInfo } from '../../common/logger'; import { IAsyncDisposable, IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry } from '../../common/types'; import { createDeferred } from '../../common/utils/async'; diff --git a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts index 887adb7e5039..0281b1141699 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts @@ -101,7 +101,7 @@ export class NativeEditorStorage implements INotebookModel, INotebookStorage, ID if (contents !== newContents) { const newCells = [...s.cells]; const newCell = { ...newCells[index], data: { ...newCells[index].data, source: newContents } }; - newCells[index] = newCell; + newCells[index] = NativeEditorStorage.asCell(newCell); s.setState({ cells: newCells }); } } @@ -130,8 +130,8 @@ export class NativeEditorStorage implements INotebookModel, INotebookStorage, ID if (first >= 0 && second >= 0) { const newCells = [...s.cells]; const temp = { ...newCells[first] }; - newCells[first] = newCells[second]; - newCells[second] = temp; + newCells[first] = NativeEditorStorage.asCell(newCells[second]); + newCells[second] = NativeEditorStorage.asCell(temp); s.setState({ cells: newCells }); } } @@ -144,7 +144,7 @@ export class NativeEditorStorage implements INotebookModel, INotebookStorage, ID private static async handleClearAllOutputs(s: NativeEditorStorage): Promise { const newCells = s.cells.map(c => { - return { ...c, data: { ...c.data, execution_count: null, outputs: [] } }; + return NativeEditorStorage.asCell({ ...c, data: { ...c.data, execution_count: null, outputs: [] } }); }); // Do our check here to see if any changes happened. We don't want @@ -159,7 +159,7 @@ export class NativeEditorStorage implements INotebookModel, INotebookStorage, ID // Update these cells in our list cells.forEach(c => { const index = newCells.findIndex(v => v.id === c.id); - newCells[index] = c; + newCells[index] = NativeEditorStorage.asCell(c); }); // Indicate dirty @@ -191,6 +191,11 @@ export class NativeEditorStorage implements INotebookModel, INotebookStorage, ID } } + // tslint:disable-next-line: no-any + private static asCell(cell: any): ICell { + return cell as ICell; + } + public dispose(): void { NativeEditorStorage.storageMap.delete(this.file.toString()); } diff --git a/src/test/datascience/mockCustomEditorService.ts b/src/test/datascience/mockCustomEditorService.ts index 6d40f9da442c..bfd4613b8ec7 100644 --- a/src/test/datascience/mockCustomEditorService.ts +++ b/src/test/datascience/mockCustomEditorService.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { Disposable, Uri, WebviewCustomEditorEditingDelegate, WebviewCustomEditorProvider, WebviewPanel, WebviewPanelOptions } from 'vscode'; -import { ICommandManager, ICustomEditorService } from '../../client/common/application/types'; +import { Disposable, Uri, WebviewPanel, WebviewPanelOptions } from 'vscode'; +import { ICommandManager, ICustomEditorService, WebviewCustomEditorEditingDelegate, WebviewCustomEditorProvider } from '../../client/common/application/types'; import { IDisposableRegistry } from '../../client/common/types'; import { noop } from '../../client/common/utils/misc'; import { INotebookEdit, INotebookEditor, INotebookEditorProvider } from '../../client/datascience/types'; @@ -15,9 +15,6 @@ export class MockCustomEditorService implements ICustomEditorService { disposableRegistry.push(commandManager.registerCommand('workbench.action.files.saveAs', this.onFileSaveAs.bind(this))); } - public get supportsCustomEditors(): boolean { - return true; - } public registerWebviewCustomEditorProvider(_viewType: string, provider: WebviewCustomEditorProvider, _options?: WebviewPanelOptions | undefined): Disposable { // Only support one view type, so just save the provider this.provider = provider; diff --git a/types/vscode.proposed.d.ts b/types/vscode.proposed.d.ts deleted file mode 100644 index a5eeae0b97f2..000000000000 --- a/types/vscode.proposed.d.ts +++ /dev/null @@ -1,119 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * This is the place for API experiments and proposals. - * These API are NOT stable and subject to change. They are only available in the Insiders - * distribution and CANNOT be used in published extensions. - * - * To test these API in local environment: - * - Use Insiders release of VS Code. - * - Add `"enableProposedApi": true` to your package.json. - * - Copy this file to your project. - */ - -declare module 'vscode' { - - - /** - * Defines the editing functionality of a webview editor. This allows the webview editor to hook into standard - * editor events such as `undo` or `save`. - * - * @param EditType Type of edits. Edit objects must be json serializable. - */ - interface WebviewCustomEditorEditingDelegate { - /** - * Save a resource. - * - * @param resource Resource being saved. - * - * @return Thenable signaling that the save has completed. - */ - save(resource: Uri): Thenable; - - /** - * Save an existing resource at a new path. - * - * @param resource Resource being saved. - * @param targetResource Location to save to. - * - * @return Thenable signaling that the save has completed. - */ - saveAs(resource: Uri, targetResource: Uri): Thenable; - - /** - * Event triggered by extensions to signal to VS Code that an edit has occurred. - */ - readonly onEdit: Event<{ readonly resource: Uri, readonly edit: EditType }>; - - /** - * Apply a set of edits. - * - * Note that is not invoked when `onEdit` is called as `onEdit` implies also updating the view to reflect the edit. - * - * @param resource Resource being edited. - * @param edit Array of edits. Sorted from oldest to most recent. - * - * @return Thenable signaling that the change has completed. - */ - applyEdits(resource: Uri, 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. - * - * @param resource Resource being edited. - * @param edit Array of edits. Sorted from most recent to oldest. - * - * @return Thenable signaling that the change has completed. - */ - undoEdits(resource: Uri, edits: readonly EditType[]): Thenable; - } - - export interface WebviewCustomEditorProvider { - /** - * Resolve a webview editor for a given resource. - * - * To resolve a webview editor, a 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`. - * - * @param resource Resource being resolved. - * @param webview Webview being resolved. The provider should take ownership of this webview. - * - * @return Thenable indicating that the webview editor has been resolved. - */ - resolveWebviewEditor( - resource: Uri, - webview: WebviewPanel, - ): Thenable; - - /** - * Controls the editing functionality of a webview editor. This allows the webview editor to hook into standard - * editor events such as `undo` or `save`. - * - * WebviewEditors that do not have `editingCapability` are considered to be readonly. Users can still interact - * with readonly editors, but these editors will not integrate with VS Code's standard editor functionality. - */ - readonly editingDelegate?: WebviewCustomEditorEditingDelegate; - } - - namespace window { - /** - * Register a new provider for webview editors of a given type. - * - * @param viewType Type of the webview editor provider. - * @param provider Resolves webview editors. - * @param options Content settings for a webview panels the provider is given. - * - * @return Disposable that unregisters the `WebviewCustomEditorProvider`. - */ - export function registerWebviewCustomEditorProvider( - viewType: string, - provider: WebviewCustomEditorProvider, - options?: WebviewPanelOptions, - ): Disposable; - } -} From 7049d5a35e81245eb21165247ae3f3c84f94b788 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Wed, 29 Jan 2020 16:57:56 -0800 Subject: [PATCH 18/18] Try fixing the toggle markdown test --- .../interactive-common/interactiveWindowTypes.ts | 2 ++ .../interactive-common/redux/store.ts | 5 +++++ .../datascience/nativeEditor.functional.test.tsx | 14 +++++++------- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/client/datascience/interactive-common/interactiveWindowTypes.ts b/src/client/datascience/interactive-common/interactiveWindowTypes.ts index e51fe52fce43..6752f88a0436 100644 --- a/src/client/datascience/interactive-common/interactiveWindowTypes.ts +++ b/src/client/datascience/interactive-common/interactiveWindowTypes.ts @@ -83,6 +83,7 @@ export enum InteractiveWindowMessages { NotebookAddCellBelow = 'notebook_add_cell_below', ExecutionRendered = 'rendered_execution', FocusedCellEditor = 'focused_cell_editor', + UnfocusedCellEditor = 'unfocused_cell_editor', MonacoReady = 'monaco_ready', ClearAllOutputs = 'clear_all_outputs', SelectKernel = 'select_kernel', @@ -373,6 +374,7 @@ export class IInteractiveWindowMapping { public [InteractiveWindowMessages.NotebookAddCellBelow]: never | undefined; public [InteractiveWindowMessages.ExecutionRendered]: IRenderComplete; public [InteractiveWindowMessages.FocusedCellEditor]: IFocusedCellEditor; + public [InteractiveWindowMessages.UnfocusedCellEditor]: never | undefined; public [InteractiveWindowMessages.MonacoReady]: never | undefined; public [InteractiveWindowMessages.ClearAllOutputs]: never | undefined; public [InteractiveWindowMessages.UpdateKernel]: IServerState | undefined; diff --git a/src/datascience-ui/interactive-common/redux/store.ts b/src/datascience-ui/interactive-common/redux/store.ts index 4e35c159d567..d83f01a4d5a0 100644 --- a/src/datascience-ui/interactive-common/redux/store.ts +++ b/src/datascience-ui/interactive-common/redux/store.ts @@ -114,6 +114,11 @@ function createTestMiddleware(): Redux.Middleware<{}, IStore> { // Send async so happens after render state changes (so our enzyme wrapper is up to date) sendMessage(InteractiveWindowMessages.FocusedCellEditor, { cellId: action.payload.cellId }); } + // Special case for unfocusing a cell + if (prevState.main.focusedCellId !== afterState.main.focusedCellId && !afterState.main.focusedCellId) { + // Send async so happens after render state changes (so our enzyme wrapper is up to date) + sendMessage(InteractiveWindowMessages.UnfocusedCellEditor); + } // Indicate settings updates if (!fastDeepEqual(prevState.main.settings, afterState.main.settings)) { diff --git a/src/test/datascience/nativeEditor.functional.test.tsx b/src/test/datascience/nativeEditor.functional.test.tsx index 03c309e68d20..42e00edd3442 100644 --- a/src/test/datascience/nativeEditor.functional.test.tsx +++ b/src/test/datascience/nativeEditor.functional.test.tsx @@ -1144,8 +1144,8 @@ for _ in range(50): await update; // Monaco editor should be rendered and the cell should be markdown - assert.ok(isCellFocused(wrapper, 'NativeCell', 1)); - assert.ok(isCellMarkdown(wrapper, 'NativeCell', 1)); + assert.ok(isCellFocused(wrapper, 'NativeCell', 1), '1st cell is not focused'); + assert.ok(isCellMarkdown(wrapper, 'NativeCell', 1), '1st cell is not markdown'); assert.equal( wrapper .find(NativeCell) @@ -1160,13 +1160,13 @@ for _ in range(50): // Switch back to code mode. // First lose focus - update = waitForUpdate(wrapper, NativeEditor, 1); + update = waitForMessage(ioc, InteractiveWindowMessages.UnfocusedCellEditor); simulateKeyPressOnCell(1, { code: 'Escape' }); await update; // Confirm markdown output is rendered - assert.ok(!isCellFocused(wrapper, 'NativeCell', 1)); - assert.ok(isCellMarkdown(wrapper, 'NativeCell', 1)); + assert.ok(!isCellFocused(wrapper, 'NativeCell', 1), '1st cell is focused'); + assert.ok(isCellMarkdown(wrapper, 'NativeCell', 1), '1st cell is not markdown'); assert.equal( wrapper .find(NativeCell) @@ -1186,8 +1186,8 @@ for _ in range(50): wrapperElement.simulate('keyDown', { key: 'y' }); await update; - assert.ok(isCellFocused(wrapper, 'NativeCell', 1)); - assert.ok(!isCellMarkdown(wrapper, 'NativeCell', 1)); + assert.ok(isCellFocused(wrapper, 'NativeCell', 1), '1st cell is not focused 2nd time'); + assert.ok(!isCellMarkdown(wrapper, 'NativeCell', 1), '1st cell is markdown second time'); // Confirm editor still has the same text editor = getNativeFocusedEditor(wrapper);