diff --git a/news/1 Enhancements/6350.md b/news/1 Enhancements/6350.md new file mode 100644 index 000000000000..2d577178d028 --- /dev/null +++ b/news/1 Enhancements/6350.md @@ -0,0 +1 @@ +Change copy back to code button in the interactive window to insert wherever the current selection is. diff --git a/src/client/datascience/interactive-window/interactiveWindow.ts b/src/client/datascience/interactive-window/interactiveWindow.ts index 4673a0080d03..5ebe5a9d0679 100644 --- a/src/client/datascience/interactive-window/interactiveWindow.ts +++ b/src/client/datascience/interactive-window/interactiveWindow.ts @@ -6,6 +6,7 @@ import '../../common/extensions'; import { nbformat } from '@jupyterlab/coreutils'; import * as fs from 'fs-extra'; import { inject, injectable, multiInject } from 'inversify'; +import * as os from 'os'; import * as path from 'path'; import * as uuid from 'uuid/v4'; import { ConfigurationTarget, Event, EventEmitter, Position, Range, Selection, TextEditor, Uri, ViewColumn } from 'vscode'; @@ -31,7 +32,7 @@ import * as localize from '../../common/utils/localize'; import { StopWatch } from '../../common/utils/stopWatch'; import { IInterpreterService, PythonInterpreter } from '../../interpreter/contracts'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; -import { CellMatcher } from '../cellMatcher'; +import { generateCellRanges } from '../cellFactory'; import { EditorContexts, Identifiers, Telemetry } from '../constants'; import { ColumnWarningSize } from '../data-viewing/types'; import { JupyterInstallError } from '../jupyter/jupyterInstallError'; @@ -464,6 +465,13 @@ export class InteractiveWindow extends WebViewHost im } } + @captureTelemetry(Telemetry.CopySourceCode, undefined, false) + public copyCode(args: ICopyCode) { + this.copyCodeInternal(args.source).catch(err => { + this.applicationShell.showErrorMessage(err); + }); + } + protected async activating() { // Only activate if the active editor is empty. This means that // vscode thinks we are actually supposed to have focus. It would be @@ -927,34 +935,48 @@ export class InteractiveWindow extends WebViewHost im } } - @captureTelemetry(Telemetry.CopySourceCode, undefined, false) - private copyCode(args: ICopyCode) { - this.copyCodeInternal(args.source).catch(err => { - this.applicationShell.showErrorMessage(err); - }); - } - private async copyCodeInternal(source: string) { let editor = this.documentManager.activeTextEditor; if (!editor || editor.document.languageId !== PYTHON_LANGUAGE) { // Find the first visible python editor const pythonEditors = this.documentManager.visibleTextEditors.filter( - e => e.document.languageId === PYTHON_LANGUAGE); + e => e.document.languageId === PYTHON_LANGUAGE || e.document.isUntitled); if (pythonEditors.length > 0) { editor = pythonEditors[0]; } } - if (editor && editor.document.languageId === PYTHON_LANGUAGE) { - const cellMatcher = new CellMatcher(this.generateDataScienceExtraSettings()); - const hasCellsAlready = cellMatcher.isCell(editor.document.getText()); - const line = editor.document.lineCount; - const newCode = hasCellsAlready || line <= 0 ? `\n\n#%%\n${source}` : `\n\n${source}`; + if (editor && (editor.document.languageId === PYTHON_LANGUAGE || editor.document.isUntitled)) { + // Figure out if any cells in this document already. + const ranges = generateCellRanges(editor.document, this.generateDataScienceExtraSettings()); + const hasCellsAlready = ranges.length > 0; + const line = editor.selection.start.line; + const revealLine = line + 1; + let newCode = `${source}${os.EOL}`; + if (hasCellsAlready) { + // See if inside of a range or not. + const matchingRange = ranges.find(r => r.range.start.line <= line && r.range.end.line >= line); + + // If in the middle, wrap the new code + if (matchingRange && matchingRange.range.start.line < line && line < editor.document.lineCount - 1) { + newCode = `#%%${os.EOL}${source}${os.EOL}#%%${os.EOL}`; + } else { + newCode = `#%%${os.EOL}${source}${os.EOL}`; + } + } else if (editor.document.lineCount <= 0 || editor.document.isUntitled) { + // No lines in the document at all, just insert new code + newCode = `#%%${os.EOL}${source}${os.EOL}`; + } + await editor.edit((editBuilder) => { editBuilder.insert(new Position(line, 0), newCode); }); - editor.revealRange(new Range(line + 2, 0, line + source.split('\n').length + 3, 0)); - editor.selection = new Selection(new Position(line + 2, 0), new Position(line + 2, 0)); + editor.revealRange(new Range(revealLine, 0, revealLine + source.split('\n').length + 3, 0)); + + // Move selection to just beyond the text we input so that the next + // paste will be right after + const selectionLine = line + newCode.split('\n').length - 1; + editor.selection = new Selection(new Position(selectionLine, 0), new Position(selectionLine, 0)); } } diff --git a/src/test/datascience/interactiveWindow.functional.test.tsx b/src/test/datascience/interactiveWindow.functional.test.tsx index 07f27a2ad365..a5ff071c8a9b 100644 --- a/src/test/datascience/interactiveWindow.functional.test.tsx +++ b/src/test/datascience/interactiveWindow.functional.test.tsx @@ -4,18 +4,21 @@ import * as assert from 'assert'; import * as fs from 'fs-extra'; import { parse } from 'node-html-parser'; +import * as os from 'os'; import * as path from 'path'; import * as TypeMoq from 'typemoq'; -import { Disposable, TextDocument, TextEditor } from 'vscode'; +import { Disposable, Selection, TextDocument, TextEditor } from 'vscode'; import { IApplicationShell, IDocumentManager } from '../../client/common/application/types'; -import { PYTHON_LANGUAGE } from '../../client/common/constants'; import { createDeferred } from '../../client/common/utils/async'; import { noop } from '../../client/common/utils/misc'; import { generateCellsFromDocument } from '../../client/datascience/cellFactory'; import { concatMultilineString } from '../../client/datascience/common'; import { EditorContexts } from '../../client/datascience/constants'; -import { InteractiveWindowMessageListener } from '../../client/datascience/interactive-window/interactiveWindowMessageListener'; +import { InteractiveWindow } from '../../client/datascience/interactive-window/interactiveWindow'; +import { + InteractiveWindowMessageListener +} from '../../client/datascience/interactive-window/interactiveWindowMessageListener'; import { InteractiveWindowMessages } from '../../client/datascience/interactive-window/interactiveWindowTypes'; import { IInteractiveWindow, IInteractiveWindowProvider } from '../../client/datascience/types'; import { MainPanel } from '../../datascience-ui/history-react/MainPanel'; @@ -43,9 +46,11 @@ import { verifyHtmlOnCell, verifyLastCellInputState } from './interactiveWindowTestHelpers'; +import { MockEditor } from './mockTextEditor'; import { waitForUpdate } from './reactHelpers'; //import { asyncDump } from '../common/asyncDump'; +import { MockDocumentManager } from './mockDocumentManager'; // tslint:disable:max-func-body-length trailing-comma no-any no-multiline-string suite('DataScience Interactive Window output tests', () => { const disposables: Disposable[] = []; @@ -469,22 +474,10 @@ for _ in range(50): runMountedTest('Copy to source input', async (wrapper) => { const showedEditor = createDeferred(); - const textEditors: TextEditor[] = []; - const docManager = TypeMoq.Mock.ofType(); - const visibleEditor = TypeMoq.Mock.ofType(); - const dummyDocument = TypeMoq.Mock.ofType(); - dummyDocument.setup(d => d.fileName).returns(() => 'foo.py'); - dummyDocument.setup(d => d.languageId).returns(() => PYTHON_LANGUAGE); - dummyDocument.setup(d => d.lineCount).returns(() => 10); - dummyDocument.setup(d => d.getText()).returns(() => '# No cells here'); - visibleEditor.setup(v => v.show()).returns(noop); - visibleEditor.setup(v => v.revealRange(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => showedEditor.resolve()); - visibleEditor.setup(v => v.document).returns(() => dummyDocument.object); - visibleEditor.setup(v => v.edit(TypeMoq.It.isAny())).returns(() => Promise.resolve(true)); - textEditors.push(visibleEditor.object); - docManager.setup(a => a.visibleTextEditors).returns(() => textEditors); - docManager.setup(a => a.activeTextEditor).returns(() => undefined); - ioc.serviceManager.rebindInstance(IDocumentManager, docManager.object); + ioc.addDocument('# No cells here', 'foo.py'); + const docManager = ioc.get(IDocumentManager) as MockDocumentManager; + const editor = await docManager.showTextDocument(docManager.textDocuments[0]) as MockEditor; + editor.setRevealCallback(() => showedEditor.resolve()); // Create an interactive window so that it listens to the results. const interactiveWindow = await getOrCreateInteractiveWindow(); @@ -613,4 +606,17 @@ for _ in range(50): } }, () => { return ioc; }); + + runMountedTest('Copy back to source', async (_wrapper) => { + ioc.addDocument(`#%%${os.EOL}print("bar")`, 'foo.py'); + const docManager = ioc.get(IDocumentManager); + docManager.showTextDocument(docManager.textDocuments[0]); + const window = await getOrCreateInteractiveWindow() as InteractiveWindow; + window.copyCode({source: 'print("baz")'}); + assert.equal(docManager.textDocuments[0].getText(), `#%%${os.EOL}print("baz")${os.EOL}#%%${os.EOL}print("bar")`, 'Text not inserted'); + const activeEditor = docManager.activeTextEditor as MockEditor; + activeEditor.selection = new Selection(1, 2, 1, 2); + window.copyCode({source: 'print("baz")'}); + assert.equal(docManager.textDocuments[0].getText(), `#%%${os.EOL}#%%${os.EOL}print("baz")${os.EOL}#%%${os.EOL}print("baz")${os.EOL}#%%${os.EOL}print("bar")`, 'Text not inserted'); + }, () => { return ioc; }); }); diff --git a/src/test/datascience/mockDocumentManager.ts b/src/test/datascience/mockDocumentManager.ts index 09190d7ff97b..dbc07c12e02b 100644 --- a/src/test/datascience/mockDocumentManager.ts +++ b/src/test/datascience/mockDocumentManager.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 'use strict'; -import * as TypeMoq from 'typemoq'; import { DecorationRenderOptions, Event, @@ -22,18 +21,9 @@ import { import { IDocumentManager } from '../../client/common/application/types'; import { MockDocument } from './mockDocument'; - +import { MockEditor } from './mockTextEditor'; // tslint:disable:no-any no-http-string no-multiline-string max-func-body-length -function createTypeMoq(tag: string): TypeMoq.IMock { - // Use typemoqs for those things that are resolved as promises. mockito doesn't allow nesting of mocks. ES6 Proxy class - // is the problem. We still need to make it thenable though. See this issue: https://github.com/florinn/typemoq/issues/67 - const result = TypeMoq.Mock.ofType(); - (result as any).tag = tag; - result.setup((x: any) => x.then).returns(() => undefined); - return result; -} - export class MockDocumentManager implements IDocumentManager { public textDocuments: TextDocument[] = []; public activeTextEditor: TextEditor | undefined; @@ -77,10 +67,9 @@ export class MockDocumentManager implements IDocumentManager { public showTextDocument(_document: TextDocument, _column?: ViewColumn, _preserveFocus?: boolean): Thenable; public showTextDocument(_document: TextDocument | Uri, _options?: TextDocumentShowOptions): Thenable; public showTextDocument(_document: any, _column?: any, _preserveFocus?: any): Thenable { - const mockEditor = createTypeMoq('TextEditor'); - mockEditor.setup(e => e.document).returns(() => this.lastDocument); - this.activeTextEditor = mockEditor.object; - return Promise.resolve(mockEditor.object); + const mockEditor = new MockEditor(this, this.lastDocument as MockDocument); + this.activeTextEditor = mockEditor; + return Promise.resolve(mockEditor); } public openTextDocument(_fileName: string | Uri): Thenable; public openTextDocument(_options?: { language?: string; content?: string }): Thenable; @@ -107,7 +96,7 @@ export class MockDocumentManager implements IDocumentManager { rangeOffset: startOffset, rangeLength: endOffset - startOffset, text: c.newText - } + }; }); const ev: TextDocumentChangeEvent = { document: doc, diff --git a/src/test/datascience/mockTextEditor.ts b/src/test/datascience/mockTextEditor.ts new file mode 100644 index 000000000000..0a66e0598c6c --- /dev/null +++ b/src/test/datascience/mockTextEditor.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { + DecorationOptions, + EndOfLine, + Position, + Range, + Selection, + SnippetString, + TextDocument, + TextEditor, + TextEditorDecorationType, + TextEditorEdit, + TextEditorOptions, + TextEditorRevealType, + ViewColumn +} from 'vscode'; + +import { noop } from '../../client/common/utils/misc'; +import { MockDocument } from './mockDocument'; +import { MockDocumentManager } from './mockDocumentManager'; + +class MockEditorEdit implements TextEditorEdit { + + constructor(private _documentManager: MockDocumentManager, private _document: MockDocument) { + } + + public replace(location: Selection | Range | Position, value: string): void { + this._documentManager.changeDocument(this._document.fileName, [{ + range: location as Range, + newText: value + }]); + } + + public insert(location: Position, value: string): void { + this._documentManager.changeDocument(this._document.fileName, [{ + range: new Range(location, location), + newText: value + }]); + } + public delete(_location: Selection | Range): void { + throw new Error('Method not implemented.'); + } + public setEndOfLine(_endOfLine: EndOfLine): void { + throw new Error('Method not implemented.'); + } +} + +export class MockEditor implements TextEditor { + public selection: Selection; + public selections: Selection[] = []; + private _revealCallback: () => void; + + constructor(private _documentManager: MockDocumentManager, private _document: MockDocument) { + this.selection = new Selection(0, 0, 0, 0); + this._revealCallback = noop; + } + + public get document(): TextDocument { + return this._document; + } + public get visibleRanges(): Range[] { + return []; + } + public get options(): TextEditorOptions { + return { + }; + } + public get viewColumn(): ViewColumn | undefined { + return undefined; + } + public edit(callback: (editBuilder: TextEditorEdit) => void, _options?: { undoStopBefore: boolean; undoStopAfter: boolean } | undefined): Thenable { + return new Promise(r => { + const editor = new MockEditorEdit(this._documentManager, this._document); + callback(editor); + r(true); + }); + } + public insertSnippet(_snippet: SnippetString, _location?: Range | Position | Range[] | Position[] | undefined, _options?: { undoStopBefore: boolean; undoStopAfter: boolean } | undefined): Thenable { + throw new Error('Method not implemented.'); + } + public setDecorations(_decorationType: TextEditorDecorationType, _rangesOrOptions: Range[] | DecorationOptions[]): void { + throw new Error('Method not implemented.'); + } + public revealRange(_range: Range, _revealType?: TextEditorRevealType | undefined): void { + this._revealCallback(); + } + public show(_column?: ViewColumn | undefined): void { + throw new Error('Method not implemented.'); + } + public hide(): void { + throw new Error('Method not implemented.'); + } + + public setRevealCallback(callback: () => void) { + this._revealCallback = callback; + } +}