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/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 diff --git a/package.json b/package.json index cc2c19893156..d11163aad1b8 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "version": "2020.2.0-dev", "languageServerVersion": "0.4.114", "publisher": "ms-python", + "enableProposedApi": true, "author": { "name": "Microsoft Corporation" }, @@ -82,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": { @@ -2771,7 +2773,18 @@ "when": "testsDiscovered" } ] - } + }, + "webviewEditors": [ + { + "viewType": "NativeEditorProvider.ipynb", + "displayName": "Jupyter Notebook", + "selector": [ + { + "filenamePattern": "*.ipynb" + } + ] + } + ] }, "scripts": { "package": "gulp clean && gulp prePublishBundle && vsce package -o ms-python-insiders.vsix", diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index 61ccbd43cef2..d12d54546284 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 { IEditCell, IInsertCell, ISwapCells } from '../../datascience/interactive-common/interactiveWindowTypes'; +import { LiveKernelModel } from '../../datascience/jupyter/kernels/types'; +import { ICell, IJupyterKernelSpec, INotebook } from '../../datascience/types'; +import { PythonInterpreter } from '../../interpreter/contracts'; import { CommandSource } from '../../testing/common/constants'; import { TestFunction, TestsToRun } from '../../testing/common/types'; import { TestDataItem, TestWorkspaceFolder } from '../../testing/types'; @@ -87,6 +90,9 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu ['python._loadLanguageServerExtension']: {}[]; ['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]; @@ -142,4 +148,12 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu [DSCommands.ScrollToCell]: [string, string]; [DSCommands.ViewJupyterOutput]: []; [DSCommands.SwitchJupyterKernel]: [INotebook | undefined]; + [DSCommands.NotebookStorage_DeleteAllCells]: [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_ClearCellOutputs]: [Uri]; + [DSCommands.NotebookStorage_UpdateVersion]: [Uri, PythonInterpreter | undefined, IJupyterKernelSpec | LiveKernelModel | undefined]; } diff --git a/src/client/common/application/customEditorService.ts b/src/client/common/application/customEditorService.ts new file mode 100644 index 000000000000..f21c2e47713f --- /dev/null +++ b/src/client/common/application/customEditorService.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { inject, injectable } from 'inversify'; +import * as vscode from 'vscode'; + +import { ICommandManager, ICustomEditorService, WebviewCustomEditorProvider } from './types'; + +@injectable() +export class CustomEditorService implements ICustomEditorService { + constructor(@inject(ICommandManager) private commandManager: ICommandManager) {} + + 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 { + await this.commandManager.executeCommand('vscode.open', file); + } +} diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 4d14f7dbf609..5632731f0619 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -49,6 +49,8 @@ import { TreeViewOptions, Uri, ViewColumn, + WebviewPanel, + WebviewPanelOptions, WindowState, WorkspaceConfiguration, WorkspaceEdit, @@ -983,6 +985,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 @@ -1042,3 +1046,102 @@ export const IActiveResourceService = Symbol('IActiveResourceService'); export interface IActiveResourceService { getActiveResource(): Resource; } + +// 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; + + /** + * 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; +} + +// 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. + * + * @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; + /** + * Opens a file with a custom editor + */ + openEditor(file: Uri): Promise; +} diff --git a/src/client/common/application/webPanels/webPanel.ts b/src/client/common/application/webPanels/webPanel.ts index 7930a8814057..c511cb07b483 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,18 +31,26 @@ 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 - } - ); + 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(); } 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/constants.ts b/src/client/datascience/constants.ts index 4f59c614cf29..774d782567cf 100644 --- a/src/client/datascience/constants.ts +++ b/src/client/datascience/constants.ts @@ -62,6 +62,16 @@ 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_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_ClearCellOutputs = 'python.datascience.notebook.clearoutputs'; + export const NotebookStorage_UpdateVersion = 'python.datascience.notebook.updateversion'; } export namespace CodeLensCommands { @@ -237,6 +247,7 @@ export enum Telemetry { UserInstalledJupyter = 'DATASCIENCE.USER_INSTALLED_JUPYTER', UserDidNotInstallJupyter = 'DATASCIENCE.USER_DID_NOT_INSTALL_JUPYTER', OpenedInteractiveWindow = 'DATASCIENCE.OPENED_INTERACTIVE', + OpenNotebookFailure = 'DS_INTERNAL.NATIVE.OPEN_NOTEBOOK_FAILURE', FindKernelForLocalConnection = 'DS_INTERNAL.FIND_KERNEL_FOR_LOCAL_CONNECTION', CompletionTimeFromLS = 'DS_INTERNAL.COMPLETION_TIME_FROM_LS', CompletionTimeFromJupyter = 'DS_INTERNAL.COMPLETION_TIME_FROM_JUPYTER' diff --git a/src/client/datascience/data-viewing/types.ts b/src/client/datascience/data-viewing/types.ts index ed1c240c108c..09751f4daa85 100644 --- a/src/client/datascience/data-viewing/types.ts +++ b/src/client/datascience/data-viewing/types.ts @@ -3,7 +3,7 @@ 'use strict'; import { JSONObject } from '@phosphor/coreutils'; -import { CssMessages, IGetCssRequest, IGetCssResponse, SharedMessages } from '../messages'; +import { SharedMessages } from '../messages'; import { IJupyterVariable } from '../types'; export const CellFetchAllLimit = 100000; @@ -40,15 +40,13 @@ export interface IGetRowsResponse { } // Map all messages to specific payloads -export class IDataViewerMapping { - public [DataViewerMessages.Started]: never | undefined; - public [DataViewerMessages.UpdateSettings]: string; - public [DataViewerMessages.InitializeData]: IJupyterVariable; - public [DataViewerMessages.GetAllRowsRequest]: never | undefined; - public [DataViewerMessages.GetAllRowsResponse]: JSONObject; - public [DataViewerMessages.GetRowsRequest]: IGetRowsRequest; - public [DataViewerMessages.GetRowsResponse]: IGetRowsResponse; - public [DataViewerMessages.CompletedData]: never | undefined; - public [CssMessages.GetCssRequest]: IGetCssRequest; - public [CssMessages.GetCssResponse]: IGetCssResponse; -} +export type IDataViewerMapping = { + [DataViewerMessages.Started]: never | undefined; + [DataViewerMessages.UpdateSettings]: string; + [DataViewerMessages.InitializeData]: IJupyterVariable; + [DataViewerMessages.GetAllRowsRequest]: never | undefined; + [DataViewerMessages.GetAllRowsResponse]: JSONObject; + [DataViewerMessages.GetRowsRequest]: IGetRowsRequest; + [DataViewerMessages.GetRowsResponse]: IGetRowsResponse; + [DataViewerMessages.CompletedData]: never | undefined; +}; diff --git a/src/client/datascience/interactive-common/interactiveBase.ts b/src/client/datascience/interactive-common/interactiveBase.ts index f9c0f90b5a20..a4522a560d25 100644 --- a/src/client/datascience/interactive-common/interactiveBase.ts +++ b/src/client/datascience/interactive-common/interactiveBase.ts @@ -11,6 +11,7 @@ import { CancellationToken, ConfigurationTarget, Event, EventEmitter, Memento, P import { Disposable } from 'vscode-jsonrpc'; import { ServerStatus } from '../../../datascience-ui/interactive-common/mainState'; +import { CommonActionType } from '../../../datascience-ui/interactive-common/redux/reducers/types'; import { IApplicationShell, ICommandManager, IDocumentManager, ILiveShareApi, IWebPanelProvider, IWorkspaceService } from '../../common/application/types'; import { CancellationError } from '../../common/cancellation'; import { EXTENSION_ROOT_DIR, PYTHON_LANGUAGE } from '../../common/constants'; @@ -43,7 +44,7 @@ import { JupyterInstallError } from '../jupyter/jupyterInstallError'; import { JupyterSelfCertsError } from '../jupyter/jupyterSelfCertsError'; import { JupyterKernelPromiseFailedError } from '../jupyter/kernels/jupyterKernelPromiseFailedError'; import { LiveKernelModel } from '../jupyter/kernels/types'; -import { CssMessages } from '../messages'; +import { CssMessages, SharedMessages } from '../messages'; import { ProgressReporter } from '../progress/progressReporter'; import { CellState, @@ -63,7 +64,6 @@ import { IJupyterVariablesResponse, IMessageCell, INotebook, - INotebookEditorProvider, INotebookExporter, INotebookServer, INotebookServerOptions, @@ -73,6 +73,7 @@ import { } from '../types'; import { WebViewHost } from '../webViewHost'; import { InteractiveWindowMessageListener } from './interactiveWindowMessageListener'; +import { BaseReduxActionPayload } from './types'; @injectable() export abstract class InteractiveBase extends WebViewHost implements IInteractiveBase { @@ -109,7 +110,6 @@ export abstract class InteractiveBase extends WebViewHost }; + this.postMessageInternal(syncPayload.type, syncPayload.payload).ignoreErrors(); + break; case InteractiveWindowMessages.GotoCodeCell: this.handleMessage(message, payload, this.gotoCode); break; @@ -438,6 +443,7 @@ export abstract class InteractiveBase extends WebViewHost; protected async clearResult(id: string): Promise { + await this.ensureServerAndNotebook(); if (this._notebook) { this._notebook.clear(id); } @@ -619,42 +625,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); @@ -1095,14 +1065,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-common/interactiveWindowMessageListener.ts b/src/client/datascience/interactive-common/interactiveWindowMessageListener.ts index 7234c41169ba..2dfe92e76597 100644 --- a/src/client/datascience/interactive-common/interactiveWindowMessageListener.ts +++ b/src/client/datascience/interactive-common/interactiveWindowMessageListener.ts @@ -15,6 +15,7 @@ import { InteractiveWindowMessages, InteractiveWindowRemoteMessages } from './in // This class listens to messages that come from the local Python Interactive window export class InteractiveWindowMessageListener implements IWebPanelMessageListener { + private static handlers = new Map void>(); private postOffice: PostOffice; private disposedCallback: () => void; private callback: (message: string, payload: any) => void; @@ -40,6 +41,7 @@ export class InteractiveWindowMessageListener implements IWebPanelMessageListene this.interactiveWindowMessages.forEach(m => { this.postOffice.registerCallback(m, a => callback(m, a)).ignoreErrors(); }); + InteractiveWindowMessageListener.handlers.set(this, callback); } public async dispose() { @@ -48,6 +50,20 @@ export class InteractiveWindowMessageListener implements IWebPanelMessageListene } public onMessage(message: string, payload: any) { + if (message === InteractiveWindowMessages.Sync) { + // const syncPayload = payload as BaseReduxActionPayload; + Array.from(InteractiveWindowMessageListener.handlers.keys()).forEach(item => { + if (item === this) { + return; + } + // Temporarily disabled. + // const cb = InteractiveWindowMessageListener.handlers.get(item); + // if (cb) { + // cb(InteractiveWindowMessages.Sync, { type: message, payload: syncPayload }); + // } + }); + return; + } // We received a message from the local webview. Broadcast it to everybody if it's a remote message if (InteractiveWindowRemoteMessages.indexOf(message) >= 0) { this.postOffice.postCommand(message, payload).ignoreErrors(); diff --git a/src/client/datascience/interactive-common/interactiveWindowTypes.ts b/src/client/datascience/interactive-common/interactiveWindowTypes.ts index 5370a18527be..d5772c5af964 100644 --- a/src/client/datascience/interactive-common/interactiveWindowTypes.ts +++ b/src/client/datascience/interactive-common/interactiveWindowTypes.ts @@ -3,8 +3,11 @@ 'use strict'; import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import { IServerState } from '../../../datascience-ui/interactive-common/mainState'; -import { CssMessages, IGetCssRequest, IGetCssResponse, IGetMonacoThemeRequest } from '../messages'; +import { CommonActionType, IAddCellAction, ICellAction } from '../../../datascience-ui/interactive-common/redux/reducers/types'; +import { CssMessages, IGetCssRequest, IGetCssResponse, IGetMonacoThemeRequest, SharedMessages } from '../messages'; +import { IGetMonacoThemeResponse } from '../monacoMessages'; import { ICell, IInteractiveWindowInfo, IJupyterVariable, IJupyterVariablesRequest, IJupyterVariablesResponse } from '../types'; +import { BaseReduxActionPayload } from './types'; export enum InteractiveWindowMessages { StartCell = 'start_cell', @@ -56,6 +59,7 @@ export enum InteractiveWindowMessages { EditCell = 'edit_cell', RemoveCell = 'remove_cell', SwapCells = 'swap_cells', + Sync = 'sync_message_used_to_broadcast_and_sync_editors', InsertCell = 'insert_cell', LoadOnigasmAssemblyRequest = 'load_onigasm_assembly_request', LoadOnigasmAssemblyResponse = 'load_onigasm_assembly_response', @@ -82,6 +86,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', @@ -303,10 +308,10 @@ export class IInteractiveWindowMapping { public [InteractiveWindowMessages.SelectKernel]: IServerState | undefined; public [InteractiveWindowMessages.SelectJupyterServer]: never | undefined; public [InteractiveWindowMessages.Export]: ICell[]; - public [InteractiveWindowMessages.GetAllCells]: ICell; + public [InteractiveWindowMessages.GetAllCells]: never | undefined; public [InteractiveWindowMessages.ReturnAllCells]: ICell[]; - public [InteractiveWindowMessages.DeleteCell]: never | undefined; - public [InteractiveWindowMessages.DeleteAllCells]: never | undefined; + public [InteractiveWindowMessages.DeleteCell]: ICellAction; + public [InteractiveWindowMessages.DeleteAllCells]: IAddCellAction; public [InteractiveWindowMessages.Undo]: never | undefined; public [InteractiveWindowMessages.Redo]: never | undefined; public [InteractiveWindowMessages.ExpandAll]: never | undefined; @@ -329,6 +334,7 @@ export class IInteractiveWindowMapping { public [CssMessages.GetCssRequest]: IGetCssRequest; public [CssMessages.GetCssResponse]: IGetCssResponse; public [CssMessages.GetMonacoThemeRequest]: IGetMonacoThemeRequest; + public [CssMessages.GetMonacoThemeResponse]: IGetMonacoThemeResponse; public [InteractiveWindowMessages.ProvideCompletionItemsRequest]: IProvideCompletionItemsRequest; public [InteractiveWindowMessages.CancelCompletionItemsRequest]: ICancelIntellisenseRequest; public [InteractiveWindowMessages.ProvideCompletionItemsResponse]: IProvideCompletionItemsResponse; @@ -364,14 +370,20 @@ export class IInteractiveWindowMapping { public [InteractiveWindowMessages.NotebookDirty]: never | undefined; public [InteractiveWindowMessages.NotebookClean]: never | undefined; public [InteractiveWindowMessages.SaveAll]: ISaveAll; + // tslint:disable-next-line: no-any + public [InteractiveWindowMessages.Sync]: { type: InteractiveWindowMessages | SharedMessages | CommonActionType; payload: BaseReduxActionPayload }; public [InteractiveWindowMessages.NativeCommand]: INativeCommand; public [InteractiveWindowMessages.VariablesComplete]: never | undefined; public [InteractiveWindowMessages.NotebookRunAllCells]: never | undefined; public [InteractiveWindowMessages.NotebookRunSelectedCell]: never | undefined; - public [InteractiveWindowMessages.NotebookAddCellBelow]: never | undefined; + public [InteractiveWindowMessages.NotebookAddCellBelow]: IAddCellAction; + public [InteractiveWindowMessages.DoSave]: 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; + public [InteractiveWindowMessages.UpdateKernel]: IServerState; + public [SharedMessages.UpdateSettings]: string; + public [SharedMessages.LocInit]: string; } diff --git a/src/client/datascience/interactive-common/synchronization.ts b/src/client/datascience/interactive-common/synchronization.ts new file mode 100644 index 000000000000..54c0decb7007 --- /dev/null +++ b/src/client/datascience/interactive-common/synchronization.ts @@ -0,0 +1,181 @@ +import { CommonActionType, CommonActionTypeMapping } from '../../../datascience-ui/interactive-common/redux/reducers/types'; +import { CssMessages, SharedMessages } from '../messages'; +import { IInteractiveWindowMapping, InteractiveWindowMessages } from './interactiveWindowTypes'; +import { BaseReduxActionPayload } from './types'; + +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export enum MessageType { + other = 0, + /** + * Messages must be re-broadcasted across other editors of the same file in the same session. + */ + syncAcrossSameNotebooks = 1 << 0, + /** + * Messages must be re-broadcasted across all sessions. + */ + syncWithLiveShare = 1 << 1, + noIdea = 1 << 2 +} + +type MessageMapping = { + [P in keyof T]: MessageType; +}; + +export type IInteractiveActionMapping = MessageMapping; + +// Do not change to a dictionary or a record. +// The current structure ensures all new enums added will be categorized. +// This way, if a new message is added, we'll make the decision early on whether it needs to be synchronized and how. +// Rather than waiting for users to report issues related to new messages. +const messageWithMessageTypes: MessageMapping & MessageMapping = { + [CommonActionType.ADD_NEW_CELL]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [CommonActionType.ARROW_DOWN]: MessageType.syncWithLiveShare, + [CommonActionType.ARROW_UP]: MessageType.syncWithLiveShare, + [CommonActionType.CHANGE_CELL_TYPE]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [CommonActionType.CLICK_CELL]: MessageType.syncWithLiveShare, + [CommonActionType.CODE_CREATED]: MessageType.noIdea, + [CommonActionType.COPY_CELL_CODE]: MessageType.other, + [CommonActionType.EDITOR_LOADED]: MessageType.other, + [CommonActionType.EDIT_CELL]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [CommonActionType.EXECUTE_ABOVE]: MessageType.other, + [CommonActionType.EXECUTE_ALL_CELLS]: MessageType.other, + [CommonActionType.EXECUTE_CELL]: MessageType.other, + [CommonActionType.EXECUTE_CELL_AND_BELOW]: MessageType.other, + [CommonActionType.EXPORT]: MessageType.other, + [CommonActionType.FOCUS_CELL]: MessageType.syncWithLiveShare, + [CommonActionType.GATHER_CELL]: MessageType.other, + [CommonActionType.GET_VARIABLE_DATA]: MessageType.other, + [CommonActionType.GOTO_CELL]: MessageType.syncWithLiveShare, + [CommonActionType.INSERT_ABOVE]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [CommonActionType.INSERT_ABOVE_FIRST]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [CommonActionType.INSERT_BELOW]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [CommonActionType.INTERRUPT_KERNEL]: MessageType.other, + [CommonActionType.LOADED_ALL_CELLS]: MessageType.other, + [CommonActionType.LINK_CLICK]: MessageType.other, + [CommonActionType.MOVE_CELL_DOWN]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [CommonActionType.MOVE_CELL_UP]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [CommonActionType.RESTART_KERNEL]: MessageType.other, + [CommonActionType.SAVE]: MessageType.other, + [CommonActionType.SCROLL]: MessageType.syncWithLiveShare, + [CommonActionType.SELECT_CELL]: MessageType.syncWithLiveShare, + [CommonActionType.SELECT_SERVER]: MessageType.other, + [CommonActionType.SEND_COMMAND]: MessageType.other, + [CommonActionType.SHOW_DATA_VIEWER]: MessageType.other, + [CommonActionType.SUBMIT_INPUT]: MessageType.other, + [CommonActionType.TOGGLE_INPUT_BLOCK]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [CommonActionType.TOGGLE_LINE_NUMBERS]: MessageType.syncWithLiveShare, + [CommonActionType.TOGGLE_OUTPUT]: MessageType.syncWithLiveShare, + [CommonActionType.TOGGLE_VARIABLE_EXPLORER]: MessageType.syncWithLiveShare, + [CommonActionType.UNFOCUS_CELL]: MessageType.syncWithLiveShare, + [CommonActionType.UNMOUNT]: MessageType.other, + + // Types from InteractiveWindowMessages + [InteractiveWindowMessages.Activate]: MessageType.other, + [InteractiveWindowMessages.AddCell]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [InteractiveWindowMessages.AddedSysInfo]: MessageType.other, + [InteractiveWindowMessages.CancelCompletionItemsRequest]: MessageType.other, + [InteractiveWindowMessages.CancelHoverRequest]: MessageType.other, + [InteractiveWindowMessages.CancelResolveCompletionItemRequest]: MessageType.other, + [InteractiveWindowMessages.CancelSignatureHelpRequest]: MessageType.other, + [InteractiveWindowMessages.ClearAllOutputs]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [InteractiveWindowMessages.CollapseAll]: MessageType.syncWithLiveShare, + [InteractiveWindowMessages.CopyCodeCell]: MessageType.other, + [InteractiveWindowMessages.DeleteAllCells]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [InteractiveWindowMessages.DeleteCell]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [InteractiveWindowMessages.DoSave]: MessageType.other, + [InteractiveWindowMessages.EditCell]: MessageType.other, + [InteractiveWindowMessages.ExecutionRendered]: MessageType.other, + [InteractiveWindowMessages.ExpandAll]: MessageType.syncWithLiveShare, + [InteractiveWindowMessages.Export]: MessageType.other, + [InteractiveWindowMessages.FinishCell]: MessageType.other, + [InteractiveWindowMessages.FocusedCellEditor]: MessageType.syncWithLiveShare, + [InteractiveWindowMessages.GatherCodeRequest]: MessageType.other, + [InteractiveWindowMessages.GetAllCells]: MessageType.other, + [InteractiveWindowMessages.GetVariablesRequest]: MessageType.other, + [InteractiveWindowMessages.GetVariablesResponse]: MessageType.other, + [InteractiveWindowMessages.GotoCodeCell]: MessageType.syncWithLiveShare, + [InteractiveWindowMessages.GotoCodeCell]: MessageType.syncWithLiveShare, + [InteractiveWindowMessages.InsertCell]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [InteractiveWindowMessages.Interrupt]: MessageType.other, + [InteractiveWindowMessages.LoadAllCells]: MessageType.other, + [InteractiveWindowMessages.LoadAllCellsComplete]: MessageType.other, + [InteractiveWindowMessages.LoadOnigasmAssemblyRequest]: MessageType.other, + [InteractiveWindowMessages.LoadOnigasmAssemblyResponse]: MessageType.other, + [InteractiveWindowMessages.LoadTmLanguageRequest]: MessageType.other, + [InteractiveWindowMessages.LoadTmLanguageResponse]: MessageType.other, + [InteractiveWindowMessages.MonacoReady]: MessageType.other, + [InteractiveWindowMessages.NativeCommand]: MessageType.other, + [InteractiveWindowMessages.NotebookAddCellBelow]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [InteractiveWindowMessages.NotebookClean]: MessageType.other, + [InteractiveWindowMessages.NotebookDirty]: MessageType.other, + [InteractiveWindowMessages.NotebookExecutionActivated]: MessageType.other, + [InteractiveWindowMessages.NotebookIdentity]: MessageType.other, + [InteractiveWindowMessages.NotebookRunAllCells]: MessageType.other, + [InteractiveWindowMessages.NotebookRunSelectedCell]: MessageType.other, + [InteractiveWindowMessages.OpenLink]: MessageType.other, + [InteractiveWindowMessages.ProvideCompletionItemsRequest]: MessageType.other, + [InteractiveWindowMessages.ProvideCompletionItemsResponse]: MessageType.other, + [InteractiveWindowMessages.ProvideHoverRequest]: MessageType.other, + [InteractiveWindowMessages.ProvideHoverResponse]: MessageType.other, + [InteractiveWindowMessages.ProvideSignatureHelpRequest]: MessageType.other, + [InteractiveWindowMessages.ProvideSignatureHelpResponse]: MessageType.other, + [InteractiveWindowMessages.ReExecuteCell]: MessageType.other, + [InteractiveWindowMessages.Redo]: MessageType.other, + [InteractiveWindowMessages.RemoteAddCode]: MessageType.other, + [InteractiveWindowMessages.RemoteReexecuteCode]: MessageType.other, + [InteractiveWindowMessages.RemoveCell]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [InteractiveWindowMessages.ResolveCompletionItemRequest]: MessageType.other, + [InteractiveWindowMessages.ResolveCompletionItemResponse]: MessageType.other, + [InteractiveWindowMessages.RestartKernel]: MessageType.other, + [InteractiveWindowMessages.ReturnAllCells]: MessageType.other, + [InteractiveWindowMessages.SaveAll]: MessageType.other, + [InteractiveWindowMessages.SavePng]: MessageType.other, + [InteractiveWindowMessages.ScrollToCell]: MessageType.syncWithLiveShare, + [InteractiveWindowMessages.SelectJupyterServer]: MessageType.other, + [InteractiveWindowMessages.SelectKernel]: MessageType.other, + [InteractiveWindowMessages.SendInfo]: MessageType.other, + [InteractiveWindowMessages.SettingsUpdated]: MessageType.other, + [InteractiveWindowMessages.ShowDataViewer]: MessageType.other, + [InteractiveWindowMessages.ShowPlot]: MessageType.other, + [InteractiveWindowMessages.StartCell]: MessageType.other, + [InteractiveWindowMessages.StartDebugging]: MessageType.other, + [InteractiveWindowMessages.StartProgress]: MessageType.other, + [InteractiveWindowMessages.Started]: MessageType.other, + [InteractiveWindowMessages.StopDebugging]: MessageType.other, + [InteractiveWindowMessages.StopProgress]: MessageType.other, + [InteractiveWindowMessages.SubmitNewCell]: MessageType.other, + [InteractiveWindowMessages.SwapCells]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, + [InteractiveWindowMessages.Sync]: MessageType.other, + [InteractiveWindowMessages.Undo]: MessageType.other, + [InteractiveWindowMessages.UnfocusedCellEditor]: MessageType.syncWithLiveShare, + [InteractiveWindowMessages.UpdateCell]: MessageType.other, + [InteractiveWindowMessages.UpdateKernel]: MessageType.other, + [InteractiveWindowMessages.VariableExplorerToggle]: MessageType.other, + [InteractiveWindowMessages.VariablesComplete]: MessageType.other, + // Types from CssMessages + [CssMessages.GetCssRequest]: MessageType.other, + [CssMessages.GetCssResponse]: MessageType.other, + [CssMessages.GetMonacoThemeRequest]: MessageType.other, + [CssMessages.GetMonacoThemeResponse]: MessageType.other, + // Types from Shared Messages + [SharedMessages.LocInit]: MessageType.other, + [SharedMessages.Started]: MessageType.other, + [SharedMessages.UpdateSettings]: MessageType.other +}; + +export function isActionPerformedByUser(action: BaseReduxActionPayload<{}> | BaseReduxActionPayload) { + return action.messageType === undefined; +} + +export function shouldRebroadcast(message: keyof IInteractiveWindowMapping): [boolean, MessageType] { + const messageType: MessageType | undefined = messageWithMessageTypes[message]; + // Support for liveshare is turned off for now, we can enable that later. + // I.e. we only support synchronizing across editors in the same session. + if (messageType === undefined || (messageType & MessageType.syncAcrossSameNotebooks) !== MessageType.syncAcrossSameNotebooks) { + return [false, MessageType.other]; + } + + return [(messageType & MessageType.syncAcrossSameNotebooks) > 0 || (messageType & MessageType.syncWithLiveShare) > 0, messageType]; +} diff --git a/src/client/datascience/interactive-common/types.ts b/src/client/datascience/interactive-common/types.ts new file mode 100644 index 000000000000..38b8a48e2cf7 --- /dev/null +++ b/src/client/datascience/interactive-common/types.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { MessageType } from './synchronization'; + +// Stuff common to React and Extensions. + +type BaseData = { + messageType?: MessageType; + /** + * Tells us whether this message is incoming for reducer use or + * whether this is a message that needs to be sent out to extension (from reducer). + */ + messageDirection?: 'incoming' | 'outgoing'; +}; + +type BaseDataWithPayload = { + messageType?: MessageType; + /** + * Tells us whether this message is incoming for reducer use or + * whether this is a message that needs to be sent out to extension (from reducer). + */ + messageDirection?: 'incoming' | 'outgoing'; + data: T; +}; + +// This forms the base content of every payload in all dispatchers. +export type BaseReduxActionPayload = T extends never ? (T extends undefined ? BaseData : BaseDataWithPayload) : BaseDataWithPayload; 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/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index 1dc8d4de8b63..a1701d2dbe3b 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -3,28 +3,23 @@ '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 * as uuid from 'uuid/v4'; -import { Event, EventEmitter, Memento, TextEditor, 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, IExperimentsManager, IExtensionContext, IMemento, WORKSPACE_MEMENTO } from '../../common/types'; +import { GLOBAL_MEMENTO, IConfigurationService, IDisposableRegistry, IExperimentsManager, IMemento } 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, @@ -37,7 +32,6 @@ import { ISwapCells, SysInfoReason } from '../interactive-common/interactiveWindowTypes'; -import { InvalidNotebookFileError } from '../jupyter/invalidNotebookFileError'; import { ProgressReporter } from '../progress/progressReporter'; import { CellState, @@ -54,24 +48,14 @@ import { INotebookEditorProvider, INotebookExporter, INotebookImporter, + INotebookModel, + INotebookModelChange, INotebookServerOptions, 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', 'notebook'); -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 +66,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 _model: INotebookModel | undefined; constructor( @multiInject(IInteractiveWindowListener) listeners: IInteractiveWindowListener[], @@ -111,16 +88,13 @@ 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, @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, @inject(IExperimentsManager) experimentsManager: IExperimentsManager ) { @@ -144,7 +118,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { dataExplorerProvider, jupyterVariables, jupyterDebugger, - editorProvider, errorHandler, commandManager, globalStorage, @@ -165,58 +138,43 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } public get file(): Uri { - return this._file; + if (this._model) { + return this._model.file; + } + return Uri.file(''); } public get isUntitled(): boolean { - const baseName = path.basename(this.file.fsPath); - return baseName.includes(localize.DataScience.untitledNotebookFileName()); + return this._model ? this._model.isUntitled : false; } public dispose(): Promise { super.dispose(); return this.close(); } - public getContents(): Promise { - return this.generateNotebookContent(this.visibleCells); - } - - public get cells(): ICell[] { - return this.visibleCells; - } - - public async load(contents: string, file: Uri): Promise { - // Save our uri - this._file = file; + 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(); // 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)); - - // Update our title to match - this.setTitle(path.basename(file.fsPath)); - - // Show ourselves - await this.show(); + await super.loadWebPanel(path.dirname(this.file.fsPath), webViewPanel); - // 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 + model.changed(this.modelChanged.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, but don't wait for this to finish, otherwise the window won't load. + this.sendInitialCellsToWebView(model.cells) + .then(() => { + // May alread be dirty, if so send a message + if (model.isDirty) { + this.postMessage(InteractiveWindowMessages.NotebookDirty).ignoreErrors(); + } + }) + .catch(exc => traceError('Error loading cells: ', exc)); } public get closed(): Event { @@ -235,12 +193,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._model ? this._model.isDirty : false; } // tslint:disable-next-line: no-any @@ -298,12 +252,16 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } public async getNotebookOptions(): Promise { - const options = await this.ipynbProvider.getNotebookOptions(); - const metadata = this.notebookJson.metadata; - return { - ...options, - metadata - }; + const options = await this.editorProvider.getNotebookOptions(); + if (this._model) { + const metadata = (await this._model.getJson()).metadata; + return { + ...options, + metadata + }; + } else { + return options; + } } public runAllCells() { @@ -320,9 +278,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 { @@ -330,44 +288,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) { @@ -375,7 +295,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, { @@ -409,7 +329,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, @@ -443,7 +363,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 { @@ -458,14 +378,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); @@ -506,444 +428,58 @@ 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; - } + private modelChanged(change: INotebookModelChange) { + if (change.isDirty !== undefined) { + this.modifiedEvent.fire(); + 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) + this.savedEvent.fire(this); - 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 || ''; + // Then tell the UI + return this.postMessage(InteractiveWindowMessages.NotebookClean); } } } - 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 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 sendInitialCellsToWebView(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); - } - - return Promise.all(promises); - } + private async close(): Promise { + // 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) @@ -955,7 +491,8 @@ 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' }); + const content = this._model ? await this._model.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); @@ -977,90 +514,15 @@ 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 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 saveAll(args: ISaveAll) { - this.visibleCells = args.cells; - this.saveToDisk().ignoreErrors(); - } - private logNativeCommand(args: INativeCommand) { const telemetryEvent = args.source === 'mouse' ? NativeMouseCommandTelemetryLookup[args.command] : NativeKeyboardCommandTelemetryLookup[args.command]; sendTelemetryEvent(telemetryEvent); @@ -1074,11 +536,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/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 e17d73ceb25b..99ddb356b6ca 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -2,75 +2,108 @@ // Licensed under the MIT License. 'use strict'; import { inject, injectable } from 'inversify'; -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 * as uuid from 'uuid/v4'; +import { Disposable, Event, EventEmitter, Uri, WebviewPanel } from 'vscode'; +import { arePathsSame } from '../../../datascience-ui/react-common/arePathsSame'; +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'; 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'; +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, IAsyncDisposable { +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; } + public get onDidCloseNotebookEditor(): Event { + return this._onDidCloseNotebookEditor.event; + } private readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); - private activeEditors: Map = new Map(); - private executedEditors: Set = new Set(); + private readonly _onDidCloseNotebookEditor = new EventEmitter(); + private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: INotebookEdit }>(); + private openedEditors: Set = new Set(); + private models = new Map>(); + private modelChangedHandlers: Map = new Map(); private _onDidOpenNotebookEditor = new EventEmitter(); + private executedEditors: Set = new Set(); private notebookCount: number = 0; private openedNotebookCount: number = 0; - private nextNumber: number = 1; - public get onDidOpenNotebookEditor(): Event { - return this._onDidOpenNotebookEditor.event; - } + private _id = uuid(); constructor( @inject(IServiceContainer) private serviceContainer: IServiceContainer, @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, @inject(IDisposableRegistry) private disposables: IDisposableRegistry, - @inject(IWorkspaceService) private workspace: IWorkspaceService, + @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(ICustomEditorService) private customEditorService: ICustomEditorService ) { + traceInfo(`id is ${this._id}`); 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))); + // Register for the custom editor service. + customEditorService.registerWebviewCustomEditorProvider(NativeEditorProvider.customEditorViewType, this, { enableFindWidget: true, retainContextWhenHidden: true }); + } - // 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)); + public save(resource: Uri): Thenable { + return this.loadStorage(resource).then(async s => { + if (s) { + await s.save(); + } + }); + } + public saveAs(resource: Uri, targetResource: Uri): Thenable { + return this.loadStorage(resource).then(async s => { + if (s) { + await s.saveAs(targetResource); } - }, 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 onEdit(): Event<{ readonly resource: Uri; readonly edit: INotebookEdit }> { + return this._editEventEmitter.event; + } + public applyEdits(_resource: Uri, _edits: readonly INotebookEdit[]): Thenable { + return Promise.resolve(); + } + public undoEdits(_resource: Uri, _edits: readonly INotebookEdit[]): Thenable { + return Promise.resolve(); + } + public async resolveWebviewEditor(resource: Uri, panel: WebviewPanel) { + try { + // Get the model + const model = await this.loadModel(resource); + + // Create a new editor + const editor = this.serviceContainer.get(INotebookEditor); + + // 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; + } + + public get onDidOpenNotebookEditor(): Event { + return this._onDidOpenNotebookEditor.event; } public async dispose(): Promise { @@ -86,47 +119,53 @@ 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.editors.find(e => e.visible && e.active); } public get editors(): INotebookEditor[] { - return [...this.activeEditors.values()]; + return [...this.openedEditors]; } - 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 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 (arePathsSame(e.file.fsPath, file.fsPath)) { + if (disposable) { + disposable.dispose(); + } + deferred.resolve(e); + } + }; + disposable = this._onDidOpenNotebookEditor.event(handler); + + // Send an open command. + this.customEditorService.openEditor(file).ignoreErrors(); + + // Promise should resolve when the file opens. + return deferred.promise; } 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.open(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(); + + // Update number of notebooks in the workspace this.notebookCount += 1; - if (contents) { - return this.open(uri, contents); - } else { - return this.open(uri, ''); - } + + // Set these contents into the storage before the file opens + await this.loadStorage(uri, contents); + + return this.open(uri); } public async getNotebookOptions(): Promise { @@ -147,159 +186,82 @@ 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 closedEditor(editor: INotebookEditor): void { + this.openedEditors.delete(editor); + this._onDidCloseNotebookEditor.fire(editor); } - 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)); + 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(this.closedEditor.bind(this)); + this._onDidOpenNotebookEditor.fire(editor); } - private onDidChangeViewState() { + + private onChangedViewState(): void { 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); + private onExecuted(editor: INotebookEditor): void { + if (editor) { + this.executedEditors.add(editor.file.fsPath); } - 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}`)); - } + private async modelChanged(file: Uri, change: INotebookModelChange): Promise { + // If the file changes, update our storage + 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) { + this._editEventEmitter.fire({ resource: file, edit: { contents: change.newCells } }); } - - 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); - } + private async loadModel(file: Uri, contents?: string): Promise { + const modelAndStorage = await this.loadModelAndStorage(file, contents); + return modelAndStorage.model; + } - // Open our own editor. - await this.open(uri, contents); + private async loadStorage(file: Uri, contents?: string): Promise { + const modelAndStorage = await this.loadModelAndStorage(file, contents); + return modelAndStorage.storage; + } - 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); + private loadModelAndStorage(file: Uri, contents?: string) { + const key = file.toString(); + 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))); } - } 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) - ); + return { model: m, storage }; + }); - if (!gitSchemeEditor) { - return false; + this.models.set(key, modelPromise); } + return modelPromise; + } - 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 async getNextNewNotebookUri(): Promise { + // See if we have any untitled storage already + const untitledStorage = [...this.models.keys()].filter(k => Uri.parse(k).scheme === 'untitled'); - // 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'); + // 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 new file mode 100644 index 000000000000..0281b1141699 --- /dev/null +++ b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts @@ -0,0 +1,557 @@ +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'; +import { Event, EventEmitter, Memento, Uri } from 'vscode'; +import { concatMultilineStringInput, splitMultilineString } from '../../../datascience-ui/common'; +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, 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'; +import { IEditCell, IInsertCell, ISwapCells } from '../interactive-common/interactiveWindowTypes'; +import { InvalidNotebookFileError } from '../jupyter/invalidNotebookFileError'; +import { LiveKernelModel } from '../jupyter/kernels/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'); + +const KeyPrefix = 'notebook-storage-'; +const NotebookTransferKey = 'notebook-transfered'; + +interface INativeEditorStorageState { + file: Uri; + cells: ICell[]; + isDirty: boolean; + notebookJson: Partial; +} + +@injectable() +export class NativeEditorStorage implements INotebookModel, INotebookStorage, IDisposable { + public get isDirty(): boolean { + return this._state.isDirty; + } + public get changed(): Event { + return this._changedEmitter.event; + } + public get file(): Uri { + return this._state.file; + } + + 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 _state: INativeEditorStorageState = { file: Uri.file(''), isDirty: false, cells: [], notebookJson: {} }; + private _loadPromise: Promise | undefined; + private indentAmount: string = ' '; + + constructor( + @inject(IDisposableRegistry) disposables: IDisposableRegistry, + @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 + ) { + // Sign up for commands if this is the first storage created. + if (!NativeEditorStorage.signedUpForCommands) { + this.registerCommands(cmdManager, disposables); + } + disposables.push(this); + } + + private static async getStorage(resource: Uri): Promise { + const storage = NativeEditorStorage.storageMap.get(resource.toString()); + if (storage && storage._loadPromise) { + await storage._loadPromise; + return storage; + } + return undefined; + } + + 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] = NativeEditorStorage.asCell(newCell); + 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(s: NativeEditorStorage, id: string): Promise { + // Filter our list + const newCells = [...s.cells].filter(v => v.id !== id); + if (newCells.length !== s.cells.length) { + s.setState({ cells: newCells }); + } + } + + private static async handleSwapCells(s: NativeEditorStorage, request: ISwapCells): Promise { + // Swap two cells in our list + 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] = NativeEditorStorage.asCell(newCells[second]); + newCells[second] = NativeEditorStorage.asCell(temp); + s.setState({ cells: newCells }); + } + } + + private static async handleDeleteAllCells(s: NativeEditorStorage): Promise { + if (s.cells.length !== 0) { + s.setState({ cells: [] }); + } + } + + private static async handleClearAllOutputs(s: NativeEditorStorage): Promise { + const newCells = s.cells.map(c => { + 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 + // to fire an unnecessary change if we can help it. + if (!fastDeepEqual(s.cells, newCells)) { + s.setState({ cells: newCells }); + } + } + + 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] = NativeEditorStorage.asCell(c); + }); + + // Indicate dirty + if (!fastDeepEqual(newCells, s.cells)) { + s.setState({ cells: newCells, isDirty: true }); + } + } + + private static async handleUpdateVersionInfo( + s: NativeEditorStorage, + interpreter: PythonInterpreter | undefined, + kernelSpec: IJupyterKernelSpec | LiveKernelModel | undefined + ): Promise { + // 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 || ''; + } + } + + // 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()); + } + + public async load(file: Uri, possibleContents?: string): Promise { + // Reset the load promise and reload our cells + this._loadPromise = this.loadFromFile(file, possibleContents); + await this._loadPromise; + return this; + } + + 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'); + if (this.isDirty || file.fsPath !== this.file.fsPath) { + this.setState({ isDirty: false, file }); + } + return this; + } + + public async getJson(): Promise> { + await this.ensureNotebookJson(); + return this._state.notebookJson; + } + + public getContent(cells?: ICell[]): Promise { + 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 { + // Save file + this.setState({ file }); + + try { + // Attempt to read the contents if a viable file + const contents = file.scheme === 'untitled' ? possibleContents : await this.fileSystem.readFile(this.file.fsPath); + + // 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 Partial) : 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._state.notebookJson = json; + } + + // Extract cells from the json + const cells = json ? (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 + }; + }); + + // Make sure at least one + if (remapped.length === 0) { + remapped.splice(0, 0, this.createEmptyCell()); + forceDirty = true; + } + + // Save as our visible list + this.setState({ cells: remapped, isDirty: forceDirty }); + + 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._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 = { + 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._state.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._state.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 setState(newState: Partial) { + let changed = false; + const change: INotebookModelChange = { model: this }; + if (newState.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) { + 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()}`; + } + + /** + * 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 + * @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); + + 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)) { + // 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); + } + + 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/interactiveWindow.ts b/src/client/datascience/interactive-window/interactiveWindow.ts index 6c8465055da8..911564318af4 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, @@ -77,7 +76,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, @@ -104,7 +102,6 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi dataExplorerProvider, jupyterVariables, jupyterDebugger, - editorProvider, errorHandler, commandManager, globalStorage, @@ -334,7 +331,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/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 ef7f9ed09d9a..b2b3cd35057a 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)); + } + } 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/messages.ts b/src/client/datascience/messages.ts index b2d17ad18404..c7e7ca14dcf8 100644 --- a/src/client/datascience/messages.ts +++ b/src/client/datascience/messages.ts @@ -2,18 +2,18 @@ // Licensed under the MIT License. 'use strict'; -export namespace CssMessages { - export const GetCssRequest = 'get_css_request'; - export const GetCssResponse = 'get_css_response'; - export const GetMonacoThemeRequest = 'get_monaco_theme_request'; - export const GetMonacoThemeResponse = 'get_monaco_theme_response'; +export enum CssMessages { + GetCssRequest = 'get_css_request', + GetCssResponse = 'get_css_response', + GetMonacoThemeRequest = 'get_monaco_theme_request', + GetMonacoThemeResponse = 'get_monaco_theme_response' } -export namespace SharedMessages { - export const UpdateSettings = 'update_settings'; - export const Started = 'started'; - export const LocInit = 'loc_init'; - export const StyleUpdate = 'style_update'; +export enum SharedMessages { + UpdateSettings = 'update_settings', + Started = 'started', + LocInit = 'loc_init', + StyleUpdate = 'style_update' } export interface IGetCssRequest { diff --git a/src/client/datascience/serviceRegistry.ts b/src/client/datascience/serviceRegistry.ts index 08556f0988c1..cc7d1ca50b2d 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'; @@ -96,6 +96,7 @@ import { INotebookExporter, INotebookImporter, INotebookServer, + INotebookStorage, IPlotViewer, IPlotViewerProvider, IStatusProvider, @@ -128,7 +129,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); @@ -140,6 +140,7 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addBinding(ICellHashProvider, INotebookExecutionLogger); serviceManager.addBinding(IJupyterDebugger, ICellHashListener); serviceManager.addSingleton(INotebookEditorProvider, NativeEditorProvider); + 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 8a6c0f4d883b..1943107e992a 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'; @@ -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'); @@ -305,7 +306,8 @@ export interface INotebookEditorProvider { readonly editors: INotebookEditor[]; readonly onDidOpenNotebookEditor: Event; readonly onDidChangeActiveNotebookEditor: Event; - open(file: Uri, contents: string): Promise; + readonly onDidCloseNotebookEditor: Event; + open(file: Uri): Promise; show(file: Uri): Promise; createNew(contents?: string): Promise; getNotebookOptions(): Promise; @@ -319,7 +321,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. */ @@ -331,7 +332,7 @@ export interface INotebookEditor extends IInteractiveBase { readonly file: Uri; readonly visible: boolean; readonly active: boolean; - load(contents: string, file: Uri): Promise; + load(storage: INotebookModel, webViewPanel: WebviewPanel): Promise; runAllCells(): void; runSelectedCell(): void; addCellBelow(): void; @@ -725,3 +726,35 @@ export interface IJupyterInterpreterDependencyManager { */ installMissingDependencies(err?: JupyterInstallError): Promise; } + +export interface INotebookEdit { + readonly contents: ICell[]; +} + +export interface INotebookModelChange { + model: INotebookModel; + newFile?: Uri; + oldFile?: Uri; + isDirty?: boolean; + isUntitled?: boolean; + newCells?: ICell[]; + oldCells?: ICell[]; +} + +export interface INotebookModel { + readonly file: Uri; + readonly isDirty: boolean; + readonly isUntitled: boolean; + readonly changed: Event; + readonly cells: ICell[]; + getJson(): Promise>; + getContent(cells?: ICell[]): Promise; +} + +export const INotebookStorage = Symbol('INotebookStorage'); + +export interface INotebookStorage { + load(file: Uri, contents?: string): Promise; + save(): Promise; + saveAs(file: Uri): Promise; +} diff --git a/src/client/datascience/webViewHost.ts b/src/client/datascience/webViewHost.ts index 6cd0ba5131a6..b1e90848eb5c 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, traceWarning } from '../common/logger'; @@ -201,7 +201,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; @@ -248,7 +248,8 @@ export class WebViewHost implements IDisposable { scripts: this.scripts, settings, startHttpServer: false, - cwd + cwd, + webViewPanel }); traceInfo('Web view created.'); @@ -297,6 +298,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'); } } diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index fbe9fca4b98b..86b6803cedb7 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -1706,6 +1706,11 @@ 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 }; /** * Telemetry event sent to capture total time taken for completions list to be provided by LS. * This is used to compare against time taken by Jupyter. diff --git a/src/datascience-ui/data-explorer/mainPanel.tsx b/src/datascience-ui/data-explorer/mainPanel.tsx index eaf9d69ed840..ea6eeeac2adf 100644 --- a/src/datascience-ui/data-explorer/mainPanel.tsx +++ b/src/datascience-ui/data-explorer/mainPanel.tsx @@ -94,7 +94,7 @@ export class MainPanel extends React.Component this.postOffice.addHandler(this); // Tell the dataviewer code we have started. - this.postOffice.sendMessage(DataViewerMessages.Started); + this.postOffice.sendMessage(DataViewerMessages.Started); } public componentWillUnmount() { diff --git a/src/datascience-ui/history-react/interactiveCell.tsx b/src/datascience-ui/history-react/interactiveCell.tsx index 8329fcf7867a..ac429959f6b5 100644 --- a/src/datascience-ui/history-react/interactiveCell.tsx +++ b/src/datascience-ui/history-react/interactiveCell.tsx @@ -132,15 +132,7 @@ export class InteractiveCell extends React.Component { // Only render if we are allowed to. if (shouldRender) { return ( -
+
{this.renderControls()}
@@ -209,14 +201,6 @@ export class InteractiveCell extends React.Component { } }; - private onMouseDoubleClick = (ev: React.MouseEvent) => { - // When we receive double click, propagate upwards. Might change our state - if (this.props.doubleClickCell) { - ev.stopPropagation(); - this.props.doubleClickCell(this.props.cellVM.cell.id); - } - }; - private renderControls = () => { const busy = this.props.cellVM.cell.state === CellState.init || this.props.cellVM.cell.state === CellState.executing; const collapseVisible = this.props.cellVM.inputBlockCollapseNeeded && this.props.cellVM.inputBlockShow && !this.props.cellVM.editable && this.isCodeCell(); diff --git a/src/datascience-ui/history-react/redux/actions.ts b/src/datascience-ui/history-react/redux/actions.ts index 94ccc0aa57f9..905c40e733a2 100644 --- a/src/datascience-ui/history-react/redux/actions.ts +++ b/src/datascience-ui/history-react/redux/actions.ts @@ -3,8 +3,9 @@ 'use strict'; import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; -import { IRefreshVariablesRequest } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { InteractiveWindowMessages } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; import { IJupyterVariable, IJupyterVariablesRequest } from '../../../client/datascience/types'; +import { createIncomingAction, createIncomingActionWithPayload } from '../../interactive-common/redux/helpers'; import { CommonAction, CommonActionType, @@ -14,49 +15,41 @@ import { IEditCellAction, ILinkClickAction, IScrollAction, - IShowDataViewerAction, - IShowPlotAction + IShowDataViewerAction } from '../../interactive-common/redux/reducers/types'; // See https://react-redux.js.org/using-react-redux/connect-mapdispatch#defining-mapdispatchtoprops-as-an-object export const actionCreators = { - refreshVariables: (newExecutionCount?: number): CommonAction => ({ type: CommonActionType.REFRESH_VARIABLES, payload: { newExecutionCount } }), - restartKernel: (): CommonAction => ({ type: CommonActionType.RESTART_KERNEL }), - interruptKernel: (): CommonAction => ({ type: CommonActionType.INTERRUPT_KERNEL }), - deleteAllCells: (): CommonAction => ({ type: CommonActionType.DELETE_ALL_CELLS }), - deleteCell: (cellId: string): CommonAction => ({ type: CommonActionType.DELETE_CELL, payload: { cellId } }), - undo: (): CommonAction => ({ type: CommonActionType.UNDO }), - redo: (): CommonAction => ({ type: CommonActionType.REDO }), - linkClick: (href: string): CommonAction => ({ type: CommonActionType.LINK_CLICK, payload: { href } }), - showPlot: (imageHtml: string): CommonAction => ({ type: CommonActionType.SHOW_PLOT, payload: { imageHtml } }), - toggleInputBlock: (cellId: string): CommonAction => ({ type: CommonActionType.TOGGLE_INPUT_BLOCK, payload: { cellId } }), - gotoCell: (cellId: string): CommonAction => ({ type: CommonActionType.GOTO_CELL, payload: { cellId } }), - copyCellCode: (cellId: string): CommonAction => ({ type: CommonActionType.COPY_CELL_CODE, payload: { cellId } }), - gatherCell: (cellId: string): CommonAction => ({ type: CommonActionType.GATHER_CELL, payload: { cellId } }), - clickCell: (cellId: string): CommonAction => ({ type: CommonActionType.CLICK_CELL, payload: { cellId } }), - doubleClickCell: (cellId: string): CommonAction => ({ type: CommonActionType.DOUBLE_CLICK_CELL, payload: { cellId } }), - editCell: (cellId: string, changes: monacoEditor.editor.IModelContentChange[], modelId: string, code: string): CommonAction => ({ - type: CommonActionType.EDIT_CELL, - payload: { cellId, changes, modelId, code } - }), - submitInput: (code: string, cellId: string): CommonAction => ({ type: CommonActionType.SUBMIT_INPUT, payload: { code, cellId } }), - toggleVariableExplorer: (): CommonAction => ({ type: CommonActionType.TOGGLE_VARIABLE_EXPLORER }), - expandAll: (): CommonAction => ({ type: CommonActionType.EXPAND_ALL }), - collapseAll: (): CommonAction => ({ type: CommonActionType.COLLAPSE_ALL }), - export: (): CommonAction => ({ type: CommonActionType.EXPORT }), - showDataViewer: (variable: IJupyterVariable, columnSize: number): CommonAction => ({ - type: CommonActionType.SHOW_DATA_VIEWER, - payload: { variable, columnSize } - }), - editorLoaded: (): CommonAction => ({ type: CommonActionType.EDITOR_LOADED }), - scroll: (isAtBottom: boolean): CommonAction => ({ type: CommonActionType.SCROLL, payload: { isAtBottom } }), - unfocus: (cellId: string | undefined): CommonAction => ({ type: CommonActionType.UNFOCUS_CELL, payload: { cellId } }), - codeCreated: (cellId: string | undefined, modelId: string): CommonAction => ({ type: CommonActionType.CODE_CREATED, payload: { cellId, modelId } }), - editorUnmounted: (): CommonAction => ({ type: CommonActionType.UNMOUNT }), - selectKernel: (): CommonAction => ({ type: CommonActionType.SELECT_KERNEL }), - selectServer: (): CommonAction => ({ type: CommonActionType.SELECT_SERVER }), - getVariableData: (newExecutionCount: number, startIndex: number = 0, pageSize: number = 100): CommonAction => ({ - type: CommonActionType.GET_VARIABLE_DATA, - payload: { executionCount: newExecutionCount, sortColumn: 'name', sortAscending: true, startIndex, pageSize } - }) + restartKernel: (): CommonAction => createIncomingAction(CommonActionType.RESTART_KERNEL), + interruptKernel: (): CommonAction => createIncomingAction(CommonActionType.INTERRUPT_KERNEL), + deleteAllCells: (): CommonAction => createIncomingAction(InteractiveWindowMessages.DeleteAllCells), + deleteCell: (cellId: string): CommonAction => createIncomingActionWithPayload(InteractiveWindowMessages.DeleteCell, { cellId }), + undo: (): CommonAction => createIncomingAction(InteractiveWindowMessages.Undo), + redo: (): CommonAction => createIncomingAction(InteractiveWindowMessages.Redo), + linkClick: (href: string): CommonAction => createIncomingActionWithPayload(CommonActionType.LINK_CLICK, { href }), + showPlot: (imageHtml: string): CommonAction => createIncomingActionWithPayload(InteractiveWindowMessages.ShowPlot, imageHtml), + toggleInputBlock: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.TOGGLE_INPUT_BLOCK, { cellId }), + gotoCell: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.GOTO_CELL, { cellId }), + copyCellCode: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.COPY_CELL_CODE, { cellId }), + gatherCell: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.GATHER_CELL, { cellId }), + clickCell: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.CLICK_CELL, { cellId }), + editCell: (cellId: string, changes: monacoEditor.editor.IModelContentChange[], modelId: string, code: string): CommonAction => + createIncomingActionWithPayload(CommonActionType.EDIT_CELL, { cellId, changes, modelId, code }), + submitInput: (code: string, cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.SUBMIT_INPUT, { code, cellId }), + toggleVariableExplorer: (): CommonAction => createIncomingAction(CommonActionType.TOGGLE_VARIABLE_EXPLORER), + expandAll: (): CommonAction => createIncomingAction(InteractiveWindowMessages.ExpandAll), + collapseAll: (): CommonAction => createIncomingAction(InteractiveWindowMessages.CollapseAll), + export: (): CommonAction => createIncomingAction(CommonActionType.EXPORT), + showDataViewer: (variable: IJupyterVariable, columnSize: number): CommonAction => + createIncomingActionWithPayload(CommonActionType.SHOW_DATA_VIEWER, { variable, columnSize }), + editorLoaded: (): CommonAction => createIncomingAction(CommonActionType.EDITOR_LOADED), + scroll: (isAtBottom: boolean): CommonAction => createIncomingActionWithPayload(CommonActionType.SCROLL, { isAtBottom }), + unfocus: (cellId: string | undefined): CommonAction => createIncomingActionWithPayload(CommonActionType.UNFOCUS_CELL, { cellId }), + codeCreated: (cellId: string | undefined, modelId: string): CommonAction => + createIncomingActionWithPayload(CommonActionType.CODE_CREATED, { cellId, modelId }), + editorUnmounted: (): CommonAction => createIncomingAction(CommonActionType.UNMOUNT), + selectKernel: (): CommonAction => createIncomingAction(InteractiveWindowMessages.SelectKernel), + selectServer: (): CommonAction => createIncomingAction(CommonActionType.SELECT_SERVER), + getVariableData: (newExecutionCount: number, startIndex: number = 0, pageSize: number = 100): CommonAction => + createIncomingActionWithPayload(CommonActionType.GET_VARIABLE_DATA, { executionCount: newExecutionCount, sortColumn: 'name', sortAscending: true, startIndex, pageSize }) }; diff --git a/src/datascience-ui/history-react/redux/mapping.ts b/src/datascience-ui/history-react/redux/mapping.ts index 54d4160e955f..8856ea8a3d52 100644 --- a/src/datascience-ui/history-react/redux/mapping.ts +++ b/src/datascience-ui/history-react/redux/mapping.ts @@ -1,75 +1,18 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 'use strict'; -import { IScrollToCell } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; -import { IGetCssResponse } from '../../../client/datascience/messages'; -import { IGetMonacoThemeResponse } from '../../../client/datascience/monacoMessages'; -import { ICell } from '../../../client/datascience/types'; -import { IMainState, IServerState } from '../../interactive-common/mainState'; -import { IncomingMessageActions } from '../../interactive-common/redux/postOffice'; -import { - CommonActionType, - ICellAction, - ICodeAction, - IEditCellAction, - ILinkClickAction, - IScrollAction, - IShowDataViewerAction, - IShowPlotAction -} from '../../interactive-common/redux/reducers/types'; +import { IInteractiveWindowMapping, InteractiveWindowMessages } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { BaseReduxActionPayload } from '../../../client/datascience/interactive-common/types'; +import { IMainState } from '../../interactive-common/mainState'; +import { CommonActionType, CommonActionTypeMapping } from '../../interactive-common/redux/reducers/types'; import { ReducerArg, ReducerFunc } from '../../react-common/reduxUtils'; -type InteractiveReducerFunc = ReducerFunc; +export type InteractiveReducerFunc = ReducerFunc>; -export type InteractiveReducerArg = ReducerArg; +export type InteractiveReducerArg = ReducerArg>; -export class IInteractiveActionMapping { - public [CommonActionType.RESTART_KERNEL]: InteractiveReducerFunc; - public [CommonActionType.SELECT_KERNEL]: InteractiveReducerFunc; - public [CommonActionType.SELECT_SERVER]: InteractiveReducerFunc; - public [CommonActionType.INTERRUPT_KERNEL]: InteractiveReducerFunc; - public [CommonActionType.EXPORT]: InteractiveReducerFunc; - public [CommonActionType.SAVE]: InteractiveReducerFunc; - public [CommonActionType.UNDO]: InteractiveReducerFunc; - public [CommonActionType.REDO]: InteractiveReducerFunc; - public [CommonActionType.SHOW_DATA_VIEWER]: InteractiveReducerFunc; - public [CommonActionType.DELETE_CELL]: InteractiveReducerFunc; - public [CommonActionType.LINK_CLICK]: InteractiveReducerFunc; - public [CommonActionType.SHOW_PLOT]: InteractiveReducerFunc; - public [CommonActionType.TOGGLE_INPUT_BLOCK]: InteractiveReducerFunc; - public [CommonActionType.GOTO_CELL]: InteractiveReducerFunc; - public [CommonActionType.COPY_CELL_CODE]: InteractiveReducerFunc; - public [CommonActionType.GATHER_CELL]: InteractiveReducerFunc; - public [CommonActionType.EDIT_CELL]: InteractiveReducerFunc; - public [CommonActionType.SUBMIT_INPUT]: InteractiveReducerFunc; - public [CommonActionType.DELETE_ALL_CELLS]: InteractiveReducerFunc; - public [CommonActionType.EXPAND_ALL]: InteractiveReducerFunc; - public [CommonActionType.COLLAPSE_ALL]: InteractiveReducerFunc; - public [CommonActionType.EDITOR_LOADED]: InteractiveReducerFunc; - public [CommonActionType.SCROLL]: InteractiveReducerFunc; - public [CommonActionType.CLICK_CELL]: InteractiveReducerFunc; - public [CommonActionType.UNFOCUS_CELL]: InteractiveReducerFunc; - public [CommonActionType.UNMOUNT]: InteractiveReducerFunc; +type InteractiveWindowReducerFunctions = { + [P in keyof T]: T[P] extends never | undefined ? InteractiveReducerFunc : InteractiveReducerFunc; +}; - // Messages from the extension - public [IncomingMessageActions.STARTCELL]: InteractiveReducerFunc; - public [IncomingMessageActions.FINISHCELL]: InteractiveReducerFunc; - public [IncomingMessageActions.UPDATECELL]: InteractiveReducerFunc; - public [IncomingMessageActions.ACTIVATE]: InteractiveReducerFunc; - public [IncomingMessageActions.RESTARTKERNEL]: InteractiveReducerFunc; - public [IncomingMessageActions.GETCSSRESPONSE]: InteractiveReducerFunc; - public [IncomingMessageActions.MONACOREADY]: InteractiveReducerFunc; - public [IncomingMessageActions.GETMONACOTHEMERESPONSE]: InteractiveReducerFunc; - public [IncomingMessageActions.GETALLCELLS]: InteractiveReducerFunc; - public [IncomingMessageActions.EXPANDALL]: InteractiveReducerFunc; - public [IncomingMessageActions.COLLAPSEALL]: InteractiveReducerFunc; - public [IncomingMessageActions.DELETEALLCELLS]: InteractiveReducerFunc; - public [IncomingMessageActions.STARTPROGRESS]: InteractiveReducerFunc; - public [IncomingMessageActions.STOPPROGRESS]: InteractiveReducerFunc; - public [IncomingMessageActions.UPDATESETTINGS]: InteractiveReducerFunc; - public [IncomingMessageActions.STARTDEBUGGING]: InteractiveReducerFunc; - public [IncomingMessageActions.STOPDEBUGGING]: InteractiveReducerFunc; - public [IncomingMessageActions.SCROLLTOCELL]: InteractiveReducerFunc; - public [IncomingMessageActions.UPDATEKERNEL]: InteractiveReducerFunc; - public [IncomingMessageActions.LOCINIT]: InteractiveReducerFunc; -} +export type IInteractiveActionMapping = InteractiveWindowReducerFunctions & InteractiveWindowReducerFunctions; diff --git a/src/datascience-ui/history-react/redux/reducers/creation.ts b/src/datascience-ui/history-react/redux/reducers/creation.ts index f06baaf97874..e0684a2dbd68 100644 --- a/src/datascience-ui/history-react/redux/reducers/creation.ts +++ b/src/datascience-ui/history-react/redux/reducers/creation.ts @@ -5,9 +5,9 @@ import { Identifiers } from '../../../../client/datascience/constants'; import { InteractiveWindowMessages } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; import { ICell, IDataScienceExtraSettings } from '../../../../client/datascience/types'; import { createCellVM, extractInputText, ICellViewModel, IMainState } from '../../../interactive-common/mainState'; -import { createPostableAction } from '../../../interactive-common/redux/postOffice'; +import { createPostableAction } from '../../../interactive-common/redux/helpers'; import { Helpers } from '../../../interactive-common/redux/reducers/helpers'; -import { ICellAction } from '../../../interactive-common/redux/reducers/types'; +import { IAddCellAction, ICellAction } from '../../../interactive-common/redux/reducers/types'; import { InteractiveReducerArg } from '../mapping'; export namespace Creation { @@ -79,9 +79,9 @@ export namespace Creation { } export function startCell(arg: InteractiveReducerArg): IMainState { - if (isCellSupported(arg.prevState, arg.payload)) { + if (isCellSupported(arg.prevState, arg.payload.data)) { const result = Helpers.updateOrAdd(arg, prepareCellVM); - if (result.cellVMs.length > arg.prevState.cellVMs.length && arg.payload.id !== Identifiers.EditCellId) { + if (result.cellVMs.length > arg.prevState.cellVMs.length && arg.payload.data.id !== Identifiers.EditCellId) { const cellVM = result.cellVMs[result.cellVMs.length - 1]; // We're adding a new cell here. Tell the intellisense engine we have a new cell @@ -100,38 +100,36 @@ export namespace Creation { } export function updateCell(arg: InteractiveReducerArg): IMainState { - if (isCellSupported(arg.prevState, arg.payload)) { + if (isCellSupported(arg.prevState, arg.payload.data)) { return Helpers.updateOrAdd(arg, prepareCellVM); } return arg.prevState; } export function finishCell(arg: InteractiveReducerArg): IMainState { - if (isCellSupported(arg.prevState, arg.payload)) { + if (isCellSupported(arg.prevState, arg.payload.data)) { return Helpers.updateOrAdd(arg, prepareCellVM); } return arg.prevState; } - export function deleteAllCells(arg: InteractiveReducerArg): IMainState { + export function deleteAllCells(arg: InteractiveReducerArg): IMainState { // Send messages to other side to indicate the deletes arg.queueAction(createPostableAction(InteractiveWindowMessages.DeleteAllCells)); return { ...arg.prevState, cellVMs: [], - undoStack: Helpers.pushStack(arg.prevState.undoStack, arg.prevState.cellVMs), - selectedCellId: undefined, - focusedCellId: undefined + undoStack: Helpers.pushStack(arg.prevState.undoStack, arg.prevState.cellVMs) }; } export function deleteCell(arg: InteractiveReducerArg): IMainState { - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); - if (index >= 0 && arg.payload.cellId) { + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); + if (index >= 0 && arg.payload.data.cellId) { // Send messages to other side to indicate the delete arg.queueAction(createPostableAction(InteractiveWindowMessages.DeleteCell)); - arg.queueAction(createPostableAction(InteractiveWindowMessages.RemoveCell, { id: arg.payload.cellId })); + arg.queueAction(createPostableAction(InteractiveWindowMessages.RemoveCell, { id: arg.payload.data.cellId })); const newVMs = arg.prevState.cellVMs.filter((_c, i) => i !== index); return { diff --git a/src/datascience-ui/history-react/redux/reducers/effects.ts b/src/datascience-ui/history-react/redux/reducers/effects.ts index 9c8e56064bd1..5cb22db9ae94 100644 --- a/src/datascience-ui/history-react/redux/reducers/effects.ts +++ b/src/datascience-ui/history-react/redux/reducers/effects.ts @@ -6,7 +6,7 @@ import { IScrollToCell } from '../../../../client/datascience/interactive-common import { CssMessages } from '../../../../client/datascience/messages'; import { IDataScienceExtraSettings } from '../../../../client/datascience/types'; import { IMainState } from '../../../interactive-common/mainState'; -import { createPostableAction } from '../../../interactive-common/redux/postOffice'; +import { createPostableAction } from '../../../interactive-common/redux/helpers'; import { Helpers } from '../../../interactive-common/redux/reducers/helpers'; import { ICellAction, IScrollAction } from '../../../interactive-common/redux/reducers/types'; import { computeEditorOptions } from '../../../react-common/settingsReactSide'; @@ -37,9 +37,9 @@ export namespace Effects { } export function toggleInputBlock(arg: InteractiveReducerArg): IMainState { - if (arg.payload.cellId) { + if (arg.payload.data.cellId) { const newVMs = [...arg.prevState.cellVMs]; - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); const oldVM = arg.prevState.cellVMs[index]; newVMs[index] = Creation.alterCellVM({ ...oldVM }, arg.prevState.settings, true, !oldVM.inputBlockOpen); return { @@ -52,7 +52,7 @@ export namespace Effects { export function updateSettings(arg: InteractiveReducerArg): IMainState { // String arg should be the IDataScienceExtraSettings - const newSettingsJSON = JSON.parse(arg.payload); + const newSettingsJSON = JSON.parse(arg.payload.data); const newSettings = newSettingsJSON; const newEditorOptions = computeEditorOptions(newSettings); const newFontFamily = newSettings.extraSettings ? newSettings.extraSettings.fontFamily : arg.prevState.font.family; @@ -86,7 +86,7 @@ export namespace Effects { export function scrollToCell(arg: InteractiveReducerArg): IMainState { // Up the scroll count on the necessary cell - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.id); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.id); if (index >= 0) { const newVMs = [...arg.prevState.cellVMs]; @@ -105,12 +105,12 @@ export namespace Effects { export function scrolled(arg: InteractiveReducerArg): IMainState { return { ...arg.prevState, - isAtBottom: arg.payload.isAtBottom + isAtBottom: arg.payload.data.isAtBottom }; } export function clickCell(arg: InteractiveReducerArg): IMainState { - if (arg.payload.cellId === Identifiers.EditCellId && arg.prevState.editCellVM && !arg.prevState.editCellVM.focused) { + if (arg.payload.data.cellId === Identifiers.EditCellId && arg.prevState.editCellVM && !arg.prevState.editCellVM.focused) { return { ...arg.prevState, editCellVM: { @@ -132,7 +132,7 @@ export namespace Effects { } export function unfocusCell(arg: InteractiveReducerArg): IMainState { - if (arg.payload.cellId === Identifiers.EditCellId && arg.prevState.editCellVM && arg.prevState.editCellVM.focused) { + if (arg.payload.data.cellId === Identifiers.EditCellId && arg.prevState.editCellVM && arg.prevState.editCellVM.focused) { return { ...arg.prevState, editCellVM: { diff --git a/src/datascience-ui/history-react/redux/reducers/execution.ts b/src/datascience-ui/history-react/redux/reducers/execution.ts index 264e9461dcc8..407911183a67 100644 --- a/src/datascience-ui/history-react/redux/reducers/execution.ts +++ b/src/datascience-ui/history-react/redux/reducers/execution.ts @@ -11,7 +11,7 @@ import { CellState } from '../../../../client/datascience/types'; import { generateMarkdownFromCodeLines } from '../../../common'; import { createCellFrom } from '../../../common/cellFactory'; import { createCellVM, IMainState } from '../../../interactive-common/mainState'; -import { createPostableAction } from '../../../interactive-common/redux/postOffice'; +import { createPostableAction } from '../../../interactive-common/redux/helpers'; import { Helpers } from '../../../interactive-common/redux/reducers/helpers'; import { ICodeAction } from '../../../interactive-common/redux/reducers/types'; import { InteractiveReducerArg } from '../mapping'; @@ -24,16 +24,13 @@ export namespace Execution { const cells = arg.prevState.undoStack[arg.prevState.undoStack.length - 1]; const undoStack = arg.prevState.undoStack.slice(0, arg.prevState.undoStack.length - 1); const redoStack = Helpers.pushStack(arg.prevState.redoStack, arg.prevState.cellVMs); - const selected = cells.findIndex(c => c.selected); arg.queueAction(createPostableAction(InteractiveWindowMessages.Undo)); return { ...arg.prevState, cellVMs: cells, undoStack: undoStack, redoStack: redoStack, - skipNextScroll: true, - selectedCellId: selected >= 0 ? cells[selected].cell.id : undefined, - focusedCellId: selected >= 0 && cells[selected].focused ? cells[selected].cell.id : undefined + skipNextScroll: true }; } @@ -46,16 +43,13 @@ export namespace Execution { const cells = arg.prevState.redoStack[arg.prevState.redoStack.length - 1]; const redoStack = arg.prevState.redoStack.slice(0, arg.prevState.redoStack.length - 1); const undoStack = Helpers.pushStack(arg.prevState.undoStack, arg.prevState.cellVMs); - const selected = cells.findIndex(c => c.selected); arg.queueAction(createPostableAction(InteractiveWindowMessages.Redo)); return { ...arg.prevState, cellVMs: cells, undoStack: undoStack, redoStack: redoStack, - skipNextScroll: true, - selectedCellId: selected >= 0 ? cells[selected].cell.id : undefined, - focusedCellId: selected >= 0 && cells[selected].focused ? cells[selected].cell.id : undefined + skipNextScroll: true }; } @@ -79,16 +73,16 @@ export namespace Execution { export function submitInput(arg: InteractiveReducerArg): IMainState { // noop if the submitted code is just a cell marker const matcher = new CellMatcher(arg.prevState.settings); - if (matcher.stripFirstMarker(arg.payload.code).length > 0 && arg.prevState.editCellVM) { + if (matcher.stripFirstMarker(arg.payload.data.code).length > 0 && arg.prevState.editCellVM) { // This should be from the edit cell VM. Copy it and change the cell id let newCell = cloneDeep(arg.prevState.editCellVM); // Change this editable cell to not editable. newCell.cell.state = CellState.executing; - newCell.cell.data.source = arg.payload.code; + newCell.cell.data.source = arg.payload.data.code; // Change type to markdown if necessary - const split = arg.payload.code.splitLines({ trim: false }); + const split = arg.payload.data.code.splitLines({ trim: false }); const firstLine = split[0]; if (matcher.isMarkdown(firstLine)) { newCell.cell.data = createCellFrom(newCell.cell.data, 'markdown'); @@ -113,7 +107,7 @@ export namespace Execution { // Send a message to execute this code if necessary. if (newCell.cell.state !== CellState.finished) { - arg.queueAction(createPostableAction(InteractiveWindowMessages.SubmitNewCell, { code: arg.payload.code, id: newCell.cell.id })); + arg.queueAction(createPostableAction(InteractiveWindowMessages.SubmitNewCell, { code: arg.payload.data.code, id: newCell.cell.id })); } // Stick in a new cell at the bottom that's editable and update our state diff --git a/src/datascience-ui/history-react/redux/reducers/index.ts b/src/datascience-ui/history-react/redux/reducers/index.ts index da3703085aae..d82b8b200a12 100644 --- a/src/datascience-ui/history-react/redux/reducers/index.ts +++ b/src/datascience-ui/history-react/redux/reducers/index.ts @@ -1,7 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 'use strict'; -import { IncomingMessageActions } from '../../../interactive-common/redux/postOffice'; +import { InteractiveWindowMessages } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { CssMessages, SharedMessages } from '../../../../client/datascience/messages'; import { CommonEffects } from '../../../interactive-common/redux/reducers/commonEffects'; import { Kernel } from '../../../interactive-common/redux/reducers/kernel'; import { Transfer } from '../../../interactive-common/redux/reducers/transfer'; @@ -12,19 +13,17 @@ import { Effects } from './effects'; import { Execution } from './execution'; // The list of reducers. 1 per message/action. -export const reducerMap: IInteractiveActionMapping = { +export const reducerMap: Partial = { // State updates [CommonActionType.RESTART_KERNEL]: Kernel.restartKernel, [CommonActionType.INTERRUPT_KERNEL]: Kernel.interruptKernel, - [CommonActionType.SELECT_KERNEL]: Kernel.selectKernel, + [InteractiveWindowMessages.SelectKernel]: Kernel.selectKernel, [CommonActionType.SELECT_SERVER]: Kernel.selectJupyterURI, [CommonActionType.EXPORT]: Transfer.exportCells, [CommonActionType.SAVE]: Transfer.save, [CommonActionType.SHOW_DATA_VIEWER]: Transfer.showDataViewer, - [CommonActionType.DELETE_CELL]: Creation.deleteCell, - [CommonActionType.UNDO]: Execution.undo, - [CommonActionType.REDO]: Execution.redo, - [CommonActionType.SHOW_PLOT]: Transfer.showPlot, + [InteractiveWindowMessages.DeleteCell]: Creation.deleteCell, + [InteractiveWindowMessages.ShowPlot]: Transfer.showPlot, [CommonActionType.LINK_CLICK]: Transfer.linkClick, [CommonActionType.GOTO_CELL]: Transfer.gotoCell, [CommonActionType.TOGGLE_INPUT_BLOCK]: Effects.toggleInputBlock, @@ -32,9 +31,7 @@ export const reducerMap: IInteractiveActionMapping = { [CommonActionType.GATHER_CELL]: Transfer.gather, [CommonActionType.EDIT_CELL]: Transfer.editCell, [CommonActionType.SUBMIT_INPUT]: Execution.submitInput, - [CommonActionType.DELETE_ALL_CELLS]: Creation.deleteAllCells, - [CommonActionType.EXPAND_ALL]: Effects.expandAll, - [CommonActionType.COLLAPSE_ALL]: Effects.collapseAll, + [InteractiveWindowMessages.ExpandAll]: Effects.expandAll, [CommonActionType.EDITOR_LOADED]: Transfer.started, [CommonActionType.SCROLL]: Effects.scrolled, [CommonActionType.CLICK_CELL]: Effects.clickCell, @@ -42,24 +39,26 @@ export const reducerMap: IInteractiveActionMapping = { [CommonActionType.UNMOUNT]: Creation.unmount, // Messages from the webview (some are ignored) - [IncomingMessageActions.STARTCELL]: Creation.startCell, - [IncomingMessageActions.FINISHCELL]: Creation.finishCell, - [IncomingMessageActions.UPDATECELL]: Creation.updateCell, - [IncomingMessageActions.ACTIVATE]: CommonEffects.activate, - [IncomingMessageActions.RESTARTKERNEL]: Kernel.handleRestarted, - [IncomingMessageActions.GETCSSRESPONSE]: CommonEffects.handleCss, - [IncomingMessageActions.MONACOREADY]: CommonEffects.monacoReady, - [IncomingMessageActions.GETMONACOTHEMERESPONSE]: CommonEffects.monacoThemeChange, - [IncomingMessageActions.GETALLCELLS]: Transfer.getAllCells, - [IncomingMessageActions.EXPANDALL]: Effects.expandAll, - [IncomingMessageActions.COLLAPSEALL]: Effects.collapseAll, - [IncomingMessageActions.DELETEALLCELLS]: Creation.deleteAllCells, - [IncomingMessageActions.STARTPROGRESS]: CommonEffects.startProgress, - [IncomingMessageActions.STOPPROGRESS]: CommonEffects.stopProgress, - [IncomingMessageActions.UPDATESETTINGS]: Effects.updateSettings, - [IncomingMessageActions.STARTDEBUGGING]: Execution.startDebugging, - [IncomingMessageActions.STOPDEBUGGING]: Execution.stopDebugging, - [IncomingMessageActions.SCROLLTOCELL]: Effects.scrollToCell, - [IncomingMessageActions.UPDATEKERNEL]: Kernel.updateStatus, - [IncomingMessageActions.LOCINIT]: CommonEffects.handleLocInit + [InteractiveWindowMessages.Undo]: Execution.undo, + [InteractiveWindowMessages.Redo]: Execution.redo, + [InteractiveWindowMessages.StartCell]: Creation.startCell, + [InteractiveWindowMessages.FinishCell]: Creation.finishCell, + [InteractiveWindowMessages.UpdateCell]: Creation.updateCell, + [InteractiveWindowMessages.Activate]: CommonEffects.activate, + [InteractiveWindowMessages.RestartKernel]: Kernel.handleRestarted, + [CssMessages.GetCssResponse]: CommonEffects.handleCss, + [InteractiveWindowMessages.MonacoReady]: CommonEffects.monacoReady, + [CssMessages.GetMonacoThemeResponse]: CommonEffects.monacoThemeChange, + [InteractiveWindowMessages.GetAllCells]: Transfer.getAllCells, + [InteractiveWindowMessages.ExpandAll]: Effects.expandAll, + [InteractiveWindowMessages.CollapseAll]: Effects.collapseAll, + [InteractiveWindowMessages.DeleteAllCells]: Creation.deleteAllCells, + [InteractiveWindowMessages.StartProgress]: CommonEffects.startProgress, + [InteractiveWindowMessages.StopProgress]: CommonEffects.stopProgress, + [SharedMessages.UpdateSettings]: Effects.updateSettings, + [InteractiveWindowMessages.StartDebugging]: Execution.startDebugging, + [InteractiveWindowMessages.StopDebugging]: Execution.stopDebugging, + [InteractiveWindowMessages.ScrollToCell]: Effects.scrollToCell, + [InteractiveWindowMessages.UpdateKernel]: Kernel.updateStatus, + [SharedMessages.LocInit]: CommonEffects.handleLocInit }; diff --git a/src/datascience-ui/interactive-common/mainState.ts b/src/datascience-ui/interactive-common/mainState.ts index f4e65a80b883..5e2e865e2d65 100644 --- a/src/datascience-ui/interactive-common/mainState.ts +++ b/src/datascience-ui/interactive-common/mainState.ts @@ -60,8 +60,6 @@ export type IMainState = { currentExecutionCount: number; debugging: boolean; dirty?: boolean; - selectedCellId?: string; - focusedCellId?: string; isAtBottom: boolean; newCellId?: string; loadTotal?: number; @@ -75,6 +73,29 @@ export type IMainState = { kernel: IServerState; }; +/** + * Returns the cell id and index of selected and focused cells. + */ +export function getSelectedAndFocusedInfo(state: IMainState) { + const info: { selectedCellId?: string; selectedCellIndex?: number; focusedCellId?: string; focusedCellIndex?: number } = {}; + for (let index = 0; index < state.cellVMs.length; index += 1) { + const cell = state.cellVMs[index]; + if (cell.selected) { + info.selectedCellId = cell.cell.id; + info.selectedCellIndex = index; + } + if (cell.focused) { + info.focusedCellId = cell.cell.id; + info.focusedCellIndex = index; + } + if (info.selectedCellId && info.focusedCellId) { + break; + } + } + + return info; +} + export interface IFont { size: number; family: string; diff --git a/src/datascience-ui/interactive-common/redux/helpers.ts b/src/datascience-ui/interactive-common/redux/helpers.ts new file mode 100644 index 000000000000..685b3c251d96 --- /dev/null +++ b/src/datascience-ui/interactive-common/redux/helpers.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import * as Redux from 'redux'; +import { IInteractiveWindowMapping, InteractiveWindowMessages } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { BaseReduxActionPayload } from '../../../client/datascience/interactive-common/types'; +import { CssMessages, SharedMessages } from '../../../client/datascience/messages'; +import { CommonAction, CommonActionType } from './reducers/types'; + +const AllowedMessages = [...Object.values(InteractiveWindowMessages), ...Object.values(CssMessages), ...Object.values(SharedMessages), ...Object.values(CommonActionType)]; +export function isAllowedMessage(message: string) { + // tslint:disable-next-line: no-any + return AllowedMessages.includes(message as any); +} +export function isAllowedAction(action: Redux.AnyAction) { + return isAllowedMessage(action.type); +} + +export function createIncomingActionWithPayload(type: CommonActionType | InteractiveWindowMessages, data: T): CommonAction { + // tslint:disable-next-line: no-any + return { type, payload: ({ data, messageDirection: 'incoming' } as any) as BaseReduxActionPayload }; +} +export function createIncomingAction(type: CommonActionType | InteractiveWindowMessages): CommonAction { + return { type, payload: { messageDirection: 'incoming', data: undefined } }; +} + +// Actions created from messages +export function createPostableAction(message: T, payload?: M[T]): Redux.AnyAction { + const newPayload: BaseReduxActionPayload = ({ + data: payload, + messageDirection: 'outgoing' + // tslint:disable-next-line: no-any + } as any) as BaseReduxActionPayload; + return { type: CommonActionType.PostOutgoingMessage, payload: { payload: newPayload, type: message } }; +} +export function unwrapPostableAction(action: Redux.AnyAction): { type: keyof IInteractiveWindowMapping; payload?: BaseReduxActionPayload<{}> } { + // Unwrap the payload that was created in `createPostableAction`. + const type = action.type; + const payload: BaseReduxActionPayload<{}> | undefined = action.payload; + return { type, payload }; +} + +export function reBroadcastMessageIfRequired( + _dispatcher: Function, + message: InteractiveWindowMessages | SharedMessages | CommonActionType | CssMessages, + payload?: BaseReduxActionPayload<{}> +) { + if (typeof payload?.messageType === 'number' || message === InteractiveWindowMessages.Sync) { + return; + } + if (payload?.messageDirection === 'outgoing') { + return; + } + // Temporarily disabled. + // // Check if we need to re-broadcast this message to other editors/sessions. + // // tslint:disable-next-line: no-any + // const result = shouldRebroadcast(message as any); + // if (result[0]) { + // // Mark message as incoming, to indicate this will be sent into the other webviews. + // // tslint:disable-next-line: no-any + // const syncPayloadData: BaseReduxActionPayload = { data: payload?.data, messageType: result[1], messageDirection: 'incoming' }; + // // tslint:disable-next-line: no-any + // const syncPayload = { type: message, payload: syncPayloadData } as any; + // // Send this out. + // dispatcher(InteractiveWindowMessages.Sync, syncPayload); + // } +} diff --git a/src/datascience-ui/interactive-common/redux/postOffice.ts b/src/datascience-ui/interactive-common/redux/postOffice.ts index 1a7891eef9e3..a7831b4eeb8e 100644 --- a/src/datascience-ui/interactive-common/redux/postOffice.ts +++ b/src/datascience-ui/interactive-common/redux/postOffice.ts @@ -3,114 +3,33 @@ 'use strict'; import * as Redux from 'redux'; -import { IInteractiveWindowMapping, InteractiveWindowMessages } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; -import { CssMessages, SharedMessages } from '../../../client/datascience/messages'; +import { IInteractiveWindowMapping } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { BaseReduxActionPayload } from '../../../client/datascience/interactive-common/types'; import { PostOffice } from '../../react-common/postOffice'; - -// Action types for Incoming messages. Basically all possible messages prefixed with the word 'action' -// This allows us to have a reducer for an incoming message and a separate reducer for an outgoing message. -// Note: Couldn't figure out a way to just generate this from the keys of the InteractiveWindowMessages. -// String literals can't come from a concat of another -export enum IncomingMessageActions { - // tslint:disable-next-line: prefer-template - STARTCELL = 'action.start_cell', - FINISHCELL = 'action.finish_cell', - UPDATECELL = 'action.update_cell', - GOTOCODECELL = 'action.gotocell_code', - COPYCODECELL = 'action.copycell_code', - RESTARTKERNEL = 'action.restart_kernel', - EXPORT = 'action.export_to_ipynb', - GETALLCELLS = 'action.get_all_cells', - RETURNALLCELLS = 'action.return_all_cells', - DELETECELL = 'action.delete_cell', - DELETEALLCELLS = 'action.delete_all_cells', - UNDO = 'action.undo', - REDO = 'action.redo', - EXPANDALL = 'action.expand_all', - COLLAPSEALL = 'action.collapse_all', - STARTPROGRESS = 'action.start_progress', - STOPPROGRESS = 'action.stop_progress', - INTERRUPT = 'action.interrupt', - SUBMITNEWCELL = 'action.submit_new_cell', - UPDATESETTINGS = 'action.update_settings', - DOSAVE = 'action.DoSave', - SENDINFO = 'action.send_info', - STARTED = 'action.started', - ADDEDSYSINFO = 'action.added_sys_info', - REMOTEADDCODE = 'action.remote_add_code', - REMOTEREEXECUTECODE = 'action.remote_reexecute_code', - ACTIVATE = 'action.activate', - SHOWDATAVIEWER = 'action.show_data_explorer', - GETVARIABLESREQUEST = 'ACTION.GET_VARIABLES_REQUEST', - GETVARIABLESRESPONSE = 'action.get_variables_response', - GETVARIABLEVALUEREQUEST = 'action.get_variable_value_request', - GETVARIABLEVALUERESPONSE = 'action.get_variable_value_response', - VARIABLEEXPLORERTOGGLE = 'action.variable_explorer_toggle', - PROVIDECOMPLETIONITEMSREQUEST = 'action.provide_completion_items_request', - CANCELCOMPLETIONITEMSREQUEST = 'action.cancel_completion_items_request', - PROVIDECOMPLETIONITEMSRESPONSE = 'action.provide_completion_items_response', - PROVIDEHOVERREQUEST = 'action.provide_hover_request', - CANCELHOVERREQUEST = 'action.cancel_hover_request', - PROVIDEHOVERRESPONSE = 'action.provide_hover_response', - PROVIDESIGNATUREHELPREQUEST = 'action.provide_signature_help_request', - CANCELSIGNATUREHELPREQUEST = 'action.cancel_signature_help_request', - PROVIDESIGNATUREHELPRESPONSE = 'action.provide_signature_help_response', - RESOLVECOMPLETIONITEMREQUEST = 'action.resolve_completion_item_request', - CANCELRESOLVECOMPLETIONITEMREQUEST = 'action.cancel_completion_items_request', - RESOLVECOMPLETIONITEMRESPONSE = 'action.resolve_completion_item_response', - ADDCELL = 'action.add_cell', - EDITCELL = 'action.edit_cell', - REMOVECELL = 'action.remove_cell', - SWAPCELLS = 'action.swap_cells', - INSERTCELL = 'action.insert_cell', - LOADONIGASMASSEMBLYREQUEST = 'action.load_onigasm_assembly_request', - LOADONIGASMASSEMBLYRESPONSE = 'action.load_onigasm_assembly_response', - LOADTMLANGUAGEREQUEST = 'action.load_tmlanguage_request', - LOADTMLANGUAGERESPONSE = 'action.load_tmlanguage_response', - OPENLINK = 'action.open_link', - SHOWPLOT = 'action.show_plot', - STARTDEBUGGING = 'action.start_debugging', - STOPDEBUGGING = 'action.stop_debugging', - GATHERCODE = 'action.gather_code', - LOADALLCELLS = 'action.load_all_cells', - LOADALLCELLSCOMPLETE = 'action.load_all_cells_complete', - SCROLLTOCELL = 'action.scroll_to_cell', - REEXECUTECELL = 'action.reexecute_cell', - NOTEBOOKIDENTITY = 'action.identity', - NOTEBOOKDIRTY = 'action.dirty', - NOTEBOOKCLEAN = 'action.clean', - SAVEALL = 'action.save_all', - NATIVECOMMAND = 'action.native_command', - VARIABLESCOMPLETE = 'action.variables_complete', - NOTEBOOKRUNALLCELLS = 'action.notebook_run_all_cells', - NOTEBOOKRUNSELECTEDCELL = 'action.notebook_run_selected_cell', - NOTEBOOKADDCELLBELOW = 'action.notebook_add_cell_below', - RENDERCOMPLETE = 'action.finished_rendering_cells', - FOCUSEDCELLEDITOR = 'action.focused_cell_editor', - MONACOREADY = 'action.monaco_ready', - GETCSSREQUEST = 'action.get_css_request', - GETCSSRESPONSE = 'action.get_css_response', - GETMONACOTHEMEREQUEST = 'action.get_monaco_theme_request', - GETMONACOTHEMERESPONSE = 'action.get_monaco_theme_response', - UPDATEKERNEL = 'action.update_kernel', - LOCINIT = 'action.loc_init' -} - -export const AllowedMessages = [...Object.values(InteractiveWindowMessages), ...Object.values(CssMessages), ...Object.values(SharedMessages)]; - -// Actions created from messages -export function createPostableAction(message: T, payload?: M[T]): Redux.AnyAction { - return { type: `${message}`, payload }; -} +import { isAllowedAction, reBroadcastMessageIfRequired, unwrapPostableAction } from './helpers'; +import { CommonActionType } from './reducers/types'; export function generatePostOfficeSendReducer(postOffice: PostOffice): Redux.Reducer<{}, Redux.AnyAction> { // tslint:disable-next-line: no-function-expression return function(_state: {} | undefined, action: Redux.AnyAction): {} { - // Make sure a valid message - if (AllowedMessages.find(k => k === action.type)) { - // Just post this to the post office. - // tslint:disable-next-line: no-any - postOffice.sendMessage(action.type, action.payload); + if (isAllowedAction(action)) { + // Make sure a valid message + if (action.type === CommonActionType.PostOutgoingMessage) { + const { type, payload } = unwrapPostableAction(action.payload); + // Just post this to the post office. + // tslint:disable-next-line: no-any + postOffice.sendMessage(type, payload?.data as any); + } else { + const payload: BaseReduxActionPayload<{}> | undefined = action.payload; + // Do not rebroadcast messages that have been sent through as part of a synchronization packet. + // If `messageType` is a number, then its some part of a synchronization packet. + if (payload?.messageDirection === 'incoming' && typeof payload?.messageType !== 'number') { + // We can delay this, first focus on UX perf. + setTimeout(() => { + reBroadcastMessageIfRequired(postOffice.sendMessage.bind(postOffice), action.type, action?.payload); + }, 1); + } + } } // We don't modify the state. diff --git a/src/datascience-ui/interactive-common/redux/reducers/commonEffects.ts b/src/datascience-ui/interactive-common/redux/reducers/commonEffects.ts index 33e3133f116f..cbc7f2f7f33d 100644 --- a/src/datascience-ui/interactive-common/redux/reducers/commonEffects.ts +++ b/src/datascience-ui/interactive-common/redux/reducers/commonEffects.ts @@ -7,52 +7,52 @@ import { IGetMonacoThemeResponse } from '../../../../client/datascience/monacoMe import { IMainState } from '../../../interactive-common/mainState'; import { Helpers } from '../../../interactive-common/redux/reducers/helpers'; import { storeLocStrings } from '../../../react-common/locReactSide'; -import { CommonReducerArg } from './types'; +import { CommonActionType, CommonReducerArg } from './types'; export namespace CommonEffects { - export function notebookDirty(arg: CommonReducerArg): IMainState { + export function notebookDirty(arg: CommonReducerArg): IMainState { return { ...arg.prevState, dirty: true }; } - export function notebookClean(arg: CommonReducerArg): IMainState { + export function notebookClean(arg: CommonReducerArg): IMainState { return { ...arg.prevState, dirty: false }; } - export function startProgress(arg: CommonReducerArg): IMainState { + export function startProgress(arg: CommonReducerArg): IMainState { return { ...arg.prevState, busy: true }; } - export function stopProgress(arg: CommonReducerArg): IMainState { + export function stopProgress(arg: CommonReducerArg): IMainState { return { ...arg.prevState, busy: false }; } - export function activate(arg: CommonReducerArg): IMainState { + export function activate(arg: CommonReducerArg): IMainState { return { ...arg.prevState, activateCount: arg.prevState.activateCount + 1 }; } - export function handleLocInit(arg: CommonReducerArg): IMainState { + export function handleLocInit(arg: CommonReducerArg): IMainState { // Read in the loc strings - const locJSON = JSON.parse(arg.payload); + const locJSON = JSON.parse(arg.payload.data); storeLocStrings(locJSON); return arg.prevState; } - export function handleCss(arg: CommonReducerArg): IMainState { + export function handleCss(arg: CommonReducerArg): IMainState { // Recompute our known dark value from the class name in the body // VS code should update this dynamically when the theme changes const computedKnownDark = Helpers.computeKnownDark(arg.prevState.settings); @@ -66,27 +66,27 @@ export namespace CommonEffects { let fontFamily: string = "Consolas, 'Courier New', monospace"; const sizeSetting = '--code-font-size: '; const familySetting = '--code-font-family: '; - const fontSizeIndex = arg.payload.css.indexOf(sizeSetting); - const fontFamilyIndex = arg.payload.css.indexOf(familySetting); + const fontSizeIndex = arg.payload.data.css.indexOf(sizeSetting); + const fontFamilyIndex = arg.payload.data.css.indexOf(familySetting); if (fontSizeIndex > -1) { - const fontSizeEndIndex = arg.payload.css.indexOf('px;', fontSizeIndex + sizeSetting.length); - fontSize = parseInt(arg.payload.css.substring(fontSizeIndex + sizeSetting.length, fontSizeEndIndex), 10); + const fontSizeEndIndex = arg.payload.data.css.indexOf('px;', fontSizeIndex + sizeSetting.length); + fontSize = parseInt(arg.payload.data.css.substring(fontSizeIndex + sizeSetting.length, fontSizeEndIndex), 10); } if (fontFamilyIndex > -1) { - const fontFamilyEndIndex = arg.payload.css.indexOf(';', fontFamilyIndex + familySetting.length); - fontFamily = arg.payload.css.substring(fontFamilyIndex + familySetting.length, fontFamilyEndIndex); + const fontFamilyEndIndex = arg.payload.data.css.indexOf(';', fontFamilyIndex + familySetting.length); + fontFamily = arg.payload.data.css.substring(fontFamilyIndex + familySetting.length, fontFamilyEndIndex); } return { ...arg.prevState, - rootCss: arg.payload.css, + rootCss: arg.payload.data.css, font: { size: fontSize, family: fontFamily }, - vscodeThemeName: arg.payload.theme, + vscodeThemeName: arg.payload.data.theme, knownDark: computedKnownDark, baseTheme: newBaseTheme }; diff --git a/src/datascience-ui/interactive-common/redux/reducers/helpers.ts b/src/datascience-ui/interactive-common/redux/reducers/helpers.ts index 8653be19a67c..d5205fe7639a 100644 --- a/src/datascience-ui/interactive-common/redux/reducers/helpers.ts +++ b/src/datascience-ui/interactive-common/redux/reducers/helpers.ts @@ -9,7 +9,7 @@ import { ICell, IDataScienceExtraSettings } from '../../../../client/datascience import { arePathsSame } from '../../../react-common/arePathsSame'; import { detectBaseTheme } from '../../../react-common/themeDetector'; import { ICellViewModel, IMainState } from '../../mainState'; -import { CommonReducerArg } from './types'; +import { CommonActionType, CommonReducerArg } from './types'; const StackLimit = 10; @@ -44,14 +44,14 @@ export namespace Helpers { return cvm as ICellViewModel; } - export function updateOrAdd(arg: CommonReducerArg, generateVM: (cell: ICell, mainState: IMainState) => ICellViewModel): IMainState { + export function updateOrAdd(arg: CommonReducerArg, generateVM: (cell: ICell, mainState: IMainState) => ICellViewModel): IMainState { // First compute new execution count. - const newExecutionCount = arg.payload.data.execution_count - ? Math.max(arg.prevState.currentExecutionCount, parseInt(arg.payload.data.execution_count.toString(), 10)) + const newExecutionCount = arg.payload.data.data.execution_count + ? Math.max(arg.prevState.currentExecutionCount, parseInt(arg.payload.data.data.execution_count.toString(), 10)) : arg.prevState.currentExecutionCount; const index = arg.prevState.cellVMs.findIndex((c: ICellViewModel) => { - return c.cell.id === arg.payload.id && c.cell.line === arg.payload.line && arePathsSame(c.cell.file, arg.payload.file); + return c.cell.id === arg.payload.data.id && c.cell.line === arg.payload.data.line && arePathsSame(c.cell.file, arg.payload.data.file); }); if (index >= 0) { // This means the cell existed already so it was actual executed code. @@ -71,9 +71,9 @@ export namespace Helpers { ...newVMs[index], cell: { ...newVMs[index].cell, - state: arg.payload.state, + state: arg.payload.data.state, data: { - ...arg.payload.data, + ...arg.payload.data.data, source: newVMs[index].cell.data.source } } @@ -87,7 +87,7 @@ export namespace Helpers { }; } else { // This is an entirely new cell (it may have started out as finished) - const newVM = generateVM(arg.payload, arg.prevState); + const newVM = generateVM(arg.payload.data, arg.prevState); const newVMs = [...arg.prevState.cellVMs, newVM]; return { ...arg.prevState, diff --git a/src/datascience-ui/interactive-common/redux/reducers/kernel.ts b/src/datascience-ui/interactive-common/redux/reducers/kernel.ts index 4c232516a5a2..924a2489450b 100644 --- a/src/datascience-ui/interactive-common/redux/reducers/kernel.ts +++ b/src/datascience-ui/interactive-common/redux/reducers/kernel.ts @@ -4,21 +4,22 @@ import { InteractiveWindowMessages } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; import { CellState } from '../../../../client/datascience/types'; import { IMainState, IServerState } from '../../mainState'; -import { createPostableAction } from '../postOffice'; -import { CommonReducerArg } from './types'; +import { createPostableAction } from '../helpers'; +import { CommonActionType, CommonReducerArg } from './types'; export namespace Kernel { - export function selectKernel(arg: CommonReducerArg): IMainState { + // tslint:disable-next-line: no-any + export function selectKernel(arg: CommonReducerArg): IMainState { arg.queueAction(createPostableAction(InteractiveWindowMessages.SelectKernel)); return arg.prevState; } - export function selectJupyterURI(arg: CommonReducerArg): IMainState { + export function selectJupyterURI(arg: CommonReducerArg): IMainState { arg.queueAction(createPostableAction(InteractiveWindowMessages.SelectJupyterServer)); return arg.prevState; } - export function restartKernel(arg: CommonReducerArg): IMainState { + export function restartKernel(arg: CommonReducerArg): IMainState { arg.queueAction(createPostableAction(InteractiveWindowMessages.RestartKernel)); // Set busy until kernel is restarted @@ -28,7 +29,7 @@ export namespace Kernel { }; } - export function interruptKernel(arg: CommonReducerArg): IMainState { + export function interruptKernel(arg: CommonReducerArg): IMainState { arg.queueAction(createPostableAction(InteractiveWindowMessages.Interrupt)); // Set busy until kernel is finished interrupting @@ -38,13 +39,13 @@ export namespace Kernel { }; } - export function updateStatus(arg: CommonReducerArg): IMainState { + export function updateStatus(arg: CommonReducerArg): IMainState { return { ...arg.prevState, kernel: { - localizedUri: arg.payload.localizedUri, - jupyterServerStatus: arg.payload.jupyterServerStatus, - displayName: arg.payload.displayName + localizedUri: arg.payload.data.localizedUri, + jupyterServerStatus: arg.payload.data.jupyterServerStatus, + displayName: arg.payload.data.displayName } }; } diff --git a/src/datascience-ui/interactive-common/redux/reducers/monaco.ts b/src/datascience-ui/interactive-common/redux/reducers/monaco.ts index f3d2bbcf874a..fc559bcaacfb 100644 --- a/src/datascience-ui/interactive-common/redux/reducers/monaco.ts +++ b/src/datascience-ui/interactive-common/redux/reducers/monaco.ts @@ -12,13 +12,15 @@ import { IProvideSignatureHelpResponse, IResolveCompletionItemResponse } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { BaseReduxActionPayload } from '../../../../client/datascience/interactive-common/types'; +import { CssMessages } from '../../../../client/datascience/messages'; import { IGetMonacoThemeResponse } from '../../../../client/datascience/monacoMessages'; import { logMessage } from '../../../react-common/logger'; import { PostOffice } from '../../../react-common/postOffice'; import { combineReducers, QueuableAction, ReducerArg, ReducerFunc } from '../../../react-common/reduxUtils'; import { IntellisenseProvider } from '../../intellisenseProvider'; import { initializeTokenizer, registerMonacoLanguage } from '../../tokenizer'; -import { IncomingMessageActions } from '../postOffice'; +import { createIncomingAction } from '../helpers'; import { CommonActionType, ICodeCreatedAction, IEditCellAction } from './types'; export interface IMonacoState { @@ -29,11 +31,11 @@ export interface IMonacoState { postOffice: PostOffice; } -type MonacoReducerFunc = ReducerFunc; +type MonacoReducerFunc = ReducerFunc>; -type MonacoReducerArg = ReducerArg; +type MonacoReducerArg = ReducerArg>; -function handleStarted(arg: MonacoReducerArg): IMonacoState { +function handleStarted(arg: MonacoReducerArg): IMonacoState { // If in test mode, register the monaco provider if (arg.prevState.testMode) { registerMonacoLanguage(); @@ -61,14 +63,14 @@ function finishTokenizer(buffer: ArrayBuffer, tmJson: string, arg: MonacoRedu if (e) { logMessage(`ERROR from onigasm: ${e}`); } - arg.queueAction({ type: IncomingMessageActions.MONACOREADY }); + arg.queueAction(createIncomingAction(InteractiveWindowMessages.MonacoReady)); }).ignoreErrors(); } function handleLoadOnigasmResponse(arg: MonacoReducerArg): IMonacoState { // Have to convert the buffer into an ArrayBuffer for the tokenizer to load it. // tslint:disable-next-line: no-any - const typedArray = new Uint8Array((arg.payload as any).data); + const typedArray = new Uint8Array((arg.payload.data as any).data); if (arg.prevState.tmLanguageData && !arg.prevState.onigasmData && typedArray.length > 0) { // Monaco is ready. Initialize the tokenizer @@ -85,57 +87,57 @@ function handleLoadOnigasmResponse(arg: MonacoReducerArg): IMonacoState function handleLoadTmLanguageResponse(arg: MonacoReducerArg): IMonacoState { if (arg.prevState.onigasmData && !arg.prevState.tmLanguageData) { // Monaco is ready. Initialize the tokenizer - finishTokenizer(arg.prevState.onigasmData, arg.payload, arg); + finishTokenizer(arg.prevState.onigasmData, arg.payload.data, arg); } return { ...arg.prevState, - tmLanguageData: arg.payload + tmLanguageData: arg.payload.data }; } function handleThemeResponse(arg: MonacoReducerArg): IMonacoState { // Tell monaco we have a new theme. THis is like a state update for monaco - monacoEditor.editor.defineTheme(Identifiers.GeneratedThemeName, arg.payload.theme); + monacoEditor.editor.defineTheme(Identifiers.GeneratedThemeName, arg.payload.data.theme); return arg.prevState; } function handleCompletionItemsResponse(arg: MonacoReducerArg): IMonacoState { const ensuredProvider = handleStarted(arg); - ensuredProvider.intellisenseProvider!.handleCompletionResponse(arg.payload); + ensuredProvider.intellisenseProvider!.handleCompletionResponse(arg.payload.data); return ensuredProvider; } function handleResolveCompletionItemResponse(arg: MonacoReducerArg): IMonacoState { const ensuredProvider = handleStarted(arg); - ensuredProvider.intellisenseProvider!.handleResolveCompletionItemResponse(arg.payload); + ensuredProvider.intellisenseProvider!.handleResolveCompletionItemResponse(arg.payload.data); return ensuredProvider; } function handleSignatureHelpResponse(arg: MonacoReducerArg): IMonacoState { const ensuredProvider = handleStarted(arg); - ensuredProvider.intellisenseProvider!.handleSignatureHelpResponse(arg.payload); + ensuredProvider.intellisenseProvider!.handleSignatureHelpResponse(arg.payload.data); return ensuredProvider; } function handleHoverResponse(arg: MonacoReducerArg): IMonacoState { const ensuredProvider = handleStarted(arg); - ensuredProvider.intellisenseProvider!.handleHoverResponse(arg.payload); + ensuredProvider.intellisenseProvider!.handleHoverResponse(arg.payload.data); return ensuredProvider; } function handleCodeCreated(arg: MonacoReducerArg): IMonacoState { const ensuredProvider = handleStarted(arg); - if (arg.payload.cellId) { - ensuredProvider.intellisenseProvider!.mapCellIdToModelId(arg.payload.cellId, arg.payload.modelId); + if (arg.payload.data.cellId) { + ensuredProvider.intellisenseProvider!.mapCellIdToModelId(arg.payload.data.cellId, arg.payload.data.modelId); } return ensuredProvider; } function handleEditCell(arg: MonacoReducerArg): IMonacoState { const ensuredProvider = handleStarted(arg); - if (arg.payload.cellId) { - ensuredProvider.intellisenseProvider!.mapCellIdToModelId(arg.payload.cellId, arg.payload.modelId); + if (arg.payload.data.cellId) { + ensuredProvider.intellisenseProvider!.mapCellIdToModelId(arg.payload.data.cellId, arg.payload.data.modelId); } return ensuredProvider; } @@ -152,31 +154,36 @@ function handleUnmount(arg: MonacoReducerArg): IMonacoState { }; } +// type MonacoReducerFunctions = { +// [P in keyof T]: T[P] extends never | undefined ? MonacoReducerFunc : MonacoReducerFunc; +// }; + +// type IMonacoActionMapping = MonacoReducerFunctions & MonacoReducerFunctions; // Create a mapping between message and reducer type class IMonacoActionMapping { - public [InteractiveWindowMessages.Started]: MonacoReducerFunc; - public [IncomingMessageActions.LOADONIGASMASSEMBLYRESPONSE]: MonacoReducerFunc; - public [IncomingMessageActions.LOADTMLANGUAGERESPONSE]: MonacoReducerFunc; - public [IncomingMessageActions.GETMONACOTHEMERESPONSE]: MonacoReducerFunc; - public [IncomingMessageActions.PROVIDECOMPLETIONITEMSRESPONSE]: MonacoReducerFunc; - public [IncomingMessageActions.PROVIDESIGNATUREHELPRESPONSE]: MonacoReducerFunc; - public [IncomingMessageActions.PROVIDEHOVERRESPONSE]: MonacoReducerFunc; - public [IncomingMessageActions.RESOLVECOMPLETIONITEMRESPONSE]: MonacoReducerFunc; + public [InteractiveWindowMessages.Started]: MonacoReducerFunc; + public [InteractiveWindowMessages.LoadOnigasmAssemblyResponse]: MonacoReducerFunc; + public [InteractiveWindowMessages.LoadTmLanguageResponse]: MonacoReducerFunc; + public [CssMessages.GetMonacoThemeResponse]: MonacoReducerFunc; + public [InteractiveWindowMessages.ProvideCompletionItemsResponse]: MonacoReducerFunc; + public [InteractiveWindowMessages.ProvideSignatureHelpResponse]: MonacoReducerFunc; + public [InteractiveWindowMessages.ProvideHoverResponse]: MonacoReducerFunc; + public [InteractiveWindowMessages.ResolveCompletionItemResponse]: MonacoReducerFunc; public [CommonActionType.CODE_CREATED]: MonacoReducerFunc; public [CommonActionType.EDIT_CELL]: MonacoReducerFunc; - public [CommonActionType.UNMOUNT]: MonacoReducerFunc; + public [CommonActionType.UNMOUNT]: MonacoReducerFunc; } // Create the map between message type and the actual function to call to update state const reducerMap: IMonacoActionMapping = { [InteractiveWindowMessages.Started]: handleStarted, - [IncomingMessageActions.LOADONIGASMASSEMBLYRESPONSE]: handleLoadOnigasmResponse, - [IncomingMessageActions.LOADTMLANGUAGERESPONSE]: handleLoadTmLanguageResponse, - [IncomingMessageActions.GETMONACOTHEMERESPONSE]: handleThemeResponse, - [IncomingMessageActions.PROVIDECOMPLETIONITEMSRESPONSE]: handleCompletionItemsResponse, - [IncomingMessageActions.PROVIDESIGNATUREHELPRESPONSE]: handleSignatureHelpResponse, - [IncomingMessageActions.PROVIDEHOVERRESPONSE]: handleHoverResponse, - [IncomingMessageActions.RESOLVECOMPLETIONITEMRESPONSE]: handleResolveCompletionItemResponse, + [InteractiveWindowMessages.LoadOnigasmAssemblyResponse]: handleLoadOnigasmResponse, + [InteractiveWindowMessages.LoadTmLanguageResponse]: handleLoadTmLanguageResponse, + [CssMessages.GetMonacoThemeResponse]: handleThemeResponse, + [InteractiveWindowMessages.ProvideCompletionItemsResponse]: handleCompletionItemsResponse, + [InteractiveWindowMessages.ProvideSignatureHelpResponse]: handleSignatureHelpResponse, + [InteractiveWindowMessages.ProvideHoverResponse]: handleHoverResponse, + [InteractiveWindowMessages.ResolveCompletionItemResponse]: handleResolveCompletionItemResponse, [CommonActionType.CODE_CREATED]: handleCodeCreated, [CommonActionType.EDIT_CELL]: handleEditCell, [CommonActionType.UNMOUNT]: handleUnmount diff --git a/src/datascience-ui/interactive-common/redux/reducers/transfer.ts b/src/datascience-ui/interactive-common/redux/reducers/transfer.ts index f12fd12b8d18..5aa4215e610e 100644 --- a/src/datascience-ui/interactive-common/redux/reducers/transfer.ts +++ b/src/datascience-ui/interactive-common/redux/reducers/transfer.ts @@ -3,14 +3,14 @@ 'use strict'; import { InteractiveWindowMessages } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; import { CssMessages } from '../../../../client/datascience/messages'; -import { extractInputText, IMainState } from '../../mainState'; -import { createPostableAction } from '../postOffice'; +import { extractInputText, getSelectedAndFocusedInfo, IMainState } from '../../mainState'; +import { createPostableAction } from '../helpers'; import { Helpers } from './helpers'; -import { CommonReducerArg, ICellAction, IEditCellAction, ILinkClickAction, ISendCommandAction, IShowDataViewerAction, IShowPlotAction } from './types'; +import { CommonActionType, CommonReducerArg, ICellAction, IEditCellAction, ILinkClickAction, ISendCommandAction, IShowDataViewerAction } from './types'; // These are all reducers that don't actually change state. They merely dispatch a message to the other side. export namespace Transfer { - export function exportCells(arg: CommonReducerArg): IMainState { + export function exportCells(arg: CommonReducerArg): IMainState { const cellContents = arg.prevState.cellVMs.map(v => v.cell); arg.queueAction(createPostableAction(InteractiveWindowMessages.Export, cellContents)); @@ -21,7 +21,7 @@ export namespace Transfer { }; } - export function save(arg: CommonReducerArg): IMainState { + export function save(arg: CommonReducerArg): IMainState { // Note: this is assuming editor contents have already been saved. That should happen as a result of focus change // Actually waiting for save results before marking as not dirty, so don't do it here. @@ -29,47 +29,49 @@ export namespace Transfer { return arg.prevState; } - export function showDataViewer(arg: CommonReducerArg): IMainState { - arg.queueAction(createPostableAction(InteractiveWindowMessages.ShowDataViewer, { variable: arg.payload.variable, columnSize: arg.payload.columnSize })); + export function showDataViewer(arg: CommonReducerArg): IMainState { + arg.queueAction(createPostableAction(InteractiveWindowMessages.ShowDataViewer, { variable: arg.payload.data.variable, columnSize: arg.payload.data.columnSize })); return arg.prevState; } - export function sendCommand(arg: CommonReducerArg): IMainState { - arg.queueAction(createPostableAction(InteractiveWindowMessages.NativeCommand, { command: arg.payload.command, source: arg.payload.commandType })); + export function sendCommand(arg: CommonReducerArg): IMainState { + arg.queueAction(createPostableAction(InteractiveWindowMessages.NativeCommand, { command: arg.payload.data.command, source: arg.payload.data.commandType })); return arg.prevState; } - export function showPlot(arg: CommonReducerArg): IMainState { - arg.queueAction(createPostableAction(InteractiveWindowMessages.ShowPlot, arg.payload.imageHtml)); + export function showPlot(arg: CommonReducerArg): IMainState { + if (arg.payload.data) { + arg.queueAction(createPostableAction(InteractiveWindowMessages.ShowPlot, arg.payload.data)); + } return arg.prevState; } - export function linkClick(arg: CommonReducerArg): IMainState { - if (arg.payload.href.startsWith('data:image/png')) { - arg.queueAction(createPostableAction(InteractiveWindowMessages.SavePng, arg.payload.href)); + export function linkClick(arg: CommonReducerArg): IMainState { + if (arg.payload.data.href.startsWith('data:image/png')) { + arg.queueAction(createPostableAction(InteractiveWindowMessages.SavePng, arg.payload.data.href)); } else { - arg.queueAction(createPostableAction(InteractiveWindowMessages.OpenLink, arg.payload.href)); + arg.queueAction(createPostableAction(InteractiveWindowMessages.OpenLink, arg.payload.data.href)); } return arg.prevState; } - export function getAllCells(arg: CommonReducerArg): IMainState { + export function getAllCells(arg: CommonReducerArg): IMainState { const cells = arg.prevState.cellVMs.map(c => c.cell); arg.queueAction(createPostableAction(InteractiveWindowMessages.ReturnAllCells, cells)); return arg.prevState; } - export function gotoCell(arg: CommonReducerArg): IMainState { - const cellVM = arg.prevState.cellVMs.find(c => c.cell.id === arg.payload.cellId); + export function gotoCell(arg: CommonReducerArg): IMainState { + const cellVM = arg.prevState.cellVMs.find(c => c.cell.id === arg.payload.data.cellId); if (cellVM && cellVM.cell.data.cell_type === 'code') { arg.queueAction(createPostableAction(InteractiveWindowMessages.GotoCodeCell, { file: cellVM.cell.file, line: cellVM.cell.line })); } return arg.prevState; } - export function copyCellCode(arg: CommonReducerArg): IMainState { - let cellVM = arg.prevState.cellVMs.find(c => c.cell.id === arg.payload.cellId); - if (!cellVM && arg.prevState.editCellVM && arg.payload.cellId === arg.prevState.editCellVM.cell.id) { + export function copyCellCode(arg: CommonReducerArg): IMainState { + let cellVM = arg.prevState.cellVMs.find(c => c.cell.id === arg.payload.data.cellId); + if (!cellVM && arg.prevState.editCellVM && arg.payload.data.cellId === arg.prevState.editCellVM.cell.id) { cellVM = arg.prevState.editCellVM; } @@ -81,28 +83,29 @@ export namespace Transfer { return arg.prevState; } - export function gather(arg: CommonReducerArg): IMainState { - const cellVM = arg.prevState.cellVMs.find(c => c.cell.id === arg.payload.cellId); + export function gather(arg: CommonReducerArg): IMainState { + const cellVM = arg.prevState.cellVMs.find(c => c.cell.id === arg.payload.data.cellId); if (cellVM) { arg.queueAction(createPostableAction(InteractiveWindowMessages.GatherCodeRequest, cellVM.cell)); } return arg.prevState; } - export function editCell(arg: CommonReducerArg): IMainState { - if (arg.payload.cellId) { - arg.queueAction(createPostableAction(InteractiveWindowMessages.EditCell, { changes: arg.payload.changes, id: arg.payload.cellId })); + export function editCell(arg: CommonReducerArg): IMainState { + if (arg.payload.data.cellId) { + arg.queueAction(createPostableAction(InteractiveWindowMessages.EditCell, { changes: arg.payload.data.changes, id: arg.payload.data.cellId })); // Update the uncomitted text on the cell view model // We keep this saved here so we don't re-render and we put this code into the input / code data // when focus is lost - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); - if (index >= 0 && arg.prevState.focusedCellId === arg.payload.cellId) { + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); + const selectionInfo = getSelectedAndFocusedInfo(arg.prevState); + if (index >= 0 && selectionInfo.focusedCellId === arg.payload.data.cellId) { const newVMs = [...arg.prevState.cellVMs]; const current = arg.prevState.cellVMs[index]; const newCell = { ...current, - uncomittedText: arg.payload.code + uncomittedText: arg.payload.data.code }; // tslint:disable-next-line: no-any @@ -116,7 +119,7 @@ export namespace Transfer { return arg.prevState; } - export function started(arg: CommonReducerArg): IMainState { + export function started(arg: CommonReducerArg): IMainState { // Send all of our initial requests arg.queueAction(createPostableAction(InteractiveWindowMessages.Started)); arg.queueAction(createPostableAction(CssMessages.GetCssRequest, { isDark: arg.prevState.baseTheme !== 'vscode-light' })); @@ -126,7 +129,7 @@ export namespace Transfer { return arg.prevState; } - export function loadedAllCells(arg: CommonReducerArg): IMainState { + export function loadedAllCells(arg: CommonReducerArg): IMainState { arg.queueAction(createPostableAction(InteractiveWindowMessages.LoadAllCellsComplete, { cells: arg.prevState.cellVMs.map(c => c.cell) })); return arg.prevState; } diff --git a/src/datascience-ui/interactive-common/redux/reducers/types.ts b/src/datascience-ui/interactive-common/redux/reducers/types.ts index 6dccb434d322..58bfdf71ed3f 100644 --- a/src/datascience-ui/interactive-common/redux/reducers/types.ts +++ b/src/datascience-ui/interactive-common/redux/reducers/types.ts @@ -3,13 +3,15 @@ 'use strict'; import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; -import { IShowDataViewer, NativeCommandType } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { InteractiveWindowMessages, IShowDataViewer, NativeCommandType } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { BaseReduxActionPayload } from '../../../../client/datascience/interactive-common/types'; +import { IJupyterVariablesRequest } from '../../../../client/datascience/types'; import { ActionWithPayload, ReducerArg } from '../../../react-common/reduxUtils'; import { CursorPos, IMainState } from '../../mainState'; /** * How to add a new state change: - * 1) Add a new action. to CommonActionType + * 1) Add a new to CommonActionType (preferably `InteractiveWindowMessages` - to keep messages in the same place). * 2) Add a new interface (or reuse 1 below) if the action takes any parameters (ex: ICellAction) * 3) Add a new actionCreator function (this is how you use it from a react control) to the * appropriate actionCreator list (one for native and one for interactive). @@ -24,22 +26,15 @@ export enum CommonActionType { ARROW_DOWN = 'action.arrow_down', ARROW_UP = 'action.arrow_up', CHANGE_CELL_TYPE = 'action.change_cell_type', - CLEAR_ALL_OUTPUTS = 'action.clear_all_outputs', CLICK_CELL = 'action.click_cell', CODE_CREATED = 'action.code_created', - COLLAPSE_ALL = 'action.collapse_all', COPY_CELL_CODE = 'action.copy_cell_code', - DELETE_ALL_CELLS = 'action.delete_all_cells', - DELETE_CELL = 'action.delete_cell', - DESELECT_CELL = 'action.deselect_cell', - DOUBLE_CLICK_CELL = 'action.double_click_cell', EDITOR_LOADED = 'action.editor_loaded', EDIT_CELL = 'action.edit_cell', EXECUTE_ABOVE = 'action.execute_above', EXECUTE_ALL_CELLS = 'action.execute_all_cells', EXECUTE_CELL = 'action.execute_cell', EXECUTE_CELL_AND_BELOW = 'action.execute_cell_and_below', - EXPAND_ALL = 'action.expand_all', EXPORT = 'action.export', FOCUS_CELL = 'action.focus_cell', GATHER_CELL = 'action.gather_cell', @@ -53,47 +48,92 @@ export enum CommonActionType { LINK_CLICK = 'action.link_click', MOVE_CELL_DOWN = 'action.move_cell_down', MOVE_CELL_UP = 'action.move_cell_up', - REDO = 'action.redo', + PostOutgoingMessage = 'action.postOutgoingMessage', REFRESH_VARIABLES = 'action.refresh_variables', RESTART_KERNEL = 'action.restart_kernel_action', SAVE = 'action.save', SCROLL = 'action.scroll', SELECT_CELL = 'action.select_cell', - SELECT_KERNEL = 'action.select_kernel', SELECT_SERVER = 'action.select_server', SEND_COMMAND = 'action.send_command', SHOW_DATA_VIEWER = 'action.show_data_viewer', - SHOW_PLOT = 'action.show_plot', - START_CELL = 'action.start_cell', SUBMIT_INPUT = 'action.submit_input', TOGGLE_INPUT_BLOCK = 'action.toggle_input_block', TOGGLE_LINE_NUMBERS = 'action.toggle_line_numbers', TOGGLE_OUTPUT = 'action.toggle_output', TOGGLE_VARIABLE_EXPLORER = 'action.toggle_variable_explorer', - UNDO = 'action.undo', UNFOCUS_CELL = 'action.unfocus_cell', UNMOUNT = 'action.unmount' } +export type CommonActionTypeMapping = { + [CommonActionType.INSERT_ABOVE]: ICellAction & IAddCellAction; + [CommonActionType.INSERT_BELOW]: ICellAction & IAddCellAction; + [CommonActionType.INSERT_ABOVE_FIRST]: IAddCellAction; + [CommonActionType.FOCUS_CELL]: ICellAndCursorAction; + [CommonActionType.UNFOCUS_CELL]: ICodeAction; + [CommonActionType.ADD_NEW_CELL]: IAddCellAction; + [CommonActionType.EDIT_CELL]: IEditCellAction; + [CommonActionType.EXECUTE_CELL]: IExecuteAction; + [CommonActionType.EXECUTE_ALL_CELLS]: never | undefined; + [CommonActionType.EXECUTE_ABOVE]: ICellAction; + [CommonActionType.EXECUTE_CELL_AND_BELOW]: ICodeAction; + [CommonActionType.RESTART_KERNEL]: never | undefined; + [CommonActionType.INTERRUPT_KERNEL]: never | undefined; + [CommonActionType.EXPORT]: never | undefined; + [CommonActionType.SAVE]: never | undefined; + [CommonActionType.SHOW_DATA_VIEWER]: IShowDataViewerAction; + [CommonActionType.SEND_COMMAND]: ISendCommandAction; + [CommonActionType.SELECT_CELL]: ICellAndCursorAction; + [CommonActionType.MOVE_CELL_UP]: ICellAction; + [CommonActionType.MOVE_CELL_DOWN]: ICellAction; + [CommonActionType.TOGGLE_LINE_NUMBERS]: ICellAction; + [CommonActionType.TOGGLE_OUTPUT]: ICellAction; + [CommonActionType.ARROW_UP]: ICodeAction; + [CommonActionType.ARROW_DOWN]: ICodeAction; + [CommonActionType.CHANGE_CELL_TYPE]: IChangeCellTypeAction; + [CommonActionType.LINK_CLICK]: ILinkClickAction; + [CommonActionType.GOTO_CELL]: ICellAction; + [CommonActionType.TOGGLE_INPUT_BLOCK]: ICellAction; + [CommonActionType.SUBMIT_INPUT]: ICodeAction; + [CommonActionType.SCROLL]: IScrollAction; + [CommonActionType.CLICK_CELL]: ICellAction; + [CommonActionType.COPY_CELL_CODE]: ICellAction; + [CommonActionType.GATHER_CELL]: ICellAction; + [CommonActionType.EDITOR_LOADED]: never | undefined; + [CommonActionType.LOADED_ALL_CELLS]: never | undefined; + [CommonActionType.UNMOUNT]: never | undefined; + [CommonActionType.SELECT_SERVER]: never | undefined; + [CommonActionType.CODE_CREATED]: ICodeCreatedAction; + [CommonActionType.GET_VARIABLE_DATA]: IJupyterVariablesRequest; + [CommonActionType.TOGGLE_VARIABLE_EXPLORER]: never | undefined; +}; + export interface IShowDataViewerAction extends IShowDataViewer {} export interface ILinkClickAction { href: string; } -export interface IShowPlotAction { - imageHtml: string; -} - export interface IScrollAction { isAtBottom: boolean; } -export type CommonReducerArg = ReducerArg; + +// tslint:disable-next-line: no-any +export type CommonReducerArg = ReducerArg>; export interface ICellAction { cellId: string | undefined; } +export interface IAddCellAction { + /** + * Id of the new cell that is to be added. + * If none provided, then generate a new id. + */ + newCellId: string; +} + export interface ICodeAction extends ICellAction { code: string; } @@ -103,9 +143,16 @@ export interface IEditCellAction extends ICodeAction { modelId: string; } -export interface IExecuteAction extends ICodeAction { - moveOp: 'add' | 'select' | 'none'; -} +// I.e. when using the operation `add`, we need the corresponding `IAddCellAction`. +// They are mutually exclusive, if not `add`, then there's no `newCellId`. +export type IExecuteAction = + | (ICodeAction & { + moveOp: 'select' | 'none'; + }) + | (ICodeAction & + IAddCellAction & { + moveOp: 'add'; + }); export interface ICodeCreatedAction extends ICellAction { modelId: string; @@ -130,4 +177,4 @@ export interface IChangeCellTypeAction { cellId: string; currentCode: string; } -export type CommonAction = ActionWithPayload; +export type CommonAction = ActionWithPayload; diff --git a/src/datascience-ui/interactive-common/redux/reducers/variables.ts b/src/datascience-ui/interactive-common/redux/reducers/variables.ts index f48cf215b90a..ad4120e6705d 100644 --- a/src/datascience-ui/interactive-common/redux/reducers/variables.ts +++ b/src/datascience-ui/interactive-common/redux/reducers/variables.ts @@ -2,11 +2,12 @@ // Licensed under the MIT License. 'use strict'; import { Reducer } from 'redux'; -import { InteractiveWindowMessages } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { IInteractiveWindowMapping, InteractiveWindowMessages } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { BaseReduxActionPayload } from '../../../../client/datascience/interactive-common/types'; import { ICell, IJupyterVariable, IJupyterVariablesRequest, IJupyterVariablesResponse } from '../../../../client/datascience/types'; import { combineReducers, QueuableAction, ReducerArg, ReducerFunc } from '../../../react-common/reduxUtils'; -import { createPostableAction, IncomingMessageActions } from '../postOffice'; -import { CommonActionType } from './types'; +import { createPostableAction } from '../helpers'; +import { CommonActionType, CommonActionTypeMapping } from './types'; export type IVariableState = { currentExecutionCount: number; @@ -17,24 +18,23 @@ export type IVariableState = { pageSize: number; }; -type VariableReducerFunc = ReducerFunc; - -type VariableReducerArg = ReducerArg; +type VariableReducerFunc = ReducerFunc>; +type VariableReducerArg = ReducerArg>; function handleRequest(arg: VariableReducerArg): IVariableState { - const newExecutionCount = arg.payload.executionCount ? arg.payload.executionCount : arg.prevState.currentExecutionCount; + const newExecutionCount = arg.payload.data.executionCount ? arg.payload.data.executionCount : arg.prevState.currentExecutionCount; arg.queueAction( createPostableAction(InteractiveWindowMessages.GetVariablesRequest, { executionCount: newExecutionCount, - sortColumn: arg.payload.sortColumn, - startIndex: arg.payload.startIndex, - sortAscending: arg.payload.sortAscending, - pageSize: arg.payload.pageSize + sortColumn: arg.payload.data.sortColumn, + startIndex: arg.payload.data.startIndex, + sortAscending: arg.payload.data.sortAscending, + pageSize: arg.payload.data.pageSize }) ); return { ...arg.prevState, - pageSize: Math.max(arg.prevState.pageSize, arg.payload.pageSize) + pageSize: Math.max(arg.prevState.pageSize, arg.payload.data.pageSize) }; } @@ -51,7 +51,10 @@ function toggleVariableExplorer(arg: VariableReducerArg): IVariableState { return handleRequest({ ...arg, prevState: newState, - payload: { executionCount: arg.prevState.currentExecutionCount, sortColumn: 'name', sortAscending: true, startIndex: 0, pageSize: arg.prevState.pageSize } + payload: { + ...arg.payload, + data: { executionCount: arg.prevState.currentExecutionCount, sortColumn: 'name', sortAscending: true, startIndex: 0, pageSize: arg.prevState.pageSize } + } }); } else { return newState; @@ -59,7 +62,7 @@ function toggleVariableExplorer(arg: VariableReducerArg): IVariableState { } function handleResponse(arg: VariableReducerArg): IVariableState { - const response = arg.payload; + const response = arg.payload.data; // Check to see if we have moved to a new execution count if ( @@ -108,17 +111,23 @@ function handleResponse(arg: VariableReducerArg): IVa function handleRestarted(arg: VariableReducerArg): IVariableState { // If the variables are visible, refresh them if (arg.prevState.visible) { - return handleRequest({ ...arg, payload: { executionCount: 0, sortColumn: 'name', sortAscending: true, startIndex: 0, pageSize: arg.prevState.pageSize } }); + return handleRequest({ + ...arg, + payload: { ...arg.payload, data: { executionCount: 0, sortColumn: 'name', sortAscending: true, startIndex: 0, pageSize: arg.prevState.pageSize } } + }); } return arg.prevState; } function handleFinishCell(arg: VariableReducerArg): IVariableState { - const executionCount = arg.payload.data.execution_count ? parseInt(arg.payload.data.execution_count.toString(), 10) : undefined; + const executionCount = arg.payload.data.data.execution_count ? parseInt(arg.payload.data.data.execution_count.toString(), 10) : undefined; // If the variables are visible, refresh them if (arg.prevState.visible && executionCount) { - return handleRequest({ ...arg, payload: { executionCount, sortColumn: 'name', sortAscending: true, startIndex: 0, pageSize: arg.prevState.pageSize } }); + return handleRequest({ + ...arg, + payload: { ...arg.payload, data: { executionCount, sortColumn: 'name', sortAscending: true, startIndex: 0, pageSize: arg.prevState.pageSize } } + }); } return { ...arg.prevState, @@ -126,25 +135,22 @@ function handleFinishCell(arg: VariableReducerArg): IVariableState { }; } -// Create a mapping between message and reducer type -class IVariableActionMapping { - public [IncomingMessageActions.RESTARTKERNEL]: VariableReducerFunc; - public [IncomingMessageActions.FINISHCELL]: VariableReducerFunc; - public [CommonActionType.TOGGLE_VARIABLE_EXPLORER]: VariableReducerFunc; - public [CommonActionType.GET_VARIABLE_DATA]: VariableReducerFunc; - public [IncomingMessageActions.GETVARIABLESRESPONSE]: VariableReducerFunc; -} +type VariableReducerFunctions = { + [P in keyof T]: T[P] extends never | undefined ? VariableReducerFunc : VariableReducerFunc; +}; + +type VariableActionMapping = VariableReducerFunctions & VariableReducerFunctions; // Create the map between message type and the actual function to call to update state -const reducerMap: IVariableActionMapping = { - [IncomingMessageActions.RESTARTKERNEL]: handleRestarted, - [IncomingMessageActions.FINISHCELL]: handleFinishCell, +const reducerMap: Partial = { + [InteractiveWindowMessages.RestartKernel]: handleRestarted, + [InteractiveWindowMessages.FinishCell]: handleFinishCell, [CommonActionType.TOGGLE_VARIABLE_EXPLORER]: toggleVariableExplorer, [CommonActionType.GET_VARIABLE_DATA]: handleRequest, - [IncomingMessageActions.GETVARIABLESRESPONSE]: handleResponse + [InteractiveWindowMessages.GetVariablesResponse]: handleResponse }; -export function generateVariableReducer(): Reducer> { +export function generateVariableReducer(): Reducer>> { // First create our default state. const defaultState: IVariableState = { currentExecutionCount: 0, @@ -156,5 +162,5 @@ export function generateVariableReducer(): Reducer(defaultState, reducerMap); + return combineReducers>(defaultState, reducerMap); } diff --git a/src/datascience-ui/interactive-common/redux/store.ts b/src/datascience-ui/interactive-common/redux/store.ts index 4e35c159d567..e00aaefde666 100644 --- a/src/datascience-ui/interactive-common/redux/store.ts +++ b/src/datascience-ui/interactive-common/redux/store.ts @@ -6,15 +6,18 @@ import * as Redux from 'redux'; import { createLogger } from 'redux-logger'; import { Identifiers } from '../../../client/datascience/constants'; import { InteractiveWindowMessages } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { BaseReduxActionPayload } from '../../../client/datascience/interactive-common/types'; +import { CssMessages } from '../../../client/datascience/messages'; import { CellState } from '../../../client/datascience/types'; -import { IMainState, ServerStatus } from '../../interactive-common/mainState'; +import { getSelectedAndFocusedInfo, IMainState, ServerStatus } from '../../interactive-common/mainState'; import { getLocString } from '../../react-common/locReactSide'; import { PostOffice } from '../../react-common/postOffice'; import { combineReducers, createQueueableActionMiddleware, QueuableAction } from '../../react-common/reduxUtils'; import { computeEditorOptions, getDefaultSettings } from '../../react-common/settingsReactSide'; import { createEditableCellVM, generateTestState } from '../mainState'; import { forceLoad } from '../transforms'; -import { AllowedMessages, createPostableAction, generatePostOfficeSendReducer, IncomingMessageActions } from './postOffice'; +import { createPostableAction, isAllowedAction, isAllowedMessage } from './helpers'; +import { generatePostOfficeSendReducer } from './postOffice'; import { generateMonacoReducer, IMonacoState } from './reducers/monaco'; import { generateVariableReducer, IVariableState } from './reducers/variables'; @@ -70,10 +73,16 @@ function createSendInfoMiddleware(): Redux.Middleware<{}, IStore> { const res = next(action); const afterState = store.getState(); + // If the action is part of a sync message, then do not send it to the extension. + if (action.payload && typeof (action.payload as BaseReduxActionPayload).messageType === 'number') { + return res; + } + // If cell vm count changed or selected cell changed, send the message + const currentSelection = getSelectedAndFocusedInfo(afterState.main); if ( prevState.main.cellVMs.length !== afterState.main.cellVMs.length || - prevState.main.selectedCellId !== afterState.main.selectedCellId || + getSelectedAndFocusedInfo(prevState.main).selectedCellId !== currentSelection.selectedCellId || prevState.main.undoStack.length !== afterState.main.undoStack.length || prevState.main.redoStack.length !== afterState.main.redoStack.length ) { @@ -82,7 +91,7 @@ function createSendInfoMiddleware(): Redux.Middleware<{}, IStore> { cellCount: afterState.main.cellVMs.length, undoCount: afterState.main.undoStack.length, redoCount: afterState.main.redoStack.length, - selectedCell: afterState.main.selectedCellId + selectedCell: currentSelection.selectedCellId }) ); } @@ -110,10 +119,17 @@ function createTestMiddleware(): Redux.Middleware<{}, IStore> { }; // Special case for focusing a cell - if (prevState.main.focusedCellId !== afterState.main.focusedCellId && afterState.main.focusedCellId) { + const previousSelection = getSelectedAndFocusedInfo(prevState.main); + const currentSelection = getSelectedAndFocusedInfo(afterState.main); + if (previousSelection.focusedCellId !== currentSelection.focusedCellId && currentSelection.focusedCellId) { // 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 (previousSelection.focusedCellId !== currentSelection.focusedCellId && !currentSelection.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)) { @@ -162,7 +178,7 @@ function createMiddleWare(testMode: boolean): Redux.Middleware<{}, IStore>[] { // Create the logger if we're not in production mode or we're forcing logging const reduceLogMessage = ''; - const actionsWithLargePayload = [IncomingMessageActions.LOADONIGASMASSEMBLYRESPONSE, IncomingMessageActions.GETCSSRESPONSE, IncomingMessageActions.LOADTMLANGUAGERESPONSE]; + const actionsWithLargePayload = [InteractiveWindowMessages.LoadOnigasmAssemblyResponse, CssMessages.GetCssResponse, InteractiveWindowMessages.LoadTmLanguageResponse]; const logger = createLogger({ // tslint:disable-next-line: no-any stateTransformer: (state: any) => { @@ -197,7 +213,7 @@ function createMiddleWare(testMode: boolean): Redux.Middleware<{}, IStore>[] { } }); const loggerMiddleware = process.env.VSC_PYTHON_FORCE_LOGGING !== undefined || (process.env.NODE_ENV !== 'production' && !testMode) ? logger : undefined; - + // tslint:disable-next-line: no-console const results: Redux.Middleware<{}, IStore>[] = []; results.push(queueableActions); results.push(updateContext); @@ -222,6 +238,21 @@ export interface IMainWithVariables extends IMainState { variableState: IVariableState; } +/** + * Middleware that will ensure all actions have `messageDirection` property. + */ +const addMessageDirectionMiddleware: Redux.Middleware = _store => next => (action: Redux.AnyAction) => { + if (isAllowedAction(action)) { + // Ensure all dispatched messages have been flagged as `incoming`. + const payload: BaseReduxActionPayload<{}> = action.payload || {}; + if (!payload.messageDirection) { + action.payload = { ...payload, messageDirection: 'incoming' }; + } + } + + return next(action); +}; + export function createStore(skipDefault: boolean, baseTheme: string, testMode: boolean, editable: boolean, reducerMap: M) { // Create a post office to listen to store dispatches and allow reducers to // send messages @@ -248,7 +279,7 @@ export function createStore(skipDefault: boolean, baseTheme: string, testMode }); // Create our middleware - const middleware = createMiddleWare(testMode); + const middleware = createMiddleWare(testMode).concat([addMessageDirectionMiddleware]); // Use this reducer and middle ware to create a store const store = Redux.createStore(rootReducer, Redux.applyMiddleware(...middleware)); @@ -257,14 +288,19 @@ export function createStore(skipDefault: boolean, baseTheme: string, testMode // turn them into actions. postOffice.addHandler({ // tslint:disable-next-line: no-any - handleMessage(message: string, payload: any): boolean { + handleMessage(message: string, payload?: any): boolean { // Double check this is one of our messages. React will actually post messages here too during development - if (AllowedMessages.find(k => k === message)) { - // Prefix message type with 'action.' so that we can: - // - Have one reducer for incoming - // - Have another reducer for outgoing - store.dispatch({ type: `action.${message}`, payload }); + if (isAllowedMessage(message)) { + const basePayload: BaseReduxActionPayload = { data: payload }; + if (message === InteractiveWindowMessages.Sync) { + // Unwrap the message. + message = payload.type; + basePayload.messageType = payload.payload.messageType; + basePayload.data = payload.payload.data; + } + store.dispatch({ type: message, payload: basePayload }); } + return true; } }); diff --git a/src/datascience-ui/native-editor/nativeEditor.tsx b/src/datascience-ui/native-editor/nativeEditor.tsx index d099dc1136e1..e9b28641215d 100644 --- a/src/datascience-ui/native-editor/nativeEditor.tsx +++ b/src/datascience-ui/native-editor/nativeEditor.tsx @@ -9,7 +9,7 @@ import { concatMultilineStringInput } from '../common'; import { ContentPanel, IContentPanelProps } from '../interactive-common/contentPanel'; import { handleLinkClick } from '../interactive-common/handlers'; import { KernelSelection } from '../interactive-common/kernelSelection'; -import { ICellViewModel, IMainState } from '../interactive-common/mainState'; +import { getSelectedAndFocusedInfo, ICellViewModel, IMainState } from '../interactive-common/mainState'; import { IMainWithVariables, IStore } from '../interactive-common/redux/store'; import { IVariablePanelProps, VariablePanel } from '../interactive-common/variablePanel'; import { getOSType } from '../react-common/constants'; @@ -109,7 +109,7 @@ export class NativeEditor extends React.Component { // tslint:disable: react-this-binding-issue // tslint:disable-next-line: max-func-body-length private renderToolbarPanel() { - const selectedIndex = this.props.cellVMs.findIndex(c => c.cell.id === this.props.selectedCellId); + const selectedInfo = getSelectedAndFocusedInfo(this.props); const addCell = () => { this.props.addCell(); @@ -132,16 +132,16 @@ export class NativeEditor extends React.Component { ? getLocString('DataScience.collapseVariableExplorerTooltip', 'Hide variables active in jupyter kernel') : getLocString('DataScience.expandVariableExplorerTooltip', 'Show variables active in jupyter kernel'); const runAbove = () => { - if (this.props.selectedCellId) { - this.props.executeAbove(this.props.selectedCellId); + if (selectedInfo.selectedCellId) { + this.props.executeAbove(selectedInfo.selectedCellId); this.props.sendCommand(NativeCommandType.RunAbove, 'mouse'); } }; const runBelow = () => { - if (this.props.selectedCellId) { + if (selectedInfo.selectedCellId && typeof selectedInfo.selectedCellIndex === 'number') { // tslint:disable-next-line: no-suspicious-comment // TODO: Is the source going to be up to date during run below? - this.props.executeCellAndBelow(this.props.selectedCellId, concatMultilineStringInput(this.props.cellVMs[selectedIndex].cell.data.source)); + this.props.executeCellAndBelow(selectedInfo.selectedCellId, concatMultilineStringInput(this.props.cellVMs[selectedInfo.selectedCellIndex].cell.data.source)); this.props.sendCommand(NativeCommandType.RunBelow, 'mouse'); } }; @@ -153,8 +153,8 @@ export class NativeEditor extends React.Component { this.props.selectServer(); this.props.sendCommand(NativeCommandType.SelectServer, 'mouse'); }; - const canRunAbove = selectedIndex > 0; - const canRunBelow = selectedIndex < this.props.cellVMs.length - 1 && this.props.selectedCellId; + const canRunAbove = (selectedInfo.selectedCellIndex ?? -1) > 0; + const canRunBelow = (selectedInfo.selectedCellIndex ?? -1) < this.props.cellVMs.length - 1 && selectedInfo.selectedCellId; return (
@@ -315,7 +315,7 @@ export class NativeEditor extends React.Component { } case 'z': case 'Z': - if (this.props.focusedCellId === undefined) { + if (!getSelectedAndFocusedInfo(this.props).focusedCellId) { if (event.shiftKey && !event.ctrlKey && !event.altKey) { event.stopPropagation(); this.props.redo(); diff --git a/src/datascience-ui/native-editor/redux/actions.ts b/src/datascience-ui/native-editor/redux/actions.ts index 0ab2f50e7d95..8619a0db3e50 100644 --- a/src/datascience-ui/native-editor/redux/actions.ts +++ b/src/datascience-ui/native-editor/redux/actions.ts @@ -2,13 +2,15 @@ // Licensed under the MIT License. 'use strict'; import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; - -import { NativeCommandType } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; +import * as uuid from 'uuid/v4'; +import { InteractiveWindowMessages, NativeCommandType } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; import { IJupyterVariable, IJupyterVariablesRequest } from '../../../client/datascience/types'; import { CursorPos } from '../../interactive-common/mainState'; +import { createIncomingAction, createIncomingActionWithPayload } from '../../interactive-common/redux/helpers'; import { CommonAction, CommonActionType, + IAddCellAction, ICellAction, ICellAndCursorAction, IChangeCellTypeAction, @@ -17,74 +19,66 @@ import { IEditCellAction, IExecuteAction, ILinkClickAction, - IRefreshVariablesAction, ISendCommandAction, - IShowDataViewerAction, - IShowPlotAction + IShowDataViewerAction } from '../../interactive-common/redux/reducers/types'; // See https://react-redux.js.org/using-react-redux/connect-mapdispatch#defining-mapdispatchtoprops-as-an-object export const actionCreators = { - insertAbove: (cellId: string | undefined): CommonAction => ({ type: CommonActionType.INSERT_ABOVE, payload: { cellId } }), - insertAboveFirst: (): CommonAction => ({ type: CommonActionType.INSERT_ABOVE_FIRST }), - insertBelow: (cellId: string | undefined): CommonAction => ({ type: CommonActionType.INSERT_BELOW, payload: { cellId } }), - focusCell: (cellId: string, cursorPos: CursorPos = CursorPos.Current): CommonAction => ({ - type: CommonActionType.FOCUS_CELL, - payload: { cellId, cursorPos } - }), - unfocusCell: (cellId: string, code: string): CommonAction => ({ type: CommonActionType.UNFOCUS_CELL, payload: { cellId, code } }), - selectCell: (cellId: string, cursorPos: CursorPos = CursorPos.Current): CommonAction => ({ - type: CommonActionType.SELECT_CELL, - payload: { cellId, cursorPos } - }), - addCell: (): CommonAction => ({ type: CommonActionType.ADD_NEW_CELL }), - executeCell: (cellId: string, code: string, moveOp: 'add' | 'select' | 'none'): CommonAction => ({ - type: CommonActionType.EXECUTE_CELL, - payload: { cellId, code, moveOp } - }), - executeAllCells: (): CommonAction => ({ type: CommonActionType.EXECUTE_ALL_CELLS }), - executeAbove: (cellId: string): CommonAction => ({ type: CommonActionType.EXECUTE_ABOVE, payload: { cellId } }), - executeCellAndBelow: (cellId: string, code: string): CommonAction => ({ type: CommonActionType.EXECUTE_CELL_AND_BELOW, payload: { cellId, code } }), - toggleVariableExplorer: (): CommonAction => ({ type: CommonActionType.TOGGLE_VARIABLE_EXPLORER }), - refreshVariables: (newExecutionCount?: number): CommonAction => ({ type: CommonActionType.REFRESH_VARIABLES, payload: { newExecutionCount } }), - restartKernel: (): CommonAction => ({ type: CommonActionType.RESTART_KERNEL }), - interruptKernel: (): CommonAction => ({ type: CommonActionType.INTERRUPT_KERNEL }), - clearAllOutputs: (): CommonAction => ({ type: CommonActionType.CLEAR_ALL_OUTPUTS }), - export: (): CommonAction => ({ type: CommonActionType.EXPORT }), - save: (): CommonAction => ({ type: CommonActionType.SAVE }), - showDataViewer: (variable: IJupyterVariable, columnSize: number): CommonAction => ({ - type: CommonActionType.SHOW_DATA_VIEWER, - payload: { variable, columnSize } - }), - sendCommand: (command: NativeCommandType, commandType: 'mouse' | 'keyboard'): CommonAction => ({ - type: CommonActionType.SEND_COMMAND, - payload: { command, commandType } - }), - moveCellUp: (cellId: string): CommonAction => ({ type: CommonActionType.MOVE_CELL_UP, payload: { cellId } }), - moveCellDown: (cellId: string): CommonAction => ({ type: CommonActionType.MOVE_CELL_DOWN, payload: { cellId } }), - changeCellType: (cellId: string, currentCode: string): CommonAction => ({ type: CommonActionType.CHANGE_CELL_TYPE, payload: { cellId, currentCode } }), - toggleLineNumbers: (cellId: string): CommonAction => ({ type: CommonActionType.TOGGLE_LINE_NUMBERS, payload: { cellId } }), - toggleOutput: (cellId: string): CommonAction => ({ type: CommonActionType.TOGGLE_OUTPUT, payload: { cellId } }), - deleteCell: (cellId: string): CommonAction => ({ type: CommonActionType.DELETE_CELL, payload: { cellId } }), - undo: (): CommonAction => ({ type: CommonActionType.UNDO }), - redo: (): CommonAction => ({ type: CommonActionType.REDO }), - arrowUp: (cellId: string, code: string): CommonAction => ({ type: CommonActionType.ARROW_UP, payload: { cellId, code } }), - arrowDown: (cellId: string, code: string): CommonAction => ({ type: CommonActionType.ARROW_DOWN, payload: { cellId, code } }), - editCell: (cellId: string, changes: monacoEditor.editor.IModelContentChange[], modelId: string, code: string): CommonAction => ({ - type: CommonActionType.EDIT_CELL, - payload: { cellId, changes, modelId, code } - }), - linkClick: (href: string): CommonAction => ({ type: CommonActionType.LINK_CLICK, payload: { href } }), - showPlot: (imageHtml: string): CommonAction => ({ type: CommonActionType.SHOW_PLOT, payload: { imageHtml } }), - gatherCell: (cellId: string | undefined): CommonAction => ({ type: CommonActionType.GATHER_CELL, payload: { cellId } }), - editorLoaded: (): CommonAction => ({ type: CommonActionType.EDITOR_LOADED }), - codeCreated: (cellId: string | undefined, modelId: string): CommonAction => ({ type: CommonActionType.CODE_CREATED, payload: { cellId, modelId } }), - loadedAllCells: (): CommonAction => ({ type: CommonActionType.LOADED_ALL_CELLS }), - editorUnmounted: (): CommonAction => ({ type: CommonActionType.UNMOUNT }), - selectKernel: (): CommonAction => ({ type: CommonActionType.SELECT_KERNEL }), - selectServer: (): CommonAction => ({ type: CommonActionType.SELECT_SERVER }), - getVariableData: (newExecutionCount: number, startIndex: number = 0, pageSize: number = 100): CommonAction => ({ - type: CommonActionType.GET_VARIABLE_DATA, - payload: { executionCount: newExecutionCount, sortColumn: 'name', sortAscending: true, startIndex, pageSize } - }) + insertAbove: (cellId: string | undefined): CommonAction => + createIncomingActionWithPayload(CommonActionType.INSERT_ABOVE, { cellId, newCellId: uuid() }), + insertAboveFirst: (): CommonAction => createIncomingActionWithPayload(CommonActionType.INSERT_ABOVE_FIRST, { newCellId: uuid() }), + insertBelow: (cellId: string | undefined): CommonAction => + createIncomingActionWithPayload(CommonActionType.INSERT_BELOW, { cellId, newCellId: uuid() }), + focusCell: (cellId: string, cursorPos: CursorPos = CursorPos.Current): CommonAction => + createIncomingActionWithPayload(CommonActionType.FOCUS_CELL, { cellId, cursorPos }), + unfocusCell: (cellId: string, code: string): CommonAction => createIncomingActionWithPayload(CommonActionType.UNFOCUS_CELL, { cellId, code }), + selectCell: (cellId: string, cursorPos: CursorPos = CursorPos.Current): CommonAction => + createIncomingActionWithPayload(CommonActionType.SELECT_CELL, { cellId, cursorPos }), + addCell: (): CommonAction => createIncomingActionWithPayload(CommonActionType.ADD_NEW_CELL, { newCellId: uuid() }), + executeCell: (cellId: string, code: string, moveOp: 'add' | 'select' | 'none'): CommonAction => { + if (moveOp === 'add') { + return createIncomingActionWithPayload(CommonActionType.EXECUTE_CELL, { cellId, code, moveOp, newCellId: uuid() }); + } else { + return createIncomingActionWithPayload(CommonActionType.EXECUTE_CELL, { cellId, code, moveOp }); + } + }, + executeAllCells: (): CommonAction => createIncomingAction(CommonActionType.EXECUTE_ALL_CELLS), + executeAbove: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.EXECUTE_ABOVE, { cellId }), + executeCellAndBelow: (cellId: string, code: string): CommonAction => createIncomingActionWithPayload(CommonActionType.EXECUTE_CELL_AND_BELOW, { cellId, code }), + toggleVariableExplorer: (): CommonAction => createIncomingAction(CommonActionType.TOGGLE_VARIABLE_EXPLORER), + restartKernel: (): CommonAction => createIncomingAction(CommonActionType.RESTART_KERNEL), + interruptKernel: (): CommonAction => createIncomingAction(CommonActionType.INTERRUPT_KERNEL), + clearAllOutputs: (): CommonAction => createIncomingAction(InteractiveWindowMessages.ClearAllOutputs), + export: (): CommonAction => createIncomingAction(CommonActionType.EXPORT), + save: (): CommonAction => createIncomingAction(CommonActionType.SAVE), + showDataViewer: (variable: IJupyterVariable, columnSize: number): CommonAction => + createIncomingActionWithPayload(CommonActionType.SHOW_DATA_VIEWER, { variable, columnSize }), + sendCommand: (command: NativeCommandType, commandType: 'mouse' | 'keyboard'): CommonAction => + createIncomingActionWithPayload(CommonActionType.SEND_COMMAND, { command, commandType }), + moveCellUp: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.MOVE_CELL_UP, { cellId }), + moveCellDown: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.MOVE_CELL_DOWN, { cellId }), + changeCellType: (cellId: string, currentCode: string): CommonAction => + createIncomingActionWithPayload(CommonActionType.CHANGE_CELL_TYPE, { cellId, currentCode }), + toggleLineNumbers: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.TOGGLE_LINE_NUMBERS, { cellId }), + toggleOutput: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.TOGGLE_OUTPUT, { cellId }), + deleteCell: (cellId: string): CommonAction => createIncomingActionWithPayload(InteractiveWindowMessages.DeleteCell, { cellId }), + undo: (): CommonAction => createIncomingAction(InteractiveWindowMessages.Undo), + redo: (): CommonAction => createIncomingAction(InteractiveWindowMessages.Redo), + arrowUp: (cellId: string, code: string): CommonAction => createIncomingActionWithPayload(CommonActionType.ARROW_UP, { cellId, code }), + arrowDown: (cellId: string, code: string): CommonAction => createIncomingActionWithPayload(CommonActionType.ARROW_DOWN, { cellId, code }), + editCell: (cellId: string, changes: monacoEditor.editor.IModelContentChange[], modelId: string, code: string): CommonAction => + createIncomingActionWithPayload(CommonActionType.EDIT_CELL, { cellId, changes, modelId, code }), + linkClick: (href: string): CommonAction => createIncomingActionWithPayload(CommonActionType.LINK_CLICK, { href }), + showPlot: (imageHtml: string): CommonAction => createIncomingActionWithPayload(InteractiveWindowMessages.ShowPlot, imageHtml), + gatherCell: (cellId: string | undefined): CommonAction => createIncomingActionWithPayload(CommonActionType.GATHER_CELL, { cellId }), + editorLoaded: (): CommonAction => createIncomingAction(CommonActionType.EDITOR_LOADED), + codeCreated: (cellId: string | undefined, modelId: string): CommonAction => + createIncomingActionWithPayload(CommonActionType.CODE_CREATED, { cellId, modelId }), + loadedAllCells: (): CommonAction => createIncomingAction(CommonActionType.LOADED_ALL_CELLS), + editorUnmounted: (): CommonAction => createIncomingAction(CommonActionType.UNMOUNT), + selectKernel: (): CommonAction => createIncomingAction(InteractiveWindowMessages.SelectKernel), + selectServer: (): CommonAction => createIncomingAction(CommonActionType.SELECT_SERVER), + getVariableData: (newExecutionCount: number, startIndex: number = 0, pageSize: number = 100): CommonAction => + createIncomingActionWithPayload(CommonActionType.GET_VARIABLE_DATA, { executionCount: newExecutionCount, sortColumn: 'name', sortAscending: true, startIndex, pageSize }) }; diff --git a/src/datascience-ui/native-editor/redux/mapping.ts b/src/datascience-ui/native-editor/redux/mapping.ts index 2460cd0d3e69..46a023ad9ec9 100644 --- a/src/datascience-ui/native-editor/redux/mapping.ts +++ b/src/datascience-ui/native-editor/redux/mapping.ts @@ -1,92 +1,18 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 'use strict'; -import { ILoadAllCells } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; -import { IGetCssResponse } from '../../../client/datascience/messages'; -import { IGetMonacoThemeResponse } from '../../../client/datascience/monacoMessages'; -import { ICell } from '../../../client/datascience/types'; -import { IMainState, IServerState } from '../../interactive-common/mainState'; -import { IncomingMessageActions } from '../../interactive-common/redux/postOffice'; -import { - CommonActionType, - ICellAction, - ICellAndCursorAction, - IChangeCellTypeAction, - ICodeAction, - IEditCellAction, - IExecuteAction, - ILinkClickAction, - ISendCommandAction, - IShowDataViewerAction, - IShowPlotAction -} from '../../interactive-common/redux/reducers/types'; +import { IInteractiveWindowMapping, InteractiveWindowMessages } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { BaseReduxActionPayload } from '../../../client/datascience/interactive-common/types'; +import { IMainState } from '../../interactive-common/mainState'; +import { CommonActionType, CommonActionTypeMapping } from '../../interactive-common/redux/reducers/types'; import { ReducerArg, ReducerFunc } from '../../react-common/reduxUtils'; -type NativeEditorReducerFunc = ReducerFunc; +type NativeEditorReducerFunc = ReducerFunc>; -export type NativeEditorReducerArg = ReducerArg; +export type NativeEditorReducerArg = ReducerArg>; -export class INativeEditorActionMapping { - public [CommonActionType.INSERT_ABOVE]: NativeEditorReducerFunc; - public [CommonActionType.INSERT_BELOW]: NativeEditorReducerFunc; - public [CommonActionType.INSERT_ABOVE_FIRST]: NativeEditorReducerFunc; - public [CommonActionType.FOCUS_CELL]: NativeEditorReducerFunc; - public [CommonActionType.UNFOCUS_CELL]: NativeEditorReducerFunc; - public [CommonActionType.ADD_NEW_CELL]: NativeEditorReducerFunc; - public [CommonActionType.EXECUTE_CELL]: NativeEditorReducerFunc; - public [CommonActionType.EXECUTE_ALL_CELLS]: NativeEditorReducerFunc; - public [CommonActionType.EXECUTE_ABOVE]: NativeEditorReducerFunc; - public [CommonActionType.EXECUTE_CELL_AND_BELOW]: NativeEditorReducerFunc; - public [CommonActionType.RESTART_KERNEL]: NativeEditorReducerFunc; - public [CommonActionType.INTERRUPT_KERNEL]: NativeEditorReducerFunc; - public [CommonActionType.CLEAR_ALL_OUTPUTS]: NativeEditorReducerFunc; - public [CommonActionType.EXPORT]: NativeEditorReducerFunc; - public [CommonActionType.SAVE]: NativeEditorReducerFunc; - public [CommonActionType.UNDO]: NativeEditorReducerFunc; - public [CommonActionType.REDO]: NativeEditorReducerFunc; - public [CommonActionType.SHOW_DATA_VIEWER]: NativeEditorReducerFunc; - public [CommonActionType.SEND_COMMAND]: NativeEditorReducerFunc; - public [CommonActionType.SELECT_CELL]: NativeEditorReducerFunc; - public [CommonActionType.MOVE_CELL_UP]: NativeEditorReducerFunc; - public [CommonActionType.MOVE_CELL_DOWN]: NativeEditorReducerFunc; - public [CommonActionType.TOGGLE_LINE_NUMBERS]: NativeEditorReducerFunc; - public [CommonActionType.TOGGLE_OUTPUT]: NativeEditorReducerFunc; - public [CommonActionType.DELETE_CELL]: NativeEditorReducerFunc; - public [CommonActionType.ARROW_UP]: NativeEditorReducerFunc; - public [CommonActionType.ARROW_DOWN]: NativeEditorReducerFunc; - public [CommonActionType.CHANGE_CELL_TYPE]: NativeEditorReducerFunc; - public [CommonActionType.EDIT_CELL]: NativeEditorReducerFunc; - public [CommonActionType.LINK_CLICK]: NativeEditorReducerFunc; - public [CommonActionType.SHOW_PLOT]: NativeEditorReducerFunc; - public [CommonActionType.GATHER_CELL]: NativeEditorReducerFunc; - public [CommonActionType.EDITOR_LOADED]: NativeEditorReducerFunc; - public [CommonActionType.LOADED_ALL_CELLS]: NativeEditorReducerFunc; - public [CommonActionType.UNMOUNT]: NativeEditorReducerFunc; - public [CommonActionType.SELECT_KERNEL]: NativeEditorReducerFunc; - public [CommonActionType.SELECT_SERVER]: NativeEditorReducerFunc; +type NativeEditorReducerFunctions = { + [P in keyof T]: T[P] extends never | undefined ? NativeEditorReducerFunc : NativeEditorReducerFunc; +}; - // Messages from the extension - public [IncomingMessageActions.STARTCELL]: NativeEditorReducerFunc; - public [IncomingMessageActions.FINISHCELL]: NativeEditorReducerFunc; - public [IncomingMessageActions.UPDATECELL]: NativeEditorReducerFunc; - public [IncomingMessageActions.NOTEBOOKDIRTY]: NativeEditorReducerFunc; - public [IncomingMessageActions.NOTEBOOKCLEAN]: NativeEditorReducerFunc; - public [IncomingMessageActions.LOADALLCELLS]: NativeEditorReducerFunc; - public [IncomingMessageActions.NOTEBOOKRUNALLCELLS]: NativeEditorReducerFunc; - public [IncomingMessageActions.NOTEBOOKRUNSELECTEDCELL]: NativeEditorReducerFunc; - public [IncomingMessageActions.NOTEBOOKADDCELLBELOW]: NativeEditorReducerFunc; - public [IncomingMessageActions.DOSAVE]: NativeEditorReducerFunc; - public [IncomingMessageActions.DELETEALLCELLS]: NativeEditorReducerFunc; - public [IncomingMessageActions.UNDO]: NativeEditorReducerFunc; - public [IncomingMessageActions.REDO]: NativeEditorReducerFunc; - public [IncomingMessageActions.STARTPROGRESS]: NativeEditorReducerFunc; - public [IncomingMessageActions.STOPPROGRESS]: NativeEditorReducerFunc; - public [IncomingMessageActions.UPDATESETTINGS]: NativeEditorReducerFunc; - public [IncomingMessageActions.ACTIVATE]: NativeEditorReducerFunc; - public [IncomingMessageActions.RESTARTKERNEL]: NativeEditorReducerFunc; - public [IncomingMessageActions.GETCSSRESPONSE]: NativeEditorReducerFunc; - public [IncomingMessageActions.MONACOREADY]: NativeEditorReducerFunc; - public [IncomingMessageActions.GETMONACOTHEMERESPONSE]: NativeEditorReducerFunc; - public [IncomingMessageActions.UPDATEKERNEL]: NativeEditorReducerFunc; - public [IncomingMessageActions.LOCINIT]: NativeEditorReducerFunc; -} +export type INativeEditorActionMapping = NativeEditorReducerFunctions & NativeEditorReducerFunctions; diff --git a/src/datascience-ui/native-editor/redux/reducers/creation.ts b/src/datascience-ui/native-editor/redux/reducers/creation.ts index f1be4c8db3aa..2aa41b26a987 100644 --- a/src/datascience-ui/native-editor/redux/reducers/creation.ts +++ b/src/datascience-ui/native-editor/redux/reducers/creation.ts @@ -1,14 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 'use strict'; -import * as uuid from 'uuid/v4'; import { ILoadAllCells, InteractiveWindowMessages } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; import { ICell, IDataScienceExtraSettings } from '../../../../client/datascience/types'; -import { createCellVM, createEmptyCell, CursorPos, extractInputText, ICellViewModel, IMainState } from '../../../interactive-common/mainState'; -import { createPostableAction } from '../../../interactive-common/redux/postOffice'; +import { createCellVM, createEmptyCell, CursorPos, extractInputText, getSelectedAndFocusedInfo, ICellViewModel, IMainState } from '../../../interactive-common/mainState'; +import { createPostableAction } from '../../../interactive-common/redux/helpers'; import { Helpers } from '../../../interactive-common/redux/reducers/helpers'; -import { ICellAction } from '../../../interactive-common/redux/reducers/types'; +import { IAddCellAction, ICellAction } from '../../../interactive-common/redux/reducers/types'; import { actionCreators } from '../actions'; import { NativeEditorReducerArg } from '../mapping'; @@ -37,12 +36,12 @@ export namespace Creation { } } - export function insertAbove(arg: NativeEditorReducerArg): IMainState { - const newVM = prepareCellVM(createEmptyCell(uuid(), null), false, arg.prevState.settings); + export function insertAbove(arg: NativeEditorReducerArg): IMainState { + const newVM = prepareCellVM(createEmptyCell(arg.payload.data.newCellId, null), false, arg.prevState.settings); const newList = [...arg.prevState.cellVMs]; // Find the position where we want to insert - let position = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + let position = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (position >= 0) { newList.splice(position, 0, newVM); } else { @@ -69,12 +68,12 @@ export namespace Creation { return result; } - export function insertBelow(arg: NativeEditorReducerArg): IMainState { - const newVM = prepareCellVM(createEmptyCell(uuid(), null), false, arg.prevState.settings); + export function insertBelow(arg: NativeEditorReducerArg): IMainState { + const newVM = prepareCellVM(createEmptyCell(arg.payload.data.newCellId, null), false, arg.prevState.settings); const newList = [...arg.prevState.cellVMs]; // Find the position where we want to insert - let position = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + let position = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); let index = 0; if (position >= 0) { newList.splice(position + 1, 0, newVM); @@ -104,17 +103,20 @@ export namespace Creation { return result; } - export function insertAboveFirst(arg: NativeEditorReducerArg): IMainState { + export function insertAboveFirst(arg: NativeEditorReducerArg): IMainState { // Get the first cell id const firstCellId = arg.prevState.cellVMs.length > 0 ? arg.prevState.cellVMs[0].cell.id : undefined; // Do what an insertAbove does - return insertAbove({ ...arg, payload: { cellId: firstCellId } }); + return insertAbove({ ...arg, payload: { ...arg.payload, data: { cellId: firstCellId, newCellId: arg.payload.data.newCellId } } }); } - export function addNewCell(arg: NativeEditorReducerArg): IMainState { + export function addNewCell(arg: NativeEditorReducerArg): IMainState { // Do the same thing that an insertBelow does using the currently selected cell. - return insertBelow({ ...arg, payload: { cellId: arg.prevState.selectedCellId } }); + return insertBelow({ + ...arg, + payload: { ...arg.payload, data: { cellId: getSelectedAndFocusedInfo(arg.prevState).selectedCellId, newCellId: arg.payload.data.newCellId } } + }); } export function startCell(arg: NativeEditorReducerArg): IMainState { @@ -129,13 +131,13 @@ export namespace Creation { return Helpers.updateOrAdd(arg, (c: ICell, s: IMainState) => prepareCellVM(c, true, s.settings)); } - export function deleteAllCells(arg: NativeEditorReducerArg): IMainState { + export function deleteAllCells(arg: NativeEditorReducerArg): IMainState { // Send messages to other side to indicate the deletes arg.queueAction(createPostableAction(InteractiveWindowMessages.DeleteAllCells)); // Just leave one single blank empty cell const newVM: ICellViewModel = { - cell: createEmptyCell(uuid(), null), + cell: createEmptyCell(arg.payload.data.newCellId, null), editable: true, inputBlockOpen: true, inputBlockShow: true, @@ -153,18 +155,16 @@ export namespace Creation { return { ...arg.prevState, cellVMs: [newVM], - undoStack: Helpers.pushStack(arg.prevState.undoStack, arg.prevState.cellVMs), - selectedCellId: undefined, - focusedCellId: undefined + undoStack: Helpers.pushStack(arg.prevState.undoStack, arg.prevState.cellVMs) }; } export function deleteCell(arg: NativeEditorReducerArg): IMainState { const cells = arg.prevState.cellVMs; - if (cells.length === 1 && cells[0].cell.id === arg.payload.cellId) { + if (cells.length === 1 && cells[0].cell.id === arg.payload.data.cellId) { // Special case, if this is the last cell, don't delete it, just clear it's output and input const newVM: ICellViewModel = { - cell: createEmptyCell(arg.payload.cellId, null), + cell: createEmptyCell(arg.payload.data.cellId, null), editable: true, inputBlockOpen: true, inputBlockShow: true, @@ -179,7 +179,7 @@ export namespace Creation { // Send messages to other side to indicate the new add arg.queueAction(createPostableAction(InteractiveWindowMessages.DeleteCell)); - arg.queueAction(createPostableAction(InteractiveWindowMessages.RemoveCell, { id: arg.payload.cellId })); + arg.queueAction(createPostableAction(InteractiveWindowMessages.RemoveCell, { id: arg.payload.data.cellId })); arg.queueAction(createPostableAction(InteractiveWindowMessages.InsertCell, { cell: newVM.cell, code: '', index: 0, codeCellAboveId: undefined })); return { @@ -187,31 +187,26 @@ export namespace Creation { undoStack: Helpers.pushStack(arg.prevState.undoStack, arg.prevState.cellVMs), cellVMs: [newVM] }; - } else if (arg.payload.cellId) { + } else if (arg.payload.data.cellId) { // Otherwise just a straight delete - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (index >= 0) { arg.queueAction(createPostableAction(InteractiveWindowMessages.DeleteCell)); - arg.queueAction(createPostableAction(InteractiveWindowMessages.RemoveCell, { id: arg.payload.cellId })); + arg.queueAction(createPostableAction(InteractiveWindowMessages.RemoveCell, { id: arg.payload.data.cellId })); // Recompute select/focus if this item has either - let newSelection = arg.prevState.selectedCellId; - let newFocused = arg.prevState.focusedCellId; - const newVMs = [...arg.prevState.cellVMs.filter(c => c.cell.id !== arg.payload.cellId)]; + const previousSelection = getSelectedAndFocusedInfo(arg.prevState); + const newVMs = [...arg.prevState.cellVMs.filter(c => c.cell.id !== arg.payload.data.cellId)]; const nextOrPrev = index === arg.prevState.cellVMs.length - 1 ? index - 1 : index; - if (arg.prevState.selectedCellId === arg.payload.cellId || arg.prevState.focusedCellId === arg.payload.cellId) { + if (previousSelection.selectedCellId === arg.payload.data.cellId || previousSelection.focusedCellId === arg.payload.data.cellId) { if (nextOrPrev >= 0) { - newVMs[nextOrPrev] = { ...newVMs[nextOrPrev], selected: true, focused: arg.prevState.focusedCellId === arg.payload.cellId }; - newSelection = newVMs[nextOrPrev].cell.id; - newFocused = newVMs[nextOrPrev].focused ? newVMs[nextOrPrev].cell.id : undefined; + newVMs[nextOrPrev] = { ...newVMs[nextOrPrev], selected: true, focused: previousSelection.focusedCellId === arg.payload.data.cellId }; } } return { ...arg.prevState, cellVMs: newVMs, - selectedCellId: newSelection, - focusedCellId: newFocused, undoStack: Helpers.pushStack(arg.prevState.undoStack, arg.prevState.cellVMs), skipNextScroll: true }; @@ -222,11 +217,11 @@ export namespace Creation { } export function loadAllCells(arg: NativeEditorReducerArg): IMainState { - const vms = arg.payload.cells.map(c => prepareCellVM(c, false, arg.prevState.settings)); + const vms = arg.payload.data.cells.map(c => prepareCellVM(c, false, arg.prevState.settings)); return { ...arg.prevState, busy: false, - loadTotal: arg.payload.cells.length, + loadTotal: arg.payload.data.cells.length, undoStack: [], cellVMs: vms, loaded: true diff --git a/src/datascience-ui/native-editor/redux/reducers/effects.ts b/src/datascience-ui/native-editor/redux/reducers/effects.ts index 65d35324431a..6bfa022ef4c8 100644 --- a/src/datascience-ui/native-editor/redux/reducers/effects.ts +++ b/src/datascience-ui/native-editor/redux/reducers/effects.ts @@ -3,8 +3,8 @@ 'use strict'; import { CssMessages } from '../../../../client/datascience/messages'; import { IDataScienceExtraSettings } from '../../../../client/datascience/types'; -import { IMainState } from '../../../interactive-common/mainState'; -import { createPostableAction } from '../../../interactive-common/redux/postOffice'; +import { getSelectedAndFocusedInfo, IMainState } from '../../../interactive-common/mainState'; +import { createPostableAction } from '../../../interactive-common/redux/helpers'; import { Helpers } from '../../../interactive-common/redux/reducers/helpers'; import { ICellAction, ICellAndCursorAction, ICodeAction } from '../../../interactive-common/redux/reducers/types'; import { computeEditorOptions } from '../../../react-common/settingsReactSide'; @@ -13,35 +13,38 @@ import { NativeEditorReducerArg } from '../mapping'; export namespace Effects { export function focusCell(arg: NativeEditorReducerArg): IMainState { // Do nothing if already the focused cell. - if (arg.prevState.focusedCellId !== arg.payload.cellId) { + let selectionInfo = getSelectedAndFocusedInfo(arg.prevState); + if (selectionInfo.focusedCellId !== arg.payload.data.cellId) { let prevState = arg.prevState; - // First find the old focused cell and unfocus it - let removeFocusIndex = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.prevState.focusedCellId); - if (removeFocusIndex < 0) { - removeFocusIndex = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.prevState.selectedCellId); - } + // Ensure we unfocus & unselect all cells. + while (selectionInfo.focusedCellId || selectionInfo.selectedCellId) { + selectionInfo = getSelectedAndFocusedInfo(prevState); + // First find the old focused cell and unfocus it + let removeFocusIndex = selectionInfo.focusedCellIndex; + if (typeof removeFocusIndex !== 'number') { + removeFocusIndex = selectionInfo.selectedCellIndex; + } - if (removeFocusIndex >= 0) { - const oldFocusCell = prevState.cellVMs[removeFocusIndex]; - const oldCode = oldFocusCell.uncomittedText || oldFocusCell.inputBlockText; - prevState = unfocusCell({ ...arg, prevState, payload: { cellId: prevState.cellVMs[removeFocusIndex].cell.id, code: oldCode } }); - prevState = deselectCell({ ...arg, prevState, payload: { cellId: prevState.cellVMs[removeFocusIndex].cell.id } }); + if (typeof removeFocusIndex === 'number') { + const oldFocusCell = prevState.cellVMs[removeFocusIndex]; + const oldCode = oldFocusCell.uncomittedText || oldFocusCell.inputBlockText; + prevState = unfocusCell({ ...arg, prevState, payload: { ...arg.payload, data: { cellId: prevState.cellVMs[removeFocusIndex].cell.id, code: oldCode } } }); + prevState = deselectCell({ ...arg, prevState, payload: { ...arg.payload, data: { cellId: prevState.cellVMs[removeFocusIndex].cell.id } } }); + } } const newVMs = [...prevState.cellVMs]; // Add focus on new cell - const addFocusIndex = newVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const addFocusIndex = newVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (addFocusIndex >= 0) { - newVMs[addFocusIndex] = { ...newVMs[addFocusIndex], focused: true, selected: true, cursorPos: arg.payload.cursorPos }; + newVMs[addFocusIndex] = { ...newVMs[addFocusIndex], focused: true, selected: true, cursorPos: arg.payload.data.cursorPos }; } return { ...prevState, - cellVMs: newVMs, - focusedCellId: arg.payload.cellId, - selectedCellId: arg.payload.cellId + cellVMs: newVMs }; } @@ -50,19 +53,20 @@ export namespace Effects { export function unfocusCell(arg: NativeEditorReducerArg): IMainState { // Unfocus the cell - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); - if (index >= 0 && arg.prevState.focusedCellId === arg.payload.cellId) { + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); + const selectionInfo = getSelectedAndFocusedInfo(arg.prevState); + if (index >= 0 && selectionInfo.focusedCellId === arg.payload.data.cellId) { const newVMs = [...arg.prevState.cellVMs]; const current = arg.prevState.cellVMs[index]; const newCell = { ...current, - inputBlockText: arg.payload.code, + inputBlockText: arg.payload.data.code, focused: false, cell: { ...current.cell, data: { ...current.cell.data, - source: arg.payload.code + source: arg.payload.data.code } } }; @@ -72,8 +76,7 @@ export namespace Effects { return { ...arg.prevState, - cellVMs: newVMs, - focusedCellId: undefined + cellVMs: newVMs }; } else if (index >= 0) { // Dont change focus state if not the focused cell. Just update the code. @@ -81,12 +84,12 @@ export namespace Effects { const current = arg.prevState.cellVMs[index]; const newCell = { ...current, - inputBlockText: arg.payload.code, + inputBlockText: arg.payload.data.code, cell: { ...current.cell, data: { ...current.cell.data, - source: arg.payload.code + source: arg.payload.data.code } } }; @@ -104,8 +107,9 @@ export namespace Effects { } export function deselectCell(arg: NativeEditorReducerArg): IMainState { - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); - if (index >= 0 && arg.prevState.selectedCellId === arg.payload.cellId) { + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); + const selectionInfo = getSelectedAndFocusedInfo(arg.prevState); + if (index >= 0 && selectionInfo.selectedCellId === arg.payload.data.cellId) { const newVMs = [...arg.prevState.cellVMs]; const target = arg.prevState.cellVMs[index]; const newCell = { @@ -118,55 +122,58 @@ export namespace Effects { return { ...arg.prevState, - cellVMs: newVMs, - selectedCellId: undefined + cellVMs: newVMs }; } return arg.prevState; } - export function selectCell(arg: NativeEditorReducerArg): IMainState { + /** + * Select a cell. + * + * @param {boolean} [shouldFocusCell] If provided, then will control the focus behavior of the cell. (defaults to focus state of previously selected cell). + */ + export function selectCell(arg: NativeEditorReducerArg, shouldFocusCell?: boolean): IMainState { // Skip doing anything if already selected. - if (arg.payload.cellId !== arg.prevState.selectedCellId) { + const selectionInfo = getSelectedAndFocusedInfo(arg.prevState); + if (arg.payload.data.cellId !== selectionInfo.selectedCellId) { let prevState = arg.prevState; - const addIndex = prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); - + const addIndex = prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); + const someOtherCellWasFocusedAndSelected = selectionInfo.focusedCellId === selectionInfo.selectedCellId && !!selectionInfo.focusedCellId; // First find the old focused cell and unfocus it - let removeFocusIndex = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.prevState.focusedCellId); + let removeFocusIndex = arg.prevState.cellVMs.findIndex(c => c.cell.id === selectionInfo.focusedCellId); if (removeFocusIndex < 0) { - removeFocusIndex = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.prevState.selectedCellId); + removeFocusIndex = arg.prevState.cellVMs.findIndex(c => c.cell.id === selectionInfo.selectedCellId); } if (removeFocusIndex >= 0) { const oldFocusCell = prevState.cellVMs[removeFocusIndex]; const oldCode = oldFocusCell.uncomittedText || oldFocusCell.inputBlockText; - prevState = unfocusCell({ ...arg, prevState, payload: { cellId: prevState.cellVMs[removeFocusIndex].cell.id, code: oldCode } }); - prevState = deselectCell({ ...arg, prevState, payload: { cellId: prevState.cellVMs[removeFocusIndex].cell.id } }); + prevState = unfocusCell({ ...arg, prevState, payload: { ...arg.payload, data: { cellId: prevState.cellVMs[removeFocusIndex].cell.id, code: oldCode } } }); + prevState = deselectCell({ ...arg, prevState, payload: { ...arg.payload, data: { cellId: prevState.cellVMs[removeFocusIndex].cell.id } } }); } const newVMs = [...prevState.cellVMs]; - if (addIndex >= 0 && arg.payload.cellId !== prevState.selectedCellId) { + if (addIndex >= 0 && arg.payload.data.cellId !== selectionInfo.selectedCellId) { newVMs[addIndex] = { ...newVMs[addIndex], - focused: prevState.focusedCellId !== undefined && prevState.focusedCellId === prevState.selectedCellId, + focused: typeof shouldFocusCell === 'boolean' ? shouldFocusCell : someOtherCellWasFocusedAndSelected, selected: true, - cursorPos: arg.payload.cursorPos + cursorPos: arg.payload.data.cursorPos }; } return { ...prevState, - cellVMs: newVMs, - focusedCellId: prevState.focusedCellId !== undefined ? arg.payload.cellId : undefined, - selectedCellId: arg.payload.cellId + cellVMs: newVMs }; } return arg.prevState; } export function toggleLineNumbers(arg: NativeEditorReducerArg): IMainState { - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (index >= 0) { const newVMs = [...arg.prevState.cellVMs]; newVMs[index] = { ...newVMs[index], showLineNumbers: !newVMs[index].showLineNumbers }; @@ -179,7 +186,7 @@ export namespace Effects { } export function toggleOutput(arg: NativeEditorReducerArg): IMainState { - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (index >= 0) { const newVMs = [...arg.prevState.cellVMs]; newVMs[index] = { ...newVMs[index], hideOutput: !newVMs[index].hideOutput }; @@ -193,7 +200,7 @@ export namespace Effects { export function updateSettings(arg: NativeEditorReducerArg): IMainState { // String arg should be the IDataScienceExtraSettings - const newSettingsJSON = JSON.parse(arg.payload); + const newSettingsJSON = JSON.parse(arg.payload.data); const newSettings = newSettingsJSON; const newEditorOptions = computeEditorOptions(newSettings); const newFontFamily = newSettings.extraSettings ? newSettings.extraSettings.fontFamily : arg.prevState.font.family; diff --git a/src/datascience-ui/native-editor/redux/reducers/execution.ts b/src/datascience-ui/native-editor/redux/reducers/execution.ts index 86be83ac613a..bfeb0ebbd176 100644 --- a/src/datascience-ui/native-editor/redux/reducers/execution.ts +++ b/src/datascience-ui/native-editor/redux/reducers/execution.ts @@ -8,8 +8,8 @@ import { InteractiveWindowMessages } from '../../../../client/datascience/intera import { CellState } from '../../../../client/datascience/types'; import { concatMultilineStringInput } from '../../../common'; import { createCellFrom } from '../../../common/cellFactory'; -import { CursorPos, ICellViewModel, IMainState } from '../../../interactive-common/mainState'; -import { createPostableAction } from '../../../interactive-common/redux/postOffice'; +import { CursorPos, getSelectedAndFocusedInfo, ICellViewModel, IMainState } from '../../../interactive-common/mainState'; +import { createPostableAction } from '../../../interactive-common/redux/helpers'; import { Helpers } from '../../../interactive-common/redux/reducers/helpers'; import { CommonActionType, ICellAction, IChangeCellTypeAction, ICodeAction, IExecuteAction } from '../../../interactive-common/redux/reducers/types'; import { QueueAnotherFunc } from '../../../react-common/reduxUtils'; @@ -54,7 +54,7 @@ export namespace Execution { } export function executeAbove(arg: NativeEditorReducerArg): IMainState { - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (index > 0) { const codes = arg.prevState.cellVMs.filter((_c, i) => i < index).map(c => concatMultilineStringInput(c.cell.data.source)); return executeRange(arg.prevState, 0, index - 1, codes, arg.queueAction); @@ -63,46 +63,51 @@ export namespace Execution { } export function executeCell(arg: NativeEditorReducerArg): IMainState { - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (index >= 0) { // Start executing this cell. - const executeResult = executeRange(arg.prevState, index, index, [arg.payload.code], arg.queueAction); + const executeResult = executeRange(arg.prevState, index, index, [arg.payload.data.code], arg.queueAction); // Modify the execute result if moving - switch (arg.payload.moveOp) { - case 'add': - // Add a new cell below - return Creation.insertBelow({ ...arg, prevState: executeResult }); - - case 'select': - // Select the cell below this one, but don't focus it - if (index < arg.prevState.cellVMs.length - 1) { - return Effects.selectCell({ + // Use `if` instead of `switch case` to ensure type safety. + if (arg.payload.data.moveOp === 'add') { + // Add a new cell below + return Creation.insertBelow({ ...arg, prevState: executeResult, payload: { ...arg.payload, data: { ...arg.payload.data } } }); + } else if (arg.payload.data.moveOp === 'select') { + // Select the cell below this one, but don't focus it + if (index < arg.prevState.cellVMs.length - 1) { + return Effects.selectCell( + { ...arg, prevState: { ...executeResult }, payload: { ...arg.payload, - cellId: arg.prevState.cellVMs[index + 1].cell.id, - cursorPos: CursorPos.Current + data: { + ...arg.payload.data, + cellId: arg.prevState.cellVMs[index + 1].cell.id, + cursorPos: CursorPos.Current + } } - }); - } - return executeResult; - - default: - return executeResult; + }, + // Select the next cell, but do not set focus to it. + false + ); + } + return executeResult; + } else { + return executeResult; } } return arg.prevState; } export function executeCellAndBelow(arg: NativeEditorReducerArg): IMainState { - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (index >= 0) { const codes = arg.prevState.cellVMs.filter((_c, i) => i > index).map(c => concatMultilineStringInput(c.cell.data.source)); - return executeRange(arg.prevState, index, index + codes.length, [arg.payload.code, ...codes], arg.queueAction); + return executeRange(arg.prevState, index, index + codes.length, [arg.payload.data.code, ...codes], arg.queueAction); } return arg.prevState; } @@ -111,7 +116,10 @@ export namespace Execution { // This is the same thing as executing the first cell and all below const firstCell = arg.prevState.cellVMs.length > 0 ? arg.prevState.cellVMs[0].cell.id : undefined; if (firstCell) { - return executeCellAndBelow({ ...arg, payload: { cellId: firstCell, code: concatMultilineStringInput(arg.prevState.cellVMs[0].cell.data.source) } }); + return executeCellAndBelow({ + ...arg, + payload: { ...arg.payload, data: { cellId: firstCell, code: concatMultilineStringInput(arg.prevState.cellVMs[0].cell.data.source) } } + }); } return arg.prevState; @@ -119,11 +127,15 @@ export namespace Execution { export function executeSelectedCell(arg: NativeEditorReducerArg): IMainState { // This is the same thing as executing the selected cell - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.prevState.selectedCellId); - if (arg.prevState.selectedCellId && index >= 0) { + const selectionInfo = getSelectedAndFocusedInfo(arg.prevState); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === selectionInfo.selectedCellId); + if (selectionInfo.selectedCellId && index >= 0) { return executeCell({ ...arg, - payload: { cellId: arg.prevState.selectedCellId, code: concatMultilineStringInput(arg.prevState.cellVMs[index].cell.data.source), moveOp: 'none' } + payload: { + ...arg.payload, + data: { cellId: selectionInfo.selectedCellId, code: concatMultilineStringInput(arg.prevState.cellVMs[index].cell.data.source), moveOp: 'none' } + } }); } @@ -144,16 +156,16 @@ export namespace Execution { } export function changeCellType(arg: NativeEditorReducerArg): IMainState { - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (index >= 0) { const cellVMs = [...arg.prevState.cellVMs]; const current = arg.prevState.cellVMs[index]; const newType = current.cell.data.cell_type === 'code' ? 'markdown' : 'code'; const newNotebookCell = createCellFrom(current.cell.data, newType); - newNotebookCell.source = arg.payload.currentCode; + newNotebookCell.source = arg.payload.data.currentCode; const newCell: ICellViewModel = { ...current, - inputBlockText: arg.payload.currentCode, + inputBlockText: arg.payload.data.currentCode, cell: { ...current.cell, data: newNotebookCell @@ -166,7 +178,7 @@ export namespace Execution { createPostableAction(InteractiveWindowMessages.InsertCell, { cell: cellVMs[index].cell, index, - code: arg.payload.currentCode, + code: arg.payload.data.currentCode, codeCellAboveId: Helpers.firstCodeCellAbove(arg.prevState, current.cell.id) }) ); @@ -175,7 +187,11 @@ export namespace Execution { } // When changing a cell type, also give the cell focus. - return Effects.focusCell({ ...arg, prevState: { ...arg.prevState, cellVMs }, payload: { cellId: arg.payload.cellId, cursorPos: CursorPos.Current } }); + return Effects.focusCell({ + ...arg, + prevState: { ...arg.prevState, cellVMs }, + payload: { ...arg.payload, data: { cellId: arg.payload.data.cellId, cursorPos: CursorPos.Current } } + }); } return arg.prevState; @@ -186,7 +202,6 @@ export namespace Execution { // Pop one off of our undo stack and update our redo const cells = arg.prevState.undoStack[arg.prevState.undoStack.length - 1]; const undoStack = arg.prevState.undoStack.slice(0, arg.prevState.undoStack.length - 1); - const selected = cells.findIndex(c => c.selected); const redoStack = Helpers.pushStack(arg.prevState.redoStack, arg.prevState.cellVMs); arg.queueAction(createPostableAction(InteractiveWindowMessages.Undo)); return { @@ -194,9 +209,7 @@ export namespace Execution { cellVMs: cells, undoStack: undoStack, redoStack: redoStack, - skipNextScroll: true, - selectedCellId: selected >= 0 ? cells[selected].cell.id : undefined, - focusedCellId: selected >= 0 && cells[selected].focused ? cells[selected].cell.id : undefined + skipNextScroll: true }; } @@ -209,16 +222,13 @@ export namespace Execution { const cells = arg.prevState.redoStack[arg.prevState.redoStack.length - 1]; const redoStack = arg.prevState.redoStack.slice(0, arg.prevState.redoStack.length - 1); const undoStack = Helpers.pushStack(arg.prevState.undoStack, arg.prevState.cellVMs); - const selected = cells.findIndex(c => c.selected); arg.queueAction(createPostableAction(InteractiveWindowMessages.Redo)); return { ...arg.prevState, cellVMs: cells, undoStack: undoStack, redoStack: redoStack, - skipNextScroll: true, - selectedCellId: selected >= 0 ? cells[selected].cell.id : undefined, - focusedCellId: selected >= 0 && cells[selected].focused ? cells[selected].cell.id : undefined + skipNextScroll: true }; } diff --git a/src/datascience-ui/native-editor/redux/reducers/index.ts b/src/datascience-ui/native-editor/redux/reducers/index.ts index 88daa9375dc0..ce91ddd0101e 100644 --- a/src/datascience-ui/native-editor/redux/reducers/index.ts +++ b/src/datascience-ui/native-editor/redux/reducers/index.ts @@ -1,7 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 'use strict'; -import { IncomingMessageActions } from '../../../interactive-common/redux/postOffice'; +import { InteractiveWindowMessages } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; +import { CssMessages, SharedMessages } from '../../../../client/datascience/messages'; import { CommonEffects } from '../../../interactive-common/redux/reducers/commonEffects'; import { Kernel } from '../../../interactive-common/redux/reducers/kernel'; import { Transfer } from '../../../interactive-common/redux/reducers/transfer'; @@ -13,7 +14,7 @@ import { Execution } from './execution'; import { Movement } from './movement'; // The list of reducers. 1 per message/action. -export const reducerMap: INativeEditorActionMapping = { +export const reducerMap: Partial = { // State updates [CommonActionType.INSERT_ABOVE]: Creation.insertAbove, [CommonActionType.INSERT_ABOVE_FIRST]: Creation.insertAboveFirst, @@ -27,26 +28,26 @@ export const reducerMap: INativeEditorActionMapping = { [CommonActionType.EXECUTE_CELL_AND_BELOW]: Execution.executeCellAndBelow, [CommonActionType.RESTART_KERNEL]: Kernel.restartKernel, [CommonActionType.INTERRUPT_KERNEL]: Kernel.interruptKernel, - [CommonActionType.CLEAR_ALL_OUTPUTS]: Execution.clearAllOutputs, + [InteractiveWindowMessages.ClearAllOutputs]: Execution.clearAllOutputs, [CommonActionType.EXPORT]: Transfer.exportCells, [CommonActionType.SAVE]: Transfer.save, [CommonActionType.SHOW_DATA_VIEWER]: Transfer.showDataViewer, [CommonActionType.SEND_COMMAND]: Transfer.sendCommand, [CommonActionType.SELECT_CELL]: Effects.selectCell, - [CommonActionType.SELECT_KERNEL]: Kernel.selectKernel, + [InteractiveWindowMessages.SelectKernel]: Kernel.selectKernel, [CommonActionType.SELECT_SERVER]: Kernel.selectJupyterURI, [CommonActionType.MOVE_CELL_UP]: Movement.moveCellUp, [CommonActionType.MOVE_CELL_DOWN]: Movement.moveCellDown, - [CommonActionType.DELETE_CELL]: Creation.deleteCell, + [InteractiveWindowMessages.DeleteCell]: Creation.deleteCell, [CommonActionType.TOGGLE_LINE_NUMBERS]: Effects.toggleLineNumbers, [CommonActionType.TOGGLE_OUTPUT]: Effects.toggleOutput, [CommonActionType.CHANGE_CELL_TYPE]: Execution.changeCellType, - [CommonActionType.UNDO]: Execution.undo, - [CommonActionType.REDO]: Execution.redo, + [InteractiveWindowMessages.Undo]: Execution.undo, + [InteractiveWindowMessages.Redo]: Execution.redo, [CommonActionType.ARROW_UP]: Movement.arrowUp, [CommonActionType.ARROW_DOWN]: Movement.arrowDown, [CommonActionType.EDIT_CELL]: Transfer.editCell, - [CommonActionType.SHOW_PLOT]: Transfer.showPlot, + [InteractiveWindowMessages.ShowPlot]: Transfer.showPlot, [CommonActionType.LINK_CLICK]: Transfer.linkClick, [CommonActionType.GATHER_CELL]: Transfer.gather, [CommonActionType.EDITOR_LOADED]: Transfer.started, @@ -54,27 +55,27 @@ export const reducerMap: INativeEditorActionMapping = { [CommonActionType.UNMOUNT]: Creation.unmount, // Messages from the webview (some are ignored) - [IncomingMessageActions.STARTCELL]: Creation.startCell, - [IncomingMessageActions.FINISHCELL]: Creation.finishCell, - [IncomingMessageActions.UPDATECELL]: Creation.updateCell, - [IncomingMessageActions.NOTEBOOKDIRTY]: CommonEffects.notebookDirty, - [IncomingMessageActions.NOTEBOOKCLEAN]: CommonEffects.notebookClean, - [IncomingMessageActions.LOADALLCELLS]: Creation.loadAllCells, - [IncomingMessageActions.NOTEBOOKRUNALLCELLS]: Execution.executeAllCells, - [IncomingMessageActions.NOTEBOOKRUNSELECTEDCELL]: Execution.executeSelectedCell, - [IncomingMessageActions.NOTEBOOKADDCELLBELOW]: Creation.addNewCell, - [IncomingMessageActions.DOSAVE]: Transfer.save, - [IncomingMessageActions.DELETEALLCELLS]: Creation.deleteAllCells, - [IncomingMessageActions.UNDO]: Execution.undo, - [IncomingMessageActions.REDO]: Execution.redo, - [IncomingMessageActions.STARTPROGRESS]: CommonEffects.startProgress, - [IncomingMessageActions.STOPPROGRESS]: CommonEffects.stopProgress, - [IncomingMessageActions.UPDATESETTINGS]: Effects.updateSettings, - [IncomingMessageActions.ACTIVATE]: CommonEffects.activate, - [IncomingMessageActions.RESTARTKERNEL]: Kernel.handleRestarted, - [IncomingMessageActions.GETCSSRESPONSE]: CommonEffects.handleCss, - [IncomingMessageActions.MONACOREADY]: CommonEffects.monacoReady, - [IncomingMessageActions.GETMONACOTHEMERESPONSE]: CommonEffects.monacoThemeChange, - [IncomingMessageActions.UPDATEKERNEL]: Kernel.updateStatus, - [IncomingMessageActions.LOCINIT]: CommonEffects.handleLocInit + [InteractiveWindowMessages.StartCell]: Creation.startCell, + [InteractiveWindowMessages.FinishCell]: Creation.finishCell, + [InteractiveWindowMessages.UpdateCell]: Creation.updateCell, + [InteractiveWindowMessages.NotebookDirty]: CommonEffects.notebookDirty, + [InteractiveWindowMessages.NotebookClean]: CommonEffects.notebookClean, + [InteractiveWindowMessages.LoadAllCells]: Creation.loadAllCells, + [InteractiveWindowMessages.NotebookRunAllCells]: Execution.executeAllCells, + [InteractiveWindowMessages.NotebookRunSelectedCell]: Execution.executeSelectedCell, + [InteractiveWindowMessages.NotebookAddCellBelow]: Creation.addNewCell, + [InteractiveWindowMessages.DoSave]: Transfer.save, + [InteractiveWindowMessages.DeleteAllCells]: Creation.deleteAllCells, + [InteractiveWindowMessages.Undo]: Execution.undo, + [InteractiveWindowMessages.Redo]: Execution.redo, + [InteractiveWindowMessages.StartProgress]: CommonEffects.startProgress, + [InteractiveWindowMessages.StopProgress]: CommonEffects.stopProgress, + [SharedMessages.UpdateSettings]: Effects.updateSettings, + [InteractiveWindowMessages.Activate]: CommonEffects.activate, + [InteractiveWindowMessages.RestartKernel]: Kernel.handleRestarted, + [CssMessages.GetCssResponse]: CommonEffects.handleCss, + [InteractiveWindowMessages.MonacoReady]: CommonEffects.monacoReady, + [CssMessages.GetMonacoThemeResponse]: CommonEffects.monacoThemeChange, + [InteractiveWindowMessages.UpdateKernel]: Kernel.updateStatus, + [SharedMessages.LocInit]: CommonEffects.handleLocInit }; diff --git a/src/datascience-ui/native-editor/redux/reducers/movement.ts b/src/datascience-ui/native-editor/redux/reducers/movement.ts index ca8d51aa6120..b21e8a8ab1f9 100644 --- a/src/datascience-ui/native-editor/redux/reducers/movement.ts +++ b/src/datascience-ui/native-editor/redux/reducers/movement.ts @@ -3,7 +3,7 @@ 'use strict'; import { InteractiveWindowMessages } from '../../../../client/datascience/interactive-common/interactiveWindowTypes'; import { CursorPos, IMainState } from '../../../interactive-common/mainState'; -import { createPostableAction } from '../../../interactive-common/redux/postOffice'; +import { createPostableAction } from '../../../interactive-common/redux/helpers'; import { Helpers } from '../../../interactive-common/redux/reducers/helpers'; import { ICellAction, ICodeAction } from '../../../interactive-common/redux/reducers/types'; import { NativeEditorReducerArg } from '../mapping'; @@ -12,10 +12,10 @@ import { Effects } from './effects'; export namespace Movement { export function moveCellUp(arg: NativeEditorReducerArg): IMainState { const newVMs = [...arg.prevState.cellVMs]; - const index = newVMs.findIndex(cvm => cvm.cell.id === arg.payload.cellId); + const index = newVMs.findIndex(cvm => cvm.cell.id === arg.payload.data.cellId); if (index > 0) { [newVMs[index - 1], newVMs[index]] = [newVMs[index], newVMs[index - 1]]; - arg.queueAction(createPostableAction(InteractiveWindowMessages.SwapCells, { firstCellId: arg.payload.cellId!, secondCellId: newVMs[index].cell.id })); + arg.queueAction(createPostableAction(InteractiveWindowMessages.SwapCells, { firstCellId: arg.payload.data.cellId!, secondCellId: newVMs[index].cell.id })); return { ...arg.prevState, cellVMs: newVMs, @@ -28,10 +28,10 @@ export namespace Movement { export function moveCellDown(arg: NativeEditorReducerArg): IMainState { const newVMs = [...arg.prevState.cellVMs]; - const index = newVMs.findIndex(cvm => cvm.cell.id === arg.payload.cellId); + const index = newVMs.findIndex(cvm => cvm.cell.id === arg.payload.data.cellId); if (index < newVMs.length - 1) { [newVMs[index + 1], newVMs[index]] = [newVMs[index], newVMs[index + 1]]; - arg.queueAction(createPostableAction(InteractiveWindowMessages.SwapCells, { firstCellId: arg.payload.cellId!, secondCellId: newVMs[index].cell.id })); + arg.queueAction(createPostableAction(InteractiveWindowMessages.SwapCells, { firstCellId: arg.payload.data.cellId!, secondCellId: newVMs[index].cell.id })); return { ...arg.prevState, cellVMs: newVMs, @@ -43,14 +43,14 @@ export namespace Movement { } export function arrowUp(arg: NativeEditorReducerArg): IMainState { - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (index > 0) { - const newState = Effects.selectCell({ ...arg, payload: { cellId: arg.prevState.cellVMs[index - 1].cell.id, cursorPos: CursorPos.Bottom } }); + const newState = Effects.selectCell({ ...arg, payload: { ...arg.payload, data: { cellId: arg.prevState.cellVMs[index - 1].cell.id, cursorPos: CursorPos.Bottom } } }); const newVMs = [...newState.cellVMs]; newVMs[index] = Helpers.asCellViewModel({ ...newVMs[index], - inputBlockText: arg.payload.code, - cell: { ...newVMs[index].cell, data: { ...newVMs[index].cell.data, source: arg.payload.code } } + inputBlockText: arg.payload.data.code, + cell: { ...newVMs[index].cell, data: { ...newVMs[index].cell.data, source: arg.payload.data.code } } }); return { ...newState, @@ -62,14 +62,14 @@ export namespace Movement { } export function arrowDown(arg: NativeEditorReducerArg): IMainState { - const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.cellId); + const index = arg.prevState.cellVMs.findIndex(c => c.cell.id === arg.payload.data.cellId); if (index < arg.prevState.cellVMs.length - 1) { - const newState = Effects.selectCell({ ...arg, payload: { cellId: arg.prevState.cellVMs[index + 1].cell.id, cursorPos: CursorPos.Top } }); + const newState = Effects.selectCell({ ...arg, payload: { ...arg.payload, data: { cellId: arg.prevState.cellVMs[index + 1].cell.id, cursorPos: CursorPos.Top } } }); const newVMs = [...newState.cellVMs]; newVMs[index] = Helpers.asCellViewModel({ ...newVMs[index], - inputBlockText: arg.payload.code, - cell: { ...newVMs[index].cell, data: { ...newVMs[index].cell.data, source: arg.payload.code } } + inputBlockText: arg.payload.data.code, + cell: { ...newVMs[index].cell, data: { ...newVMs[index].cell.data, source: arg.payload.data.code } } }); return { ...newState, diff --git a/src/datascience-ui/plot/mainPanel.tsx b/src/datascience-ui/plot/mainPanel.tsx index d33d4123c59e..9d1f2442a150 100644 --- a/src/datascience-ui/plot/mainPanel.tsx +++ b/src/datascience-ui/plot/mainPanel.tsx @@ -80,7 +80,7 @@ export class MainPanel extends React.Component this.postOffice.addHandler(this); // Tell the plot viewer code we have started. - this.postOffice.sendMessage(PlotViewerMessages.Started); + this.postOffice.sendMessage(PlotViewerMessages.Started); // Listen to key events window.addEventListener('keydown', this.onKeyDown); diff --git a/src/datascience-ui/react-common/postOffice.ts b/src/datascience-ui/react-common/postOffice.ts index 524721a7b6c0..899611d53830 100644 --- a/src/datascience-ui/react-common/postOffice.ts +++ b/src/datascience-ui/react-common/postOffice.ts @@ -3,7 +3,6 @@ 'use strict'; import { WebPanelMessage } from '../../client/common/application/types'; import { IDisposable } from '../../client/common/types'; -import { noop } from '../../client/common/utils/misc'; import { logMessage } from './logger'; export interface IVsCodeApi { @@ -69,60 +68,12 @@ export class PostOffice implements IDisposable { if (!this.vscodeApi && typeof acquireVsCodeApi !== 'undefined') { this.vscodeApi = acquireVsCodeApi(); // NOSONAR } - let rewireConsole = false; if (!this.registered) { - rewireConsole = true; + // rewireConsole = true; this.registered = true; window.addEventListener('message', this.baseHandler); } - if (this.vscodeApi && rewireConsole) { - const originalConsole = window.console; - const vscodeApi = this.vscodeApi; - // Replace console.log with sending a message - const customConsole = { - ...originalConsole, - // tslint:disable-next-line: no-any no-function-expression - log: function(message?: any, ..._optionalParams: any[]) { - try { - originalConsole.log.apply(arguments); - vscodeApi?.postMessage({ type: 'console_log', payload: message }); - } catch { - noop(); - } - }, - // tslint:disable-next-line: no-any no-function-expression - info: function(message?: any, ..._optionalParams: any[]) { - try { - originalConsole.info.apply(arguments); - vscodeApi?.postMessage({ type: 'console_info', payload: message }); - } catch { - noop(); - } - }, - // tslint:disable-next-line: no-any no-function-expression - error: function(message?: any, ..._optionalParams: any[]) { - try { - originalConsole.error.apply(arguments); - vscodeApi?.postMessage({ type: 'console_error', payload: message }); - } catch { - noop(); - } - }, - // tslint:disable-next-line: no-any no-function-expression - warn: function(message?: any, ..._optionalParams: any[]) { - try { - originalConsole.warn.apply(arguments); - vscodeApi?.postMessage({ type: 'console_warn', payload: message }); - } catch { - noop(); - } - } - }; - // tslint:disable-next-line: no-any - (window as any).console = customConsole; - } - return this.vscodeApi; } diff --git a/src/datascience-ui/react-common/reduxUtils.ts b/src/datascience-ui/react-common/reduxUtils.ts index 1e982cda8506..451fa74115bb 100644 --- a/src/datascience-ui/react-common/reduxUtils.ts +++ b/src/datascience-ui/react-common/reduxUtils.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. 'use strict'; import { Action, AnyAction, Middleware, Reducer } from 'redux'; +import { BaseReduxActionPayload } from '../../client/datascience/interactive-common/types'; // tslint:disable-next-line: interface-name interface TypedAnyAction extends Action { @@ -11,10 +12,11 @@ interface TypedAnyAction extends Action { } export type QueueAnotherFunc = (nextAction: Action) => void; export type QueuableAction = TypedAnyAction & { queueAction: QueueAnotherFunc }; -export type ReducerArg = T extends null | undefined +export type ReducerArg = T extends never | undefined ? { prevState: S; queueAction: QueueAnotherFunc; + payload: BaseReduxActionPayload; } : { prevState: S; @@ -23,8 +25,8 @@ export type ReducerArg = T extends null | undefined }; export type ReducerFunc = (args: ReducerArg) => S; - -export type ActionWithPayload = T extends null | undefined ? TypedAnyAction : TypedAnyAction & { payload: T }; +export type ActionWithPayload = TypedAnyAction & { payload: BaseReduxActionPayload }; +export type ActionWithOutPayloadData = TypedAnyAction & { payload: BaseReduxActionPayload }; /** * CombineReducers takes in a map of action.type to func and creates a reducer that will call the appropriate function for diff --git a/src/test/common/insidersBuild/insidersExtensionService.unit.test.ts b/src/test/common/insidersBuild/insidersExtensionService.unit.test.ts index df5f4219cff2..166fe635649b 100644 --- a/src/test/common/insidersBuild/insidersExtensionService.unit.test.ts +++ b/src/test/common/insidersBuild/insidersExtensionService.unit.test.ts @@ -99,7 +99,10 @@ suite('Insiders Extension Service - Activation', () => { let handleEdgeCases: sinon.SinonStub; let insidersInstaller: IExtensionBuildInstaller; let insidersExtensionService: InsidersExtensionService; + let envUITEST_DISABLE_INSIDERSExists = false; setup(() => { + envUITEST_DISABLE_INSIDERSExists = process.env.UITEST_DISABLE_INSIDERS !== undefined; + delete process.env.UITEST_DISABLE_INSIDERS; extensionChannelService = mock(ExtensionChannelService); insidersInstaller = mock(InsidersBuildInstaller); appEnvironment = mock(ApplicationEnvironment); @@ -112,6 +115,9 @@ suite('Insiders Extension Service - Activation', () => { }); teardown(() => { + if (envUITEST_DISABLE_INSIDERSExists) { + process.env.UITEST_DISABLE_INSIDERS = '1'; + } sinon.restore(); }); diff --git a/src/test/datascience/dataScienceIocContainer.ts b/src/test/datascience/dataScienceIocContainer.ts index 732eb8e83237..04c71fe303ab 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, @@ -91,7 +92,7 @@ import { PersistentStateFactory } from '../../client/common/persistentState'; import { IS_WINDOWS } from '../../client/common/platform/constants'; import { PathUtils } from '../../client/common/platform/pathUtils'; import { RegistryImplementation } from '../../client/common/platform/registry'; -import { IRegistry } from '../../client/common/platform/types'; +import { IFileSystem, IRegistry } from '../../client/common/platform/types'; import { CurrentProcess } from '../../client/common/process/currentProcess'; import { BufferDecoder } from '../../client/common/process/decoder'; import { ProcessLogger } from '../../client/common/process/logger'; @@ -146,9 +147,9 @@ 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 { 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'; @@ -211,6 +212,7 @@ import { INotebookExporter, INotebookImporter, INotebookServer, + INotebookStorage, IPlotViewer, IPlotViewerProvider, IStatusProvider, @@ -283,9 +285,11 @@ 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'; +import { MockFileSystem } from './mockFileSystem'; import { MockJupyterManager, SupportedCommands } from './mockJupyterManager'; import { MockJupyterManagerFactory } from './mockJupyterManagerFactory'; import { MockLanguageServerAnalysisOptions } from './mockLanguageServerAnalysisOptions'; @@ -405,6 +409,7 @@ export class DataScienceIocContainer extends UnitTestIocContainer { const testWorkspaceFolder = path.join(EXTENSION_ROOT_DIR, 'src', 'test', 'datascience'); this.registerFileSystemTypes(); + this.serviceManager.rebindInstance(IFileSystem, new MockFileSystem()); this.serviceManager.addSingleton(IJupyterExecution, JupyterExecutionFactory); this.serviceManager.addSingleton(IInteractiveWindowProvider, TestInteractiveWindowProvider); this.serviceManager.addSingleton(IDataViewerProvider, DataViewerProvider); @@ -437,6 +442,8 @@ export class DataScienceIocContainer extends UnitTestIocContainer { this.serviceManager.addSingleton(IDebugLocationTracker, DebugLocationTrackerFactory); this.serviceManager.addSingleton(INotebookEditorProvider, TestNativeEditorProvider); this.serviceManager.add(INotebookEditor, NativeEditor); + 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); this.serviceManager.addSingleton(ICryptoUtils, CryptoUtils); @@ -470,7 +477,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); @@ -759,6 +765,10 @@ export class DataScienceIocContainer extends UnitTestIocContainer { this.addInterpreter(this.workingPython2, SupportedCommands.all); this.addInterpreter(this.workingPython, SupportedCommands.all); } + public setFileContents(uri: Uri, contents: string) { + const fileSystem = this.serviceManager.get(IFileSystem) as MockFileSystem; + fileSystem.addFileContents(uri.fsPath, contents); + } public async activate(): Promise { // Activate all of the extension activation services @@ -766,6 +776,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 @@ -780,7 +810,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 @@ -805,23 +835,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/nativeEditor.unit.test.ts b/src/test/datascience/interactive-ipynb/nativeEditor.unit.test.ts deleted file mode 100644 index 3c69bdd364b9..000000000000 --- a/src/test/datascience/interactive-ipynb/nativeEditor.unit.test.ts +++ /dev/null @@ -1,644 +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 { ExperimentsManager } from '../../../client/common/experiments'; -import { IFileSystem } from '../../../client/common/platform/types'; -import { IConfigurationService, ICryptoUtils, IExperimentsManager, 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; - let experimentsManager: IExperimentsManager; - 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); - experimentsManager = mock(ExperimentsManager); - const settings = mock(PythonSettings); - const settingsChangedEvent = new EventEmitter(); - - context.setup(c => c.globalStoragePath).returns(() => path.join(EXTENSION_ROOT_DIR, 'src', 'test', 'datascience', 'WorkspaceDir')); - - when(experimentsManager.inExperiment(anything())).thenReturn(false); - 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), - instance(experimentsManager) - ); - } - - 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..acafed51f0b9 100644 --- a/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts +++ b/src/test/datascience/interactive-ipynb/nativeEditorProvider.unit.test.ts @@ -8,172 +8,98 @@ import { expect } from 'chai'; import { instance, mock, when } from 'ts-mockito'; import * as typemoq from 'typemoq'; -import { EventEmitter, TextDocument, TextEditor, Uri } from 'vscode'; -import { CommandManager } from '../../../client/common/application/commandManager'; -import { DocumentManager } from '../../../client/common/application/documentManager'; -import { ICommandManager, 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 { 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 { noop } from '../../../client/common/utils/misc'; import { NativeEditorProvider } from '../../../client/datascience/interactive-ipynb/nativeEditorProvider'; -import { IDataScienceErrorHandler, 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'; -import { sleep } from '../../core'; // tslint:disable: max-func-body-length suite('Data Science - Native Editor Provider', () => { let workspace: IWorkspaceService; 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 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); - fileSystem = mock(FileSystem); - docManager = mock(DocumentManager); - dsErrorHandler = mock(DataScienceErrorHandler); - cmdManager = mock(CommandManager); workspace = mock(WorkspaceService); - changeActiveTextEditorEventEmitter = new EventEmitter(); + 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.object); + }); + storage.setup(s => s.file).returns(() => storageFile); when(svcContainer.get(INotebookEditor)).thenReturn(editor.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())) + .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(fileSystem), - instance(docManager), - instance(cmdManager), - instance(dsErrorHandler) + customEditorService.object ); - } - 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(); + return registeredProvider; } - test('Open the notebook editor when an ipynb file is opened', async () => { - await testAutomaticallyOpeningNotebookEditorWhenOpeningFiles(Uri.file('some file.ipynb'), true); + 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'); }); - 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 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 new file mode 100644 index 000000000000..8fa146938ff2 --- /dev/null +++ b/src/test/datascience/interactive-ipynb/nativeEditorStorage.unit.test.ts @@ -0,0 +1,467 @@ +// 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, 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'; +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 Storage', () => { + 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; + let storage: NativeEditorStorage; + const disposables: IDisposable[] = []; + 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); + }); + + storage = new NativeEditorStorage( + disposables, + instance(executionProvider), + fileSystem.object, // Use typemoq so can save values in returns + instance(crypto), + context.object, + globalMemento, + localMemento, + cmdManager + ); + }); + + teardown(() => { + globalMemento.clear(); + sinon.reset(); + disposables.forEach(d => d.dispose()); + }); + + function executeCommand(command: E, ...rest: U) { + return cmdManager.executeCommand(command, ...rest); + } + + 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 = 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/); + }); + + test('Move cells around', async () => { + await storage.load(baseUri); + await executeCommand(Commands.NotebookStorage_SwapCells, baseUri, { firstCellId: 'NotebookImport#0', secondCellId: 'NotebookImport#1' }); + 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/); + }); + + test('Edit/delete cells', async () => { + await storage.load(baseUri); + expect(storage.isDirty).to.be.equal(false, 'Editor should not be dirty'); + await 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 = 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 = storage.cells; + expect(cells).to.be.lengthOf(2); + expect(cells[0].id).to.be.match(/NotebookImport#1/); + await executeCommand(Commands.NotebookStorage_DeleteAllCells, baseUri); + cells = storage.cells; + 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 + await localMemento.update(`notebook-storage-${file.toString()}`, differentFile); + await storage.load(file); + + // It should load with that value + const cells = storage.cells; + 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 + await globalMemento.update(`notebook-storage-${file.toString()}`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); + await storage.load(file); + + // It should load with that value + const cells = storage.cells; + 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 + await globalMemento.update(`notebook-storage-${file.toString()}`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); + + // Put another file into the global storage + await globalMemento.update(`notebook-storage-file::///bar.ipynb`, { contents: differentFile, lastModifiedTimeMs: Date.now() }); + + await storage.load(file); + + // It should load with that value + const cells = storage.cells; + 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/mockCustomEditorService.ts b/src/test/datascience/mockCustomEditorService.ts new file mode 100644 index 000000000000..bfd4613b8ec7 --- /dev/null +++ b/src/test/datascience/mockCustomEditorService.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +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'; + +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 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 async openEditor(file: Uri): Promise { + 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); + } + + await 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/mockFileSystem.ts b/src/test/datascience/mockFileSystem.ts new file mode 100644 index 000000000000..5c03402ea321 --- /dev/null +++ b/src/test/datascience/mockFileSystem.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +import { LegacyFileSystem } from '../serviceRegistry'; + +export class MockFileSystem extends LegacyFileSystem { + private contentOverloads = new Map(); + + constructor() { + super(); + } + 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 a1899597db38..b5016091fe5a 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'; @@ -369,7 +367,6 @@ for _ in range(50): return; } }; - let saveCalled = false; const appShell = TypeMoq.Mock.ofType(); appShell .setup(a => a.showErrorMessage(TypeMoq.It.isAnyString())) @@ -380,20 +377,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 +473,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 +490,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); }, () => { @@ -653,7 +655,6 @@ for _ in range(50): outputs: [], source: [] }); - const addedJSONFile = JSON.stringify(addedJSON, null, ' '); let notebookFile: { filePath: string; @@ -1187,8 +1188,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) @@ -1203,13 +1204,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) @@ -1229,8 +1230,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); @@ -1388,218 +1389,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(); @@ -1681,9 +1470,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'); @@ -1692,10 +1479,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..0401309a273d 100644 --- a/src/test/datascience/testNativeEditorProvider.ts +++ b/src/test/datascience/testNativeEditorProvider.ts @@ -4,14 +4,13 @@ import { inject, injectable } from 'inversify'; import { Event, Uri } from 'vscode'; -import { ICommandManager, IDocumentManager, 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'; 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() @@ -19,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; @@ -30,22 +32,9 @@ export class TestNativeEditorProvider implements INotebookEditorProvider { @inject(IDisposableRegistry) disposables: IDisposableRegistry, @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, 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/debuggerTest.ts b/src/test/debuggerTest.ts index 466a845947f8..810b70ab7946 100644 --- a/src/test/debuggerTest.ts +++ b/src/test/debuggerTest.ts @@ -18,7 +18,8 @@ function start() { extensionDevelopmentPath: EXTENSION_ROOT_DIR_FOR_TESTS, extensionTestsPath: path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'out', 'test', 'index'), launchArgs: [workspacePath], - version: 'stable' + version: 'insiders', + extensionTestsEnv: { ...process.env, UITEST_DISABLE_INSIDERS: '1' } }).catch(ex => { console.error('End Debugger tests (with errors)', ex); process.exit(1); diff --git a/src/test/multiRootTest.ts b/src/test/multiRootTest.ts index 0d2ea8ccb4eb..49d74149b26c 100644 --- a/src/test/multiRootTest.ts +++ b/src/test/multiRootTest.ts @@ -13,8 +13,9 @@ function start() { runTests({ extensionDevelopmentPath: EXTENSION_ROOT_DIR_FOR_TESTS, extensionTestsPath: path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'out', 'test', 'index'), - launchArgs: [workspacePath], - version: 'stable' + launchArgs: [workspacePath, '--enable-proposed-api', 'ms-python.python'], + version: 'insiders', + extensionTestsEnv: { ...process.env, UITEST_DISABLE_INSIDERS: '1' } }).catch(ex => { console.error('End Multiroot tests (with errors)', ex); process.exit(1); diff --git a/src/test/serviceRegistry.ts b/src/test/serviceRegistry.ts index 9609a97649ae..f1ecf748838f 100644 --- a/src/test/serviceRegistry.ts +++ b/src/test/serviceRegistry.ts @@ -94,7 +94,7 @@ class FakeVSCodeFileSystemAPI { return convertStat(stat, filetype); } } -class LegacyFileSystem extends FileSystem { +export class LegacyFileSystem extends FileSystem { constructor() { super(); const vscfs = new FakeVSCodeFileSystemAPI(); diff --git a/src/test/standardTest.ts b/src/test/standardTest.ts index 62c18f44c709..d762a80d6075 100644 --- a/src/test/standardTest.ts +++ b/src/test/standardTest.ts @@ -15,8 +15,9 @@ function start() { runTests({ extensionDevelopmentPath: extensionDevelopmentPath, extensionTestsPath: path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'out', 'test', 'index'), - launchArgs: [workspacePath], - version: 'stable' + launchArgs: [workspacePath, '--enable-proposed-api', 'ms-python.python'], + version: 'insiders', + extensionTestsEnv: { ...process.env, UITEST_DISABLE_INSIDERS: '1' } }).catch(ex => { console.error('End Standard tests (with errors)', ex); process.exit(1); 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; + } +}