diff --git a/news/1 Enhancements/9821.md b/news/1 Enhancements/9821.md new file mode 100644 index 000000000000..0e7967ac65ba --- /dev/null +++ b/news/1 Enhancements/9821.md @@ -0,0 +1 @@ +Add undo/redo support to notebooks. \ No newline at end of file diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index d12d54546284..a34a714c217c 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -6,10 +6,7 @@ import { CancellationToken, Position, TextDocument, Uri } from 'vscode'; import { Commands as LSCommands } from '../../activation/languageServer/constants'; import { Commands as DSCommands } from '../../datascience/constants'; -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 { INotebook } from '../../datascience/types'; import { CommandSource } from '../../testing/common/constants'; import { TestFunction, TestsToRun } from '../../testing/common/types'; import { TestDataItem, TestWorkspaceFolder } from '../../testing/types'; @@ -148,12 +145,4 @@ 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/datascience/constants.ts b/src/client/datascience/constants.ts index 774d782567cf..c8a03ab4a8b9 100644 --- a/src/client/datascience/constants.ts +++ b/src/client/datascience/constants.ts @@ -62,16 +62,6 @@ 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 { diff --git a/src/client/datascience/interactive-common/intellisense/intellisenseDocument.ts b/src/client/datascience/interactive-common/intellisense/intellisenseDocument.ts index 8b5f81b968f2..3df2d3d4d52c 100644 --- a/src/client/datascience/interactive-common/intellisense/intellisenseDocument.ts +++ b/src/client/datascience/interactive-common/intellisense/intellisenseDocument.ts @@ -3,12 +3,12 @@ 'use strict'; import '../../../common/extensions'; -import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import { EndOfLine, Position, Range, TextDocument, TextDocumentContentChangeEvent, TextLine, Uri } from 'vscode'; import * as vscodeLanguageClient from 'vscode-languageclient'; import { PYTHON_LANGUAGE } from '../../../common/constants'; import { Identifiers } from '../../constants'; +import { IEditorContentChange } from '../interactiveWindowTypes'; import { DefaultWordPattern, ensureValidWordDefinition, getWordAtText, regExpLeadsToEndlessLoop } from './wordHelper'; class IntellisenseLine implements TextLine { @@ -201,54 +201,57 @@ export class IntellisenseDocument implements TextDocument { } public loadAllCells(cells: { code: string; id: string }[]): TextDocumentContentChangeEvent[] { - let changes: TextDocumentContentChangeEvent[] = []; if (!this.inEditMode) { this.inEditMode = true; - this._version += 1; + return this.reloadCells(cells); + } + return []; + } - // Normalize all of the cells, removing \r and separating each - // with a newline - const normalized = cells.map(c => { - return { - id: c.id, - code: `${c.code.replace(/\r/g, '')}\n` - }; - }); + public reloadCells(cells: { code: string; id: string }[]): TextDocumentContentChangeEvent[] { + this._version += 1; + + // Normalize all of the cells, removing \r and separating each + // with a newline + const normalized = cells.map(c => { + return { + id: c.id, + code: `${c.code.replace(/\r/g, '')}\n` + }; + }); - // Contents are easy, just load all of the code in a row - this._contents = normalized - .map(c => c.code) - .reduce((p, c) => { - return `${p}${c}`; - }); - - // Cell ranges are slightly more complicated - let prev: number = 0; - this._cellRanges = normalized.map(c => { - const result = { - id: c.id, - start: prev, - fullEnd: prev + c.code.length, - currentEnd: prev + c.code.length - }; - prev += c.code.length; - return result; + // Contents are easy, just load all of the code in a row + this._contents = normalized + .map(c => c.code) + .reduce((p, c) => { + return `${p}${c}`; }); - // Then create the lines. - this._lines = this.createLines(); + // Cell ranges are slightly more complicated + let prev: number = 0; + this._cellRanges = normalized.map(c => { + const result = { + id: c.id, + start: prev, + fullEnd: prev + c.code.length, + currentEnd: prev + c.code.length + }; + prev += c.code.length; + return result; + }); - // Return our changes - changes = [ - { - range: this.createSerializableRange(new Position(0, 0), new Position(0, 0)), - rangeOffset: 0, - rangeLength: 0, // Adds are always zero - text: this._contents - } - ]; - } - return changes; + // Then create the lines. + this._lines = this.createLines(); + + // Return our changes + return [ + { + range: this.createSerializableRange(new Position(0, 0), new Position(0, 0)), + rangeOffset: 0, + rangeLength: 0, // Adds are always zero + text: this._contents + } + ]; } public addCell(fullCode: string, currentCode: string, id: string): TextDocumentContentChangeEvent[] { @@ -293,7 +296,24 @@ export class IntellisenseDocument implements TextDocument { ]; } - public insertCell(id: string, code: string, codeCellAbove: string | undefined): TextDocumentContentChangeEvent[] { + public reloadCell(id: string, code: string): TextDocumentContentChangeEvent[] { + this._version += 1; + + // Make sure to put a newline between this code and the next code + const newCode = `${code.replace(/\r/g, '')}\n`; + + // Figure where this goes + const index = this._cellRanges.findIndex(r => r.id === id); + if (index >= 0) { + const start = this.positionAt(this._cellRanges[index].start); + const end = this.positionAt(this._cellRanges[index].currentEnd); + return this.removeRange(newCode, start, end, index); + } + + return []; + } + + public insertCell(id: string, code: string, codeCellAboveOrIndex: string | undefined | number): TextDocumentContentChangeEvent[] { // This should only happen once for each cell. this._version += 1; @@ -301,8 +321,8 @@ export class IntellisenseDocument implements TextDocument { const newCode = `${code.replace(/\r/g, '')}\n`; // Figure where this goes - const aboveIndex = this._cellRanges.findIndex(r => r.id === codeCellAbove); - const insertIndex = aboveIndex + 1; + const aboveIndex = this._cellRanges.findIndex(r => r.id === codeCellAboveOrIndex); + const insertIndex = typeof codeCellAboveOrIndex === 'number' ? codeCellAboveOrIndex : aboveIndex + 1; // Compute where we start from. const fromOffset = insertIndex < this._cellRanges.length ? this._cellRanges[insertIndex].start : this._contents.length; @@ -356,7 +376,7 @@ export class IntellisenseDocument implements TextDocument { return []; } - public edit(editorChanges: monacoEditor.editor.IModelContentChange[], id: string): TextDocumentContentChangeEvent[] { + public editCell(editorChanges: IEditorContentChange[], id: string): TextDocumentContentChangeEvent[] { this._version += 1; // Convert the range to local (and remove 1 based) diff --git a/src/client/datascience/interactive-common/intellisense/intellisenseProvider.ts b/src/client/datascience/interactive-common/intellisense/intellisenseProvider.ts index 24ab4955477a..6c52b6e0daa6 100644 --- a/src/client/datascience/interactive-common/intellisense/intellisenseProvider.ts +++ b/src/client/datascience/interactive-common/intellisense/intellisenseProvider.ts @@ -21,13 +21,10 @@ import { noop } from '../../../common/utils/misc'; import { HiddenFileFormatString } from '../../../constants'; import { IInterpreterService, PythonInterpreter } from '../../../interpreter/contracts'; import { sendTelemetryWhenDone } from '../../../telemetry'; -import { Identifiers, Settings, Telemetry } from '../../constants'; -import { IInteractiveWindowListener, IInteractiveWindowProvider, IJupyterExecution, INotebook } from '../../types'; +import { Settings, Telemetry } from '../../constants'; +import { ICell, IInteractiveWindowListener, IInteractiveWindowProvider, IJupyterExecution, INotebook } from '../../types'; import { - IAddCell, ICancelIntellisenseRequest, - IEditCell, - IInsertCell, IInteractiveWindowMapping, ILoadAllCells, INotebookIdentity, @@ -35,9 +32,8 @@ import { IProvideCompletionItemsRequest, IProvideHoverRequest, IProvideSignatureHelpRequest, - IRemoveCell, IResolveCompletionItemRequest, - ISwapCells + NotebookModelChange } from '../interactiveWindowTypes'; import { convertStringsToSuggestions, @@ -52,6 +48,9 @@ import { IntellisenseDocument } from './intellisenseDocument'; // tslint:disable:no-any @injectable() export class IntellisenseProvider implements IInteractiveWindowListener { + public get postMessage(): Event<{ message: string; payload: any }> { + return this.postEmitter.event; + } private documentPromise: Deferred | undefined; private temporaryFile: TemporaryFile | undefined; private postEmitter: EventEmitter<{ message: string; payload: any }> = new EventEmitter<{ message: string; payload: any }>(); @@ -82,10 +81,6 @@ export class IntellisenseProvider implements IInteractiveWindowListener { } } - public get postMessage(): Event<{ message: string; payload: any }> { - return this.postEmitter.event; - } - public onMessage(message: string, payload?: any) { switch (message) { case InteractiveWindowMessages.CancelCompletionItemsRequest: @@ -109,28 +104,8 @@ export class IntellisenseProvider implements IInteractiveWindowListener { this.dispatchMessage(message, payload, this.handleResolveCompletionItemRequest); break; - case InteractiveWindowMessages.EditCell: - this.dispatchMessage(message, payload, this.editCell); - break; - - case InteractiveWindowMessages.AddCell: - this.dispatchMessage(message, payload, this.addCell); - break; - - case InteractiveWindowMessages.InsertCell: - this.dispatchMessage(message, payload, this.insertCell); - break; - - case InteractiveWindowMessages.RemoveCell: - this.dispatchMessage(message, payload, this.removeCell); - break; - - case InteractiveWindowMessages.SwapCells: - this.dispatchMessage(message, payload, this.swapCells); - break; - - case InteractiveWindowMessages.DeleteAllCells: - this.dispatchMessage(message, payload, this.removeAllCells); + case InteractiveWindowMessages.UpdateModel: + this.dispatchMessage(message, payload, this.update); break; case InteractiveWindowMessages.RestartKernel: @@ -150,6 +125,32 @@ export class IntellisenseProvider implements IInteractiveWindowListener { } } + public getDocument(resource?: Uri): Promise { + if (!this.documentPromise) { + this.documentPromise = createDeferred(); + + // Create our dummy document. Compute a file path for it. + if (this.workspaceService.rootPath || resource) { + const dir = resource ? path.dirname(resource.fsPath) : this.workspaceService.rootPath!; + const dummyFilePath = path.join(dir, HiddenFileFormatString.format(uuid().replace(/-/g, ''))); + this.documentPromise.resolve(new IntellisenseDocument(dummyFilePath)); + } else { + this.fileSystem + .createTemporaryFile('.py') + .then(t => { + this.temporaryFile = t; + const dummyFilePath = this.temporaryFile.filePath; + this.documentPromise!.resolve(new IntellisenseDocument(dummyFilePath)); + }) + .catch(e => { + this.documentPromise!.reject(e); + }); + } + } + + return this.documentPromise.promise; + } + protected async getLanguageServer(): Promise { // Resource should be our potential resource if its set. Otherwise workspace root const resource = this.potentialResource || (this.workspaceService.rootPath ? Uri.parse(this.workspaceService.rootPath) : undefined); @@ -193,32 +194,6 @@ export class IntellisenseProvider implements IInteractiveWindowListener { return this.languageServer; } - protected getDocument(resource?: Uri): Promise { - if (!this.documentPromise) { - this.documentPromise = createDeferred(); - - // Create our dummy document. Compute a file path for it. - if (this.workspaceService.rootPath || resource) { - const dir = resource ? path.dirname(resource.fsPath) : this.workspaceService.rootPath!; - const dummyFilePath = path.join(dir, HiddenFileFormatString.format(uuid().replace(/-/g, ''))); - this.documentPromise.resolve(new IntellisenseDocument(dummyFilePath)); - } else { - this.fileSystem - .createTemporaryFile('.py') - .then(t => { - this.temporaryFile = t; - const dummyFilePath = this.temporaryFile.filePath; - this.documentPromise!.resolve(new IntellisenseDocument(dummyFilePath)); - }) - .catch(e => { - this.documentPromise!.reject(e); - }); - } - } - - return this.documentPromise.promise; - } - protected async provideCompletionItems( position: monacoEditor.Position, context: monacoEditor.languages.CompletionContext, @@ -518,63 +493,99 @@ export class IntellisenseProvider implements IInteractiveWindowListener { ); } - private async addCell(request: IAddCell): Promise { - // Save this request file as our potential resource - if (request.cell.file !== Identifiers.EmptyFileName) { - this.potentialResource = Uri.file(request.cell.file); - } - - // Get the document and then pass onto the sub class - const document = await this.getDocument(request.cell.file === Identifiers.EmptyFileName ? undefined : Uri.file(request.cell.file)); - if (document) { - const changes = document.addCell(request.fullText, request.currentText, request.cell.id); - return this.handleChanges(document, changes); + private async update(request: NotebookModelChange): Promise { + // See where this request is coming from + switch (request.source) { + case 'redo': + case 'user': + return this.handleRedo(request); + case 'undo': + return this.handleUndo(request); + default: + break; } } - private async insertCell(request: IInsertCell): Promise { - // Get the document and then pass onto the sub class - const document = await this.getDocument(); - if (document) { - const changes = document.insertCell(request.cell.id, request.code, request.codeCellAboveId); - return this.handleChanges(document, changes); - } + private convertToDocCells(cells: ICell[]): { code: string; id: string }[] { + return cells + .filter(c => c.data.cell_type === 'code') + .map(c => { + return { code: concatMultilineStringInput(c.data.source), id: c.id }; + }); } - private async editCell(request: IEditCell): Promise { - // First get the document + private async handleUndo(request: NotebookModelChange): Promise { const document = await this.getDocument(); - if (document) { - const changes = document.edit(request.changes, request.id); - return this.handleChanges(document, changes); + let changes: TextDocumentContentChangeEvent[] = []; + switch (request.kind) { + case 'clear': + // This one can be ignored, it only clears outputs + break; + case 'edit': + changes = document.editCell(request.reverse, request.id); + break; + case 'add': + case 'insert': + changes = document.remove(request.cell.id); + break; + case 'modify': + // This one can be ignored. it's only used for updating cell finished state. + break; + case 'remove': + changes = document.insertCell(request.cell.id, concatMultilineStringInput(request.cell.data.source), request.index); + break; + case 'remove_all': + changes = document.reloadCells(this.convertToDocCells(request.oldCells)); + break; + case 'swap': + changes = document.swap(request.secondCellId, request.firstCellId); + break; + case 'version': + // Also ignored. updates version which we don't keep track of. + break; + default: + break; } - } - private async removeCell(request: IRemoveCell): Promise { - // First get the document - const document = await this.getDocument(); - if (document) { - const changes = document.remove(request.id); - return this.handleChanges(document, changes); - } + return this.handleChanges(document, changes); } - private async swapCells(request: ISwapCells): Promise { - // First get the document + private async handleRedo(request: NotebookModelChange): Promise { const document = await this.getDocument(); - if (document) { - const changes = document.swap(request.firstCellId, request.secondCellId); - return this.handleChanges(document, changes); + let changes: TextDocumentContentChangeEvent[] = []; + switch (request.kind) { + case 'clear': + // This one can be ignored, it only clears outputs + break; + case 'edit': + changes = document.editCell(request.forward, request.id); + break; + case 'add': + changes = document.addCell(request.fullText, request.currentText, request.cell.id); + break; + case 'insert': + changes = document.insertCell(request.cell.id, concatMultilineStringInput(request.cell.data.source), request.codeCellAboveId || request.index); + break; + case 'modify': + // This one can be ignored. it's only used for updating cell finished state. + break; + case 'remove': + changes = document.remove(request.cell.id); + break; + case 'remove_all': + changes = document.removeAll(); + break; + case 'swap': + changes = document.swap(request.firstCellId, request.secondCellId); + break; + case 'version': + // Also ignored. updates version which we don't keep track of. + break; + default: + break; } - } - private async removeAllCells(): Promise { - // First get the document - const document = await this.getDocument(); - if (document) { - const changes = document.removeAll(); - return this.handleChanges(document, changes); - } + return this.handleChanges(document, changes); } private async loadAllCells(payload: ILoadAllCells) { diff --git a/src/client/datascience/interactive-common/interactiveBase.ts b/src/client/datascience/interactive-common/interactiveBase.ts index a4522a560d25..c1ad2302e9c5 100644 --- a/src/client/datascience/interactive-common/interactiveBase.ts +++ b/src/client/datascience/interactive-common/interactiveBase.ts @@ -209,14 +209,6 @@ export abstract class InteractiveBase extends WebViewHost & Messa [CommonActionType.ARROW_UP]: MessageType.syncWithLiveShare, [CommonActionType.CHANGE_CELL_TYPE]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, [CommonActionType.CLICK_CELL]: MessageType.syncWithLiveShare, + [CommonActionType.DELETE_CELL]: MessageType.syncWithLiveShare, [CommonActionType.CODE_CREATED]: MessageType.noIdea, [CommonActionType.COPY_CELL_CODE]: MessageType.other, [CommonActionType.EDITOR_LOADED]: MessageType.other, @@ -73,7 +74,6 @@ const messageWithMessageTypes: MessageMapping & Messa // 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, @@ -83,9 +83,7 @@ const messageWithMessageTypes: MessageMapping & Messa [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, @@ -97,7 +95,6 @@ const messageWithMessageTypes: MessageMapping & Messa [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, @@ -124,8 +121,8 @@ const messageWithMessageTypes: MessageMapping & Messa [InteractiveWindowMessages.ReExecuteCell]: MessageType.other, [InteractiveWindowMessages.Redo]: MessageType.other, [InteractiveWindowMessages.RemoteAddCode]: MessageType.other, + [InteractiveWindowMessages.ReceivedUpdateModel]: MessageType.other, [InteractiveWindowMessages.RemoteReexecuteCode]: MessageType.other, - [InteractiveWindowMessages.RemoveCell]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, [InteractiveWindowMessages.ResolveCompletionItemRequest]: MessageType.other, [InteractiveWindowMessages.ResolveCompletionItemResponse]: MessageType.other, [InteractiveWindowMessages.RestartKernel]: MessageType.other, @@ -146,11 +143,11 @@ const messageWithMessageTypes: MessageMapping & Messa [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.UpdateModel]: MessageType.syncAcrossSameNotebooks | MessageType.syncWithLiveShare, [InteractiveWindowMessages.UpdateKernel]: MessageType.other, [InteractiveWindowMessages.VariableExplorerToggle]: MessageType.other, [InteractiveWindowMessages.VariablesComplete]: MessageType.other, diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index a1701d2dbe3b..53aa4114f8c1 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -19,19 +19,9 @@ import { StopWatch } from '../../common/utils/stopWatch'; import { EXTENSION_ROOT_DIR } from '../../constants'; import { IInterpreterService } from '../../interpreter/contracts'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; -import { Commands, EditorContexts, Identifiers, NativeKeyboardCommandTelemetryLookup, NativeMouseCommandTelemetryLookup, Telemetry } from '../constants'; +import { EditorContexts, Identifiers, NativeKeyboardCommandTelemetryLookup, NativeMouseCommandTelemetryLookup, Telemetry } from '../constants'; import { InteractiveBase } from '../interactive-common/interactiveBase'; -import { - IEditCell, - IInsertCell, - INativeCommand, - InteractiveWindowMessages, - IRemoveCell, - ISaveAll, - ISubmitNewCell, - ISwapCells, - SysInfoReason -} from '../interactive-common/interactiveWindowTypes'; +import { INativeCommand, InteractiveWindowMessages, ISaveAll, ISubmitNewCell, NotebookModelChange, SysInfoReason } from '../interactive-common/interactiveWindowTypes'; import { ProgressReporter } from '../progress/progressReporter'; import { CellState, @@ -49,7 +39,6 @@ import { INotebookExporter, INotebookImporter, INotebookModel, - INotebookModelChange, INotebookServerOptions, IStatusProvider, IThemeFinder @@ -65,7 +54,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { private closedEvent: EventEmitter = new EventEmitter(); private executedEvent: EventEmitter = new EventEmitter(); private modifiedEvent: EventEmitter = new EventEmitter(); - private savedEvent: EventEmitter = new EventEmitter(); private loadedPromise: Deferred = createDeferred(); private startupTimer: StopWatch = new StopWatch(); private loadedAllCells: boolean = false; @@ -189,10 +177,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { return this.modifiedEvent.event; } - public get saved(): Event { - return this.savedEvent.event; - } - public get isDirty(): boolean { return this._model ? this._model.isDirty : false; } @@ -213,24 +197,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { this.handleMessage(message, payload, this.export); break; - case InteractiveWindowMessages.EditCell: - this.handleMessage(message, payload, this.editCell); - break; - - case InteractiveWindowMessages.InsertCell: - this.handleMessage(message, payload, this.insertCell); - break; - - case InteractiveWindowMessages.RemoveCell: - this.handleMessage(message, payload, this.removeCell); - break; - - case InteractiveWindowMessages.SwapCells: - this.handleMessage(message, payload, this.swapCells); - break; - - case InteractiveWindowMessages.DeleteAllCells: - this.handleMessage(message, payload, this.removeAllCells); + case InteractiveWindowMessages.UpdateModel: + this.handleMessage(message, payload, this.updateModel); break; case InteractiveWindowMessages.NativeCommand: @@ -242,10 +210,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { this.handleMessage(message, payload, this.loadCellsComplete); break; - case InteractiveWindowMessages.ClearAllOutputs: - this.handleMessage(message, payload, this.clearAllOutputs); - break; - default: break; } @@ -276,13 +240,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { this.postMessage(InteractiveWindowMessages.NotebookAddCellBelow).ignoreErrors(); } - public async removeAllCells(): Promise { - super.removeAllCells(); - // 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 { // These are not supported. return Promise.resolve(); @@ -378,15 +335,33 @@ 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 storage - this.commandManager.executeCommand(Commands.NotebookStorage_ModifyCells, this.file, cells); + // Update these cells in our storage only when cells are finished + const modified = filtered.filter(c => c.state === CellState.finished || c.state === CellState.error); + const unmodified = this._model?.cells.filter(c => modified.find(m => m.id === c.id)); + if (modified.length > 0 && unmodified && this._model) { + this._model.update({ + source: 'user', + kind: 'modify', + newCells: modified, + oldCells: unmodified, + oldDirty: this._model.isDirty, + newDirty: true + }); + } // Tell storage about our notebook object const notebook = this.getNotebook(); - if (notebook) { + if (notebook && this._model) { const interpreter = notebook.getMatchingInterpreter(); const kernelSpec = notebook.getKernelSpec(); - this.commandManager.executeCommand(Commands.NotebookStorage_UpdateVersion, this.file, interpreter, kernelSpec); + this._model.update({ + source: 'user', + kind: 'version', + oldDirty: this._model.isDirty, + newDirty: this._model.isDirty, + interpreter, + kernelSpec + }); } // Send onto the webview. @@ -428,21 +403,33 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Actually don't close, just let the error bubble out } - private modelChanged(change: INotebookModelChange) { - if (change.isDirty !== undefined) { + private async modelChanged(change: NotebookModelChange) { + if (change.source !== 'user') { + // VS code is telling us to broadcast this to our UI. Tell the UI about the new change + await this.postMessage(InteractiveWindowMessages.UpdateModel, change); + } + + // Use the current state of the model to indicate dirty (not the message itself) + if (this._model && change.newDirty !== change.oldDirty) { this.modifiedEvent.fire(); - if (change.model.isDirty) { - return this.postMessage(InteractiveWindowMessages.NotebookDirty); + if (this._model.isDirty) { + await this.postMessage(InteractiveWindowMessages.NotebookDirty); } else { - // Going clean should only happen on a save (for now. Undo might do this too) - this.savedEvent.fire(this); - // Then tell the UI - return this.postMessage(InteractiveWindowMessages.NotebookClean); + await this.postMessage(InteractiveWindowMessages.NotebookClean); } } } + private updateModel(change: NotebookModelChange) { + // Send to our model using a command. User has done something that changes the model + if (change.source === 'user' && this._model) { + // Note, originally this was posted with a command but sometimes had problems + // with commands being handled out of order. + this._model.update(change); + } + } + private async sendInitialCellsToWebView(cells: ICell[]): Promise { sendTelemetryEvent(Telemetry.CellCount, undefined, { count: cells.length }); return this.postMessage(InteractiveWindowMessages.LoadAllCells, { cells }); @@ -465,23 +452,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { } } - private async editCell(request: IEditCell) { - this.commandManager.executeCommand(Commands.NotebookStorage_EditCell, this.file, request); - } - - private async insertCell(request: IInsertCell): Promise { - this.commandManager.executeCommand(Commands.NotebookStorage_InsertCell, this.file, request); - } - - private async removeCell(request: IRemoveCell): Promise { - this.commandManager.executeCommand(Commands.NotebookStorage_RemoveCell, this.file, request.id); - } - - private async swapCells(request: ISwapCells): Promise { - // Swap two cells in our list - this.commandManager.executeCommand(Commands.NotebookStorage_SwapCells, this.file, request); - } - @captureTelemetry(Telemetry.ConvertToPythonFile, undefined, false) private async export(cells: ICell[]): Promise { const status = this.setStatus(localize.DataScience.convertingToPythonFile(), false); @@ -534,8 +504,4 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { sendTelemetryEvent(Telemetry.NotebookOpenTime, this.startupTimer.elapsedTime); } } - - private async clearAllOutputs() { - this.commandManager.executeCommand(Commands.NotebookStorage_ClearCellOutputs, this.file); - } } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index 99ddb356b6ca..78796b9471b9 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -13,12 +13,13 @@ import * as localize from '../../common/utils/localize'; import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { Identifiers, Settings, Telemetry } from '../constants'; -import { INotebookEdit, INotebookEditor, INotebookEditorProvider, INotebookModel, INotebookModelChange, INotebookServerOptions, INotebookStorage } from '../types'; +import { NotebookModelChange } from '../interactive-common/interactiveWindowTypes'; +import { INotebookEditor, INotebookEditorProvider, INotebookModel, INotebookServerOptions, INotebookStorage } from '../types'; // Class that is registered as the custom editor provider for notebooks. VS code will call into this class when // opening an ipynb file. This class then creates a backing storage, model, and opens a view for the file. @injectable() -export class NativeEditorProvider implements INotebookEditorProvider, WebviewCustomEditorProvider, WebviewCustomEditorEditingDelegate, IAsyncDisposable { +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 { @@ -29,7 +30,7 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } private readonly _onDidChangeActiveNotebookEditor = new EventEmitter(); private readonly _onDidCloseNotebookEditor = new EventEmitter(); - private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: INotebookEdit }>(); + private readonly _editEventEmitter = new EventEmitter<{ readonly resource: Uri; readonly edit: NotebookModelChange }>(); private openedEditors: Set = new Set(); private models = new Map>(); private modelChangedHandlers: Map = new Map(); @@ -74,14 +75,22 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } }); } - public get onEdit(): Event<{ readonly resource: Uri; readonly edit: INotebookEdit }> { + public get onEdit(): Event<{ readonly resource: Uri; readonly edit: NotebookModelChange }> { return this._editEventEmitter.event; } - public applyEdits(_resource: Uri, _edits: readonly INotebookEdit[]): Thenable { - return Promise.resolve(); + public applyEdits(resource: Uri, edits: readonly NotebookModelChange[]): Thenable { + return this.loadModel(resource).then(s => { + if (s) { + edits.forEach(e => s.update({ ...e, source: 'redo' })); + } + }); } - public undoEdits(_resource: Uri, _edits: readonly INotebookEdit[]): Thenable { - return Promise.resolve(); + public undoEdits(resource: Uri, edits: readonly NotebookModelChange[]): Thenable { + return this.loadModel(resource).then(s => { + if (s) { + edits.forEach(e => s.update({ ...e, source: 'undo' })); + } + }); } public async resolveWebviewEditor(resource: Uri, panel: WebviewPanel) { try { @@ -212,16 +221,23 @@ export class NativeEditorProvider implements INotebookEditorProvider, WebviewCus } } - 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 } }); + private async modelChanged(file: Uri, change: NotebookModelChange): Promise { + // If the cells change because of a UI event, tell VS code about it + if (change.source === 'user') { + // Skip version and file changes. They can't be undone + switch (change.kind) { + case 'version': + break; + case 'file': + // Update our storage + const promise = this.models.get(change.oldFile.toString()); + this.models.delete(change.oldFile.toString()); + this.models.set(change.newFile.toString(), promise!); + break; + default: + this._editEventEmitter.fire({ resource: file, edit: change }); + break; + } } } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts index 0281b1141699..bc34569ae631 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorStorage.ts @@ -6,17 +6,16 @@ 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 { GLOBAL_MEMENTO, ICryptoUtils, 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 { Identifiers } from '../constants'; +import { IEditorContentChange, NotebookModelChange } 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'; +import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, INotebookModel, INotebookStorage } from '../types'; // tslint:disable-next-line:no-require-imports no-var-requires import detectIndent = require('detect-indent'); @@ -27,16 +26,16 @@ const NotebookTransferKey = 'notebook-transfered'; interface INativeEditorStorageState { file: Uri; cells: ICell[]; - isDirty: boolean; + changeCountSinceSave: number; notebookJson: Partial; } @injectable() -export class NativeEditorStorage implements INotebookModel, INotebookStorage, IDisposable { +export class NativeEditorStorage implements INotebookModel, INotebookStorage { public get isDirty(): boolean { - return this._state.isDirty; + return this._state.changeCountSinceSave > 0; } - public get changed(): Event { + public get changed(): Event { return this._changedEmitter.event; } public get file(): Uri { @@ -49,221 +48,263 @@ export class NativeEditorStorage implements INotebookModel, INotebookStorage, ID 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 _changedEmitter = new EventEmitter(); + private _state: INativeEditorStorageState = { file: Uri.file(''), changeCountSinceSave: 0, cells: [], notebookJson: {} }; 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); + @inject(IMemento) @named(WORKSPACE_MEMENTO) private localStorage: Memento + ) {} + + public async load(file: Uri, possibleContents?: string): Promise { + // Reload our cells + await this.loadFromFile(file, possibleContents); + return 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 }); - } - } - } + public update(change: NotebookModelChange): void { + this.handleModelChange(change); } - 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 }); + public save(): Promise { + return this.saveAs(this.file); } - 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 }); + 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.handleModelChange({ source: 'user', kind: 'file', newFile: file, oldFile: this.file, newDirty: false, oldDirty: this.isDirty }); } + return this; } - 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 }); - } + public async getJson(): Promise> { + await this.ensureNotebookJson(); + return this._state.notebookJson; } - private static async handleDeleteAllCells(s: NativeEditorStorage): Promise { - if (s.cells.length !== 0) { - s.setState({ cells: [] }); - } + public getContent(cells?: ICell[]): Promise { + return this.generateNotebookContent(cells ? cells : this.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: [] } }); - }); + private handleModelChange(change: NotebookModelChange) { + const oldDirty = this.isDirty; + let changed = false; + + switch (change.source) { + case 'redo': + case 'user': + changed = this.handleRedo(change); + break; + case 'undo': + changed = this.handleUndo(change); + break; + default: + break; + } - // 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 }); + // Forward onto our listeners if necessary + if (changed || this.isDirty !== oldDirty) { + this._changedEmitter.fire({ ...change, newDirty: this.isDirty, oldDirty }); } } - 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); - }); + private handleRedo(change: NotebookModelChange): boolean { + let changed = false; + switch (change.kind) { + case 'clear': + changed = this.clearOutputs(); + break; + case 'edit': + changed = this.editCell(change.forward, change.id); + break; + case 'insert': + changed = this.insertCell(change.cell, change.index); + break; + case 'modify': + changed = this.modifyCells(change.newCells); + break; + case 'remove': + changed = this.removeCell(change.cell); + break; + case 'remove_all': + changed = this.removeAllCells(change.newCellId); + break; + case 'swap': + changed = this.swapCells(change.firstCellId, change.secondCellId); + break; + case 'version': + this.updateVersionInfo(change.interpreter, change.kernelSpec); + break; + case 'file': + changed = !this.fileSystem.arePathsSame(this._state.file.fsPath, change.newFile.fsPath); + this._state.file = change.newFile; + this._state.changeCountSinceSave = 0; + break; + default: + break; + } - // Indicate dirty - if (!fastDeepEqual(newCells, s.cells)) { - s.setState({ cells: newCells, isDirty: true }); + // Dirty state comes from undo. At least VS code will track it that way. However + // skip version and file changes as we don't forward those to VS code + if (change.kind !== 'file' && change.kind !== 'version') { + this._state.changeCountSinceSave += 1; } + + return changed; } - 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; + private handleUndo(change: NotebookModelChange): boolean { + let changed = false; + switch (change.kind) { + case 'clear': + changed = !fastDeepEqual(this._state.cells, change.oldCells); + this._state.cells = change.oldCells; + break; + case 'edit': + this.editCell(change.reverse, change.id); + changed = true; + break; + case 'insert': + changed = this.removeCell(change.cell); + break; + case 'modify': + changed = this.modifyCells(change.oldCells); + break; + case 'remove': + changed = this.insertCell(change.cell, change.index); + break; + case 'remove_all': + this._state.cells = change.oldCells; + changed = true; + break; + case 'swap': + changed = this.swapCells(change.firstCellId, change.secondCellId); + break; + default: + break; } - 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 || ''; - } + // Dirty state comes from undo. At least VS code will track it that way. + // Note unlike redo, 'file' and 'version' are not possible on undo as + // we don't send them to VS code. + this._state.changeCountSinceSave -= 1; + + return changed; } - // tslint:disable-next-line: no-any - private static asCell(cell: any): ICell { - return cell as ICell; + private removeAllCells(newCellId: string) { + this._state.cells = []; + this._state.cells.push(this.createEmptyCell(newCellId)); + return true; } - public dispose(): void { - NativeEditorStorage.storageMap.delete(this.file.toString()); + private applyCellContentChange(change: IEditorContentChange, id: string): boolean { + const normalized = change.text.replace(/\r/g, ''); + + // Figure out which cell we're editing. + const index = this.cells.findIndex(c => c.id === id); + if (index >= 0) { + // This is an actual edit. + const contents = concatMultilineStringInput(this.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 newCell = { ...this.cells[index], data: { ...this.cells[index].data, source: newContents } }; + this._state.cells[index] = this.asCell(newCell); + return true; + } + } + return false; } - 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; + private editCell(changes: IEditorContentChange[], id: string): boolean { + // Apply the changes to the visible cell list + if (changes && changes.length) { + return changes.map(c => this.applyCellContentChange(c, id)).reduce((p, c) => p || c, false); + } + + return false; } - public save(): Promise { - return this.saveAs(this.file); + private swapCells(firstCellId: string, secondCellId: string) { + const first = this.cells.findIndex(v => v.id === firstCellId); + const second = this.cells.findIndex(v => v.id === secondCellId); + if (first >= 0 && second >= 0 && first !== second) { + const temp = { ...this.cells[first] }; + this._state.cells[first] = this.asCell(this.cells[second]); + this._state.cells[second] = this.asCell(temp); + return true; + } + return false; } - 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 }); + private modifyCells(cells: ICell[]): boolean { + // Update these cells in our list + cells.forEach(c => { + const index = this.cells.findIndex(v => v.id === c.id); + this._state.cells[index] = this.asCell(c); + }); + return true; + } + + private removeCell(cell: ICell): boolean { + const index = this.cells.findIndex(c => c.id === cell.id); + if (index >= 0) { + this._state.cells.splice(index, 1); + return true; } - return this; + return false; } - public async getJson(): Promise> { - await this.ensureNotebookJson(); - return this._state.notebookJson; + private clearOutputs(): boolean { + const newCells = this.cells.map(c => this.asCell({ ...c, data: { ...c.data, execution_count: null, outputs: [] } })); + const result = !fastDeepEqual(newCells, this.cells); + this._state.cells = newCells; + return result; } - public getContent(cells?: ICell[]): Promise { - return this.generateNotebookContent(cells ? cells : this.cells); + private insertCell(cell: ICell, index: number): boolean { + // Insert a cell into our visible list based on the index. They should be in sync + this._state.cells.splice(index, 0, cell); + return true; } - // 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 updateVersionInfo(interpreter: PythonInterpreter | undefined, kernelSpec: IJupyterKernelSpec | LiveKernelModel | undefined) { + // Get our kernel_info and language_info from the current notebook + if (interpreter && interpreter.version && this._state.notebookJson.metadata && this._state.notebookJson.metadata.language_info) { + this._state.notebookJson.metadata.language_info.version = interpreter.version.raw; + } + + if (kernelSpec && this._state.notebookJson.metadata && !this._state.notebookJson.metadata.kernelspec) { + // Add a new spec in this case + this._state.notebookJson.metadata.kernelspec = { + name: kernelSpec.name || kernelSpec.display_name || '', + display_name: kernelSpec.display_name || kernelSpec.name || '' + }; + } else if (kernelSpec && this._state.notebookJson.metadata && this._state.notebookJson.metadata.kernelspec) { + // Spec exists, just update name and display_name + this._state.notebookJson.metadata.kernelspec.name = kernelSpec.name || kernelSpec.display_name || ''; + this._state.notebookJson.metadata.kernelspec.display_name = kernelSpec.display_name || kernelSpec.name || ''; } } - 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 { + // tslint:disable-next-line: no-any + private asCell(cell: any): ICell { + // Works around problems with setting a cell to another one in the nyc compiler. + return cell as ICell; + } + + private async loadFromFile(file: Uri, possibleContents?: string) { // Save file - this.setState({ file }); + this._state.file = file; try { // Attempt to read the contents if a viable file @@ -273,20 +314,20 @@ export class NativeEditorStorage implements INotebookModel, INotebookStorage, ID const dirtyContents = await this.getStoredContents(); if (dirtyContents) { // This means we're dirty. Indicate dirty and load from this content - return this.loadContents(dirtyContents, true); + this.loadContents(dirtyContents); } else { // Load without setting dirty - return this.loadContents(contents, false); + this.loadContents(contents); } } catch { // May not exist at this time. Should always have a single cell though - return [this.createEmptyCell()]; + return [this.createEmptyCell(uuid())]; } } - private createEmptyCell() { + private createEmptyCell(id: string) { return { - id: uuid(), + id, line: 0, file: Identifiers.EmptyFileName, state: CellState.finished, @@ -294,7 +335,7 @@ export class NativeEditorStorage implements INotebookModel, INotebookStorage, ID }; } - private async loadContents(contents: string | undefined, forceDirty: boolean): Promise { + private loadContents(contents: string | undefined) { // tslint:disable-next-line: no-any const json = contents ? (JSON.parse(contents) as Partial) : undefined; @@ -329,14 +370,11 @@ export class NativeEditorStorage implements INotebookModel, INotebookStorage, ID // Make sure at least one if (remapped.length === 0) { - remapped.splice(0, 0, this.createEmptyCell()); - forceDirty = true; + remapped.splice(0, 0, this.createEmptyCell(uuid())); } // Save as our visible list - this.setState({ cells: remapped, isDirty: forceDirty }); - - return this.cells; + this._state.cells = remapped; } private async extractPythonMainVersion(notebookData: Partial): Promise { @@ -408,38 +446,6 @@ export class NativeEditorStorage implements INotebookModel, INotebookStorage, ID } 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()}`; } diff --git a/src/client/datascience/interactive-window/interactiveWindow.ts b/src/client/datascience/interactive-window/interactiveWindow.ts index 911564318af4..5fac9b234bd3 100644 --- a/src/client/datascience/interactive-window/interactiveWindow.ts +++ b/src/client/datascience/interactive-window/interactiveWindow.ts @@ -16,7 +16,7 @@ import { IInterpreterService } from '../../interpreter/contracts'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { EditorContexts, Identifiers, Telemetry } from '../constants'; import { InteractiveBase } from '../interactive-common/interactiveBase'; -import { InteractiveWindowMessages, ISubmitNewCell, SysInfoReason } from '../interactive-common/interactiveWindowTypes'; +import { InteractiveWindowMessages, ISubmitNewCell, NotebookModelChange, SysInfoReason } from '../interactive-common/interactiveWindowTypes'; import { ProgressReporter } from '../progress/progressReporter'; import { ICell, @@ -185,6 +185,10 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi this.handleMessage(message, payload, this.handleReturnAllCells); break; + case InteractiveWindowMessages.UpdateModel: + this.handleMessage(message, payload, this.handleModelChange); + break; + default: break; } @@ -339,6 +343,17 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi } } + private handleModelChange(update: NotebookModelChange) { + // Send telemetry for delete and delete all. We don't send telemetry for the other updates yet + if (update.source === 'user') { + if (update.kind === 'remove_all') { + sendTelemetryEvent(Telemetry.DeleteAllCells); + } else if (update.kind === 'remove') { + sendTelemetryEvent(Telemetry.DeleteCell); + } + } + } + // tslint:disable-next-line:no-any private handleReturnAllCells(cells: ICell[]) { // See what we're waiting for. diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index 1943107e992a..8526cda143c2 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -14,6 +14,7 @@ import { IAsyncDisposable, IDataScienceSettings, IDisposable } from '../common/t import { StopWatch } from '../common/utils/stopWatch'; import { PythonInterpreter } from '../interpreter/contracts'; import { JupyterCommands } from './constants'; +import { NotebookModelChange } from './interactive-common/interactiveWindowTypes'; import { JupyterServerInfo } from './jupyter/jupyterConnection'; import { JupyterInstallError } from './jupyter/jupyterInstallError'; import { JupyterKernelSpec } from './jupyter/kernels/jupyterKernelSpec'; @@ -320,7 +321,6 @@ export interface INotebookEditor extends IInteractiveBase { readonly closed: Event; readonly executed: Event; readonly modified: Event; - readonly saved: Event; /** * Is this notebook representing an untitled file which has never been saved yet. */ @@ -727,28 +727,15 @@ 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 changed: Event; readonly cells: ICell[]; getJson(): Promise>; getContent(cells?: ICell[]): Promise; + update(change: NotebookModelChange): void; } export const INotebookStorage = Symbol('INotebookStorage'); diff --git a/src/datascience-ui/history-react/interactiveCell.tsx b/src/datascience-ui/history-react/interactiveCell.tsx index ac429959f6b5..7ca1ef305ba7 100644 --- a/src/datascience-ui/history-react/interactiveCell.tsx +++ b/src/datascience-ui/history-react/interactiveCell.tsx @@ -11,7 +11,6 @@ import { connect } from 'react-redux'; import { Identifiers } from '../../client/datascience/constants'; import { CellState, IDataScienceExtraSettings } from '../../client/datascience/types'; -import { concatMultilineStringInput } from '../common'; import { CellInput } from '../interactive-common/cellInput'; import { CellOutput } from '../interactive-common/cellOutput'; import { CollapseButton } from '../interactive-common/collapseButton'; @@ -23,6 +22,7 @@ import { IKeyboardEvent } from '../react-common/event'; import { Image, ImageName } from '../react-common/image'; import { ImageButton } from '../react-common/imageButton'; import { getLocString } from '../react-common/locReactSide'; +import { IMonacoModelContentChangeEvent } from '../react-common/monacoHelpers'; import { actionCreators } from './redux/actions'; interface IInteractiveCellBaseProps { @@ -250,6 +250,8 @@ export class InteractiveCell extends React.Component { keyDown={this.isEditCell() ? this.onEditCellKeyDown : undefined} showLineNumbers={this.props.cellVM.showLineNumbers} font={this.props.font} + disableUndoStack={this.props.cellVM.cell.id !== Identifiers.EditCellId} + codeVersion={this.props.cellVM.codeVersion ? this.props.cellVM.codeVersion : 0} /> ); } @@ -264,14 +266,8 @@ export class InteractiveCell extends React.Component { this.props.unfocus(this.getCell().id); }; - private getCurrentCode(): string { - // Get current monaco code, if not available fallback to cell data source - const contents = this.codeRef.current ? this.codeRef.current.getContents() : undefined; - return contents || concatMultilineStringInput(this.props.cellVM.cell.data.source); - } - - private onCodeChange = (changes: monacoEditor.editor.IModelContentChange[], cellId: string, modelId: string) => { - this.props.editCell(cellId, changes, modelId, this.getCurrentCode()); + private onCodeChange = (e: IMonacoModelContentChangeEvent) => { + this.props.editCell(this.getCell().id, e); }; private onCodeCreated = (_code: string, _file: string, cellId: string, modelId: string) => { @@ -312,6 +308,8 @@ export class InteractiveCell extends React.Component { this.editCellEscape(e); } else if (e.code === 'Enter' && e.shiftKey) { this.editCellSubmit(e); + } else if (e.code === 'NumpadEnter' && e.shiftKey) { + this.editCellSubmit(e); } }; diff --git a/src/datascience-ui/history-react/redux/actions.ts b/src/datascience-ui/history-react/redux/actions.ts index 905c40e733a2..3fecd310aeeb 100644 --- a/src/datascience-ui/history-react/redux/actions.ts +++ b/src/datascience-ui/history-react/redux/actions.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 'use strict'; -import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import { InteractiveWindowMessages } from '../../../client/datascience/interactive-common/interactiveWindowTypes'; import { IJupyterVariable, IJupyterVariablesRequest } from '../../../client/datascience/types'; @@ -17,13 +16,14 @@ import { IScrollAction, IShowDataViewerAction } from '../../interactive-common/redux/reducers/types'; +import { IMonacoModelContentChangeEvent } from '../../react-common/monacoHelpers'; // See https://react-redux.js.org/using-react-redux/connect-mapdispatch#defining-mapdispatchtoprops-as-an-object export const actionCreators = { 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 }), + deleteCell: (cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.DELETE_CELL, { cellId }), undo: (): CommonAction => createIncomingAction(InteractiveWindowMessages.Undo), redo: (): CommonAction => createIncomingAction(InteractiveWindowMessages.Redo), linkClick: (href: string): CommonAction => createIncomingActionWithPayload(CommonActionType.LINK_CLICK, { href }), @@ -33,8 +33,16 @@ export const actionCreators = { 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 }), + editCell: (cellId: string, e: IMonacoModelContentChangeEvent): CommonAction => + createIncomingActionWithPayload(CommonActionType.EDIT_CELL, { + cellId, + version: e.versionId, + modelId: e.model.id, + forward: e.forward, + reverse: e.reverse, + id: cellId, + code: e.model.getValue() + }), submitInput: (code: string, cellId: string): CommonAction => createIncomingActionWithPayload(CommonActionType.SUBMIT_INPUT, { code, cellId }), toggleVariableExplorer: (): CommonAction => createIncomingAction(CommonActionType.TOGGLE_VARIABLE_EXPLORER), expandAll: (): CommonAction => createIncomingAction(InteractiveWindowMessages.ExpandAll), diff --git a/src/datascience-ui/history-react/redux/reducers/creation.ts b/src/datascience-ui/history-react/redux/reducers/creation.ts index e0684a2dbd68..a91183029fea 100644 --- a/src/datascience-ui/history-react/redux/reducers/creation.ts +++ b/src/datascience-ui/history-react/redux/reducers/creation.ts @@ -86,10 +86,14 @@ export namespace Creation { // We're adding a new cell here. Tell the intellisense engine we have a new cell arg.queueAction( - createPostableAction(InteractiveWindowMessages.AddCell, { + createPostableAction(InteractiveWindowMessages.UpdateModel, { + source: 'user', + kind: 'add', + oldDirty: arg.prevState.dirty, + newDirty: true, + cell: cellVM.cell, fullText: extractInputText(cellVM, result.settings), - currentText: cellVM.inputBlockText, - cell: cellVM.cell + currentText: cellVM.inputBlockText }) ); } @@ -128,8 +132,16 @@ export namespace Creation { 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.data.cellId })); + arg.queueAction( + createPostableAction(InteractiveWindowMessages.UpdateModel, { + source: 'user', + kind: 'remove', + index, + oldDirty: arg.prevState.dirty, + newDirty: true, + cell: arg.prevState.cellVMs[index].cell + }) + ); const newVMs = arg.prevState.cellVMs.filter((_c, i) => i !== index); return { diff --git a/src/datascience-ui/history-react/redux/reducers/index.ts b/src/datascience-ui/history-react/redux/reducers/index.ts index d82b8b200a12..f1c53b4b03d5 100644 --- a/src/datascience-ui/history-react/redux/reducers/index.ts +++ b/src/datascience-ui/history-react/redux/reducers/index.ts @@ -22,7 +22,7 @@ export const reducerMap: Partial = { [CommonActionType.EXPORT]: Transfer.exportCells, [CommonActionType.SAVE]: Transfer.save, [CommonActionType.SHOW_DATA_VIEWER]: Transfer.showDataViewer, - [InteractiveWindowMessages.DeleteCell]: Creation.deleteCell, + [CommonActionType.DELETE_CELL]: Creation.deleteCell, [InteractiveWindowMessages.ShowPlot]: Transfer.showPlot, [CommonActionType.LINK_CLICK]: Transfer.linkClick, [CommonActionType.GOTO_CELL]: Transfer.gotoCell, diff --git a/src/datascience-ui/interactive-common/cellInput.tsx b/src/datascience-ui/interactive-common/cellInput.tsx index 5ff000057b6f..37fa68551bbd 100644 --- a/src/datascience-ui/interactive-common/cellInput.tsx +++ b/src/datascience-ui/interactive-common/cellInput.tsx @@ -9,6 +9,7 @@ import * as React from 'react'; import { concatMultilineStringInput } from '../common'; import { IKeyboardEvent } from '../react-common/event'; +import { IMonacoModelContentChangeEvent } from '../react-common/monacoHelpers'; import { Code } from './code'; import { InputHistory } from './inputHistory'; import { ICellViewModel, IFont } from './mainState'; @@ -17,6 +18,7 @@ import { Markdown } from './markdown'; // tslint:disable-next-line: no-require-importss interface ICellInputProps { cellVM: ICellViewModel; + codeVersion: number; codeTheme: string; testMode?: boolean; history: InputHistory | undefined; @@ -26,7 +28,8 @@ interface ICellInputProps { editorMeasureClassName?: string; showLineNumbers?: boolean; font: IFont; - onCodeChange(changes: monacoEditor.editor.IModelContentChange[], cellId: string, modelId: string): void; + disableUndoStack: boolean; + onCodeChange(e: IMonacoModelContentChangeEvent): void; onCodeCreated(code: string, file: string, cellId: string, modelId: string): void; openLink(uri: monacoEditor.Uri): void; keyDown?(cellId: string, e: IKeyboardEvent): void; @@ -96,7 +99,7 @@ export class CellInput extends React.Component { readOnly={!this.props.cellVM.editable} showWatermark={this.props.showWatermark} ref={this.codeRef} - onChange={this.onCodeChange} + onChange={this.props.onCodeChange} onCreated={this.onCodeCreated} outermostParentClass="cell-wrapper" monacoTheme={this.props.monacoTheme} @@ -110,6 +113,9 @@ export class CellInput extends React.Component { showLineNumbers={this.props.showLineNumbers} useQuickEdit={this.props.cellVM.useQuickEdit} font={this.props.font} + disableUndoStack={this.props.disableUndoStack} + version={this.props.codeVersion} + previousCode={this.props.cellVM.uncommittedText} /> ); @@ -128,7 +134,7 @@ export class CellInput extends React.Component { markdown={source} codeTheme={this.props.codeTheme} testMode={this.props.testMode ? true : false} - onChange={this.onCodeChange} + onChange={this.props.onCodeChange} onCreated={this.onCodeCreated} outermostParentClass="cell-wrapper" hasFocus={this.props.cellVM.focused} @@ -142,6 +148,9 @@ export class CellInput extends React.Component { ref={this.markdownRef} useQuickEdit={false} font={this.props.font} + disableUndoStack={this.props.disableUndoStack} + version={this.props.codeVersion} + previousMarkdown={this.props.cellVM.uncommittedText} /> ); @@ -180,10 +189,6 @@ export class CellInput extends React.Component { } }; - private onCodeChange = (changes: monacoEditor.editor.IModelContentChange[], modelId: string) => { - this.props.onCodeChange(changes, this.props.cellVM.cell.id, modelId); - }; - private onCodeCreated = (code: string, modelId: string) => { this.props.onCodeCreated(code, this.props.cellVM.cell.file, this.props.cellVM.cell.id, modelId); }; diff --git a/src/datascience-ui/interactive-common/code.tsx b/src/datascience-ui/interactive-common/code.tsx index 2159047cb11a..2baab6eba609 100644 --- a/src/datascience-ui/interactive-common/code.tsx +++ b/src/datascience-ui/interactive-common/code.tsx @@ -7,11 +7,14 @@ import * as React from 'react'; import { InputHistory } from '../interactive-common/inputHistory'; import { IKeyboardEvent } from '../react-common/event'; import { getLocString } from '../react-common/locReactSide'; +import { IMonacoModelContentChangeEvent } from '../react-common/monacoHelpers'; import { Editor } from './editor'; import { CursorPos, IFont } from './mainState'; export interface ICodeProps { code: string; + previousCode: string | undefined; + version: number; codeTheme: string; testMode: boolean; readOnly: boolean; @@ -25,9 +28,10 @@ export interface ICodeProps { useQuickEdit?: boolean; font: IFont; hasFocus: boolean; - cursorPos: CursorPos; + cursorPos: CursorPos | monacoEditor.IPosition; + disableUndoStack: boolean; onCreated(code: string, modelId: string): void; - onChange(changes: monacoEditor.editor.IModelContentChange[], modelId: string): void; + onChange(e: IMonacoModelContentChangeEvent): void; openLink(uri: monacoEditor.Uri): void; keyDown?(e: IKeyboardEvent): void; focused?(): void; @@ -76,6 +80,9 @@ export class Code extends React.Component { showLineNumbers={this.props.showLineNumbers} useQuickEdit={this.props.useQuickEdit} font={this.props.font} + disableUndoStack={this.props.disableUndoStack} + version={this.props.version} + previousContent={this.props.previousCode} />
{this.getWatermarkString()} @@ -106,10 +113,10 @@ export class Code extends React.Component { return getLocString('DataScience.inputWatermark', 'Type code here and press shift-enter to run'); }; - private onModelChanged = (changes: monacoEditor.editor.IModelContentChange[], model: monacoEditor.editor.ITextModel) => { - if (!this.props.readOnly && model) { - this.setState({ allowWatermark: model.getValueLength() === 0 }); + private onModelChanged = (e: IMonacoModelContentChangeEvent) => { + if (!this.props.readOnly && e.model) { + this.setState({ allowWatermark: e.model.getValueLength() === 0 }); } - this.props.onChange(changes, model.id); + this.props.onChange(e); }; } diff --git a/src/datascience-ui/interactive-common/editor.tsx b/src/datascience-ui/interactive-common/editor.tsx index 1b9b961fe8a2..bf13ed3a488e 100644 --- a/src/datascience-ui/interactive-common/editor.tsx +++ b/src/datascience-ui/interactive-common/editor.tsx @@ -7,12 +7,15 @@ import * as React from 'react'; import { noop } from '../../client/common/utils/misc'; import { IKeyboardEvent } from '../react-common/event'; import { MonacoEditor } from '../react-common/monacoEditor'; +import { IMonacoModelContentChangeEvent } from '../react-common/monacoHelpers'; import { InputHistory } from './inputHistory'; import { CursorPos, IFont } from './mainState'; // tslint:disable-next-line: import-name export interface IEditorProps { content: string; + previousContent: string | undefined; + version: number; codeTheme: string; readOnly: boolean; testMode: boolean; @@ -26,82 +29,54 @@ export interface IEditorProps { useQuickEdit?: boolean; font: IFont; hasFocus: boolean; - cursorPos: CursorPos; + cursorPos: CursorPos | monacoEditor.IPosition; + disableUndoStack: boolean; onCreated(code: string, modelId: string): void; - onChange(changes: monacoEditor.editor.IModelContentChange[], model: monacoEditor.editor.ITextModel): void; + onChange(e: IMonacoModelContentChangeEvent): void; openLink(uri: monacoEditor.Uri): void; keyDown?(e: IKeyboardEvent): void; focused?(): void; unfocused?(): void; } -interface IEditorState { - editor: monacoEditor.editor.IStandaloneCodeEditor | undefined; - model: monacoEditor.editor.ITextModel | null; - forceMonaco: boolean; -} - -export class Editor extends React.Component { +export class Editor extends React.Component { private subscriptions: monacoEditor.IDisposable[] = []; private lastCleanVersionId: number = 0; private monacoRef: React.RefObject = React.createRef(); constructor(prop: IEditorProps) { super(prop); - this.state = { editor: undefined, model: null, forceMonaco: false }; } public componentWillUnmount = () => { this.subscriptions.forEach(d => d.dispose()); }; - public componentDidUpdate(prevProps: IEditorProps, prevState: IEditorState) { - if (this.props.hasFocus && (!prevProps.hasFocus || !prevState.editor)) { + public componentDidUpdate(prevProps: IEditorProps) { + if (this.props.hasFocus && !prevProps.hasFocus) { this.giveFocus(this.props.cursorPos); } } public render() { const classes = this.props.readOnly ? 'editor-area' : 'editor-area editor-area-editable'; - const renderEditor = - this.state.forceMonaco || this.props.useQuickEdit === undefined || this.props.useQuickEdit === false ? this.renderMonacoEditor : this.renderQuickEditor; + const renderEditor = this.renderMonacoEditor; return
{renderEditor()}
; } - public giveFocus(cursorPos: CursorPos) { - const readOnly = this.props.readOnly; - if (this.state.editor && !readOnly) { - this.state.editor.focus(); - } - if (this.state.editor && cursorPos !== CursorPos.Current) { - const current = this.state.editor.getPosition(); - const lineNumber = cursorPos === CursorPos.Top ? 1 : this.state.editor.getModel()!.getLineCount(); - const column = current && current.lineNumber === lineNumber ? current.column : 1; - this.state.editor.setPosition({ lineNumber, column }); + public giveFocus(cursorPos: CursorPos | monacoEditor.IPosition) { + if (this.monacoRef.current) { + this.monacoRef.current.giveFocus(cursorPos); } } public getContents(): string { - if (this.state.model) { - return this.state.model.getValue().replace(/\r/g, ''); + if (this.monacoRef.current) { + return this.monacoRef.current.getContents(); } return ''; } - private renderQuickEditor = (): JSX.Element => { - const readOnly = this.props.readOnly; - return ( -