diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 1ca7809193..c3b2fa83aa 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -34,6 +34,7 @@ expressly granted herein, whether by implication, estoppel or otherwise. jquery-ui: https://github.com/jquery/jquery-ui jquery.event.drag: https://github.com/devongovett/jquery.event.drag jschardet: https://github.com/aadsm/jschardet + JupyterLab: https://github.com/jupyterlab/jupyterlab make-error: https://github.com/JsCommunity/make-error minimist: https://github.com/substack/minimist moment: https://github.com/moment/moment @@ -1166,6 +1167,43 @@ That's all there is to it! ========================================= END OF jschardet NOTICES AND INFORMATION +%% JupyterLab NOTICES AND INFORMATION BEGIN HERE +Copyright (c) 2015 Project Jupyter Contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Semver File License +=================== + +The semver.py file is from https://github.com/podhmo/python-semver +which is licensed under the "MIT" license. See the semver.py file for details. + +END OF JupyterLab NOTICES AND INFORMATION + %% make-error NOTICES AND INFORMATION BEGIN HERE ========================================= ISC © Julien Fontanet diff --git a/build/gulpfile.vscode.js b/build/gulpfile.vscode.js index 39199be6a7..151759ccee 100644 --- a/build/gulpfile.vscode.js +++ b/build/gulpfile.vscode.js @@ -129,6 +129,7 @@ const vscodeResources = [ 'out-build/sql/parts/jobManagement/common/media/*.svg', 'out-build/sql/media/objectTypes/*.svg', 'out-build/sql/media/icons/*.svg', + 'out-build/sql/parts/notebook/media/**/*.svg', '!**/test/**' ]; diff --git a/build/yarn.lock b/build/yarn.lock index 7e5ce01e6b..c92047d496 100644 --- a/build/yarn.lock +++ b/build/yarn.lock @@ -46,6 +46,7 @@ "@types/minimatch@^3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" + integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== "@types/node@*": version "8.0.51" diff --git a/extensions/azurecore/yarn.lock b/extensions/azurecore/yarn.lock index b8786938f0..5540b46559 100644 --- a/extensions/azurecore/yarn.lock +++ b/extensions/azurecore/yarn.lock @@ -10,6 +10,7 @@ "@types/node@^8.0.24": version "8.10.36" resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.36.tgz#eac05d576fbcd0b4ea3c912dc58c20475c08d9e4" + integrity sha512-SL6KhfM7PTqiFmbCW3eVNwVBZ+88Mrzbuvn9olPsfv43mbiWaFY+nRcz/TGGku0/lc2FepdMbImdMY1JrQ+zbw== "@types/node@^8.0.47": version "8.10.30" @@ -63,6 +64,7 @@ async@2.6.0: async@>=0.6.0: version "2.6.1" resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" + integrity sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ== dependencies: lodash "^4.17.10" @@ -152,6 +154,7 @@ combined-stream@1.0.6: combined-stream@~1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" + integrity sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w== dependencies: delayed-stream "~1.0.0" @@ -168,6 +171,7 @@ concat-map@0.0.1: core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= dashdash@^1.12.0: version "1.14.1" @@ -221,10 +225,12 @@ ecdsa-sig-formatter@1.0.10: escape-string-regexp@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== extsprintf@1.3.0: version "1.3.0" @@ -249,6 +255,7 @@ fast-json-stable-stringify@^2.0.0: forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= form-data@~2.3.2: version "2.3.2" @@ -331,6 +338,7 @@ inflight@^1.0.4: inherits@2: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= is-buffer@^1.1.6: version "1.1.6" @@ -350,6 +358,7 @@ is-typedarray@~1.0.0: isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= jsbn@~0.1.0: version "0.1.1" @@ -369,6 +378,7 @@ json-schema@0.2.3: json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= jsprim@^1.2.2: version "1.4.1" @@ -410,6 +420,7 @@ mime-db@~1.36.0: mime-types@^2.1.12, mime-types@~2.1.19: version "2.1.20" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.20.tgz#930cb719d571e903738520f8470911548ca2cc19" + integrity sha512-HrkrPaP9vGuWbLK1B1FfgAkbqNjIuy4eHlIYnFi7kamZyLLrGlo2mpcx0bBmNpKqBtYtAfGbodDddIgddSJC2A== dependencies: mime-db "~1.36.0" @@ -638,6 +649,7 @@ through@^2.3.8: tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" + integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== dependencies: psl "^1.1.24" punycode "^1.4.1" diff --git a/extensions/import/yarn.lock b/extensions/import/yarn.lock index 3dc0f8a91b..b1e352edce 100644 --- a/extensions/import/yarn.lock +++ b/extensions/import/yarn.lock @@ -12,6 +12,7 @@ agent-base@4, agent-base@^4.1.0: applicationinsights@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/applicationinsights/-/applicationinsights-1.0.1.tgz#53446b830fe8d5d619eee2a278b31d3d25030927" + integrity sha1-U0Rrgw/o1dYZ7uKieLMdPSUDCSc= dependencies: diagnostic-channel "0.2.0" diagnostic-channel-publishers "0.2.1" @@ -143,10 +144,12 @@ decompress@^4.2.0: diagnostic-channel-publishers@0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/diagnostic-channel-publishers/-/diagnostic-channel-publishers-0.2.1.tgz#8e2d607a8b6d79fe880b548bc58cc6beb288c4f3" + integrity sha1-ji1geottef6IC1SLxYzGvrKIxPM= diagnostic-channel@0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/diagnostic-channel/-/diagnostic-channel-0.2.0.tgz#cc99af9612c23fb1fff13612c72f2cbfaa8d5a17" + integrity sha1-zJmvlhLCP7H/8TYSxy8sv6qNWhc= dependencies: semver "^5.3.0" @@ -366,6 +369,7 @@ seek-bzip@^1.0.5: semver@^5.3.0: version "5.6.0" resolved "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" + integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg== "service-downloader@github:anthonydresser/service-downloader#0.1.5": version "0.1.5" @@ -438,6 +442,7 @@ util-deprecate@~1.0.1: vscode-extension-telemetry@0.0.18: version "0.0.18" resolved "https://registry.npmjs.org/vscode-extension-telemetry/-/vscode-extension-telemetry-0.0.18.tgz#602ba20d8c71453aa34533a291e7638f6e5c0327" + integrity sha512-Vw3Sr+dZwl+c6PlsUwrTtCOJkgrmvS3OUVDQGcmpXWAgq9xGq6as0K4pUx+aGqTjzLAESmWSrs6HlJm6J6Khcg== dependencies: applicationinsights "1.0.1" @@ -492,3 +497,4 @@ yauzl@^2.4.2: zone.js@0.7.6: version "0.7.6" resolved "https://registry.npmjs.org/zone.js/-/zone.js-0.7.6.tgz#fbbc39d3e0261d0986f1ba06306eb3aeb0d22009" + integrity sha1-+7w50+AmHQmG8boGMG6zrrDSIAk= diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index b49638d970..4dfaba0cf8 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -23,7 +23,8 @@ "onCommand:markdown.showLockedPreviewToSide", "onCommand:markdown.showSource", "onCommand:markdown.showPreviewSecuritySelector", - "onWebviewPanel:markdown.preview" + "onWebviewPanel:markdown.preview", + "onCommand:notebook.showPreview" ], "contributes": { "commands": [ @@ -77,6 +78,11 @@ "command": "markdown.preview.toggleLock", "title": "%markdown.preview.toggleLock.title%", "category": "Markdown" + }, + { + "command": "notebook.showPreview", + "title": "notebook.showPreview", + "category": "Notebook" } ], "menus": { @@ -154,6 +160,10 @@ { "command": "markdown.preview.toggleLock", "when": "markdownPreviewFocus" + }, + { + "command": "notebook.showPreview", + "when": "false" } ] }, diff --git a/extensions/markdown-language-features/src/commandManager.ts b/extensions/markdown-language-features/src/commandManager.ts index 174f30cd44..966049df92 100644 --- a/extensions/markdown-language-features/src/commandManager.ts +++ b/extensions/markdown-language-features/src/commandManager.ts @@ -8,7 +8,8 @@ import * as vscode from 'vscode'; export interface Command { readonly id: string; - execute(...args: any[]): void; + // {{SQL CARBON EDIT}} + execute(...args: any[]): any; } export class CommandManager { @@ -26,7 +27,8 @@ export class CommandManager { return command; } - private registerCommand(id: string, impl: (...args: any[]) => void, thisArg?: any) { + // {{SQL CARBON EDIT}} + private registerCommand(id: string, impl: (...args: any[]) => any, thisArg?: any) { if (this.commands.has(id)) { return; } diff --git a/extensions/markdown-language-features/src/commands/index.ts b/extensions/markdown-language-features/src/commands/index.ts index 087f93987f..09c37d1bfd 100644 --- a/extensions/markdown-language-features/src/commands/index.ts +++ b/extensions/markdown-language-features/src/commands/index.ts @@ -11,3 +11,5 @@ export { RefreshPreviewCommand } from './refreshPreview'; export { ShowPreviewSecuritySelectorCommand } from './showPreviewSecuritySelector'; export { MoveCursorToPositionCommand } from './moveCursorToPosition'; export { ToggleLockCommand } from './toggleLock'; +// {{SQL CARBON EDIT}} +export { ShowNotebookPreview } from './showNotebookPreview'; diff --git a/extensions/markdown-language-features/src/commands/showNotebookPreview.ts b/extensions/markdown-language-features/src/commands/showNotebookPreview.ts new file mode 100644 index 0000000000..ddab003f02 --- /dev/null +++ b/extensions/markdown-language-features/src/commands/showNotebookPreview.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + +import { Command } from '../commandManager'; +import { MarkdownEngine } from '../markdownEngine'; + +export class ShowNotebookPreview implements Command { + public readonly id = 'notebook.showPreview'; + + public constructor( + private readonly engine: MarkdownEngine + ) { } + + public async execute(document: vscode.Uri, textContent: string): Promise { + return this.engine.renderText(document, textContent); + } +} \ No newline at end of file diff --git a/extensions/markdown-language-features/src/extension.ts b/extensions/markdown-language-features/src/extension.ts index b73ed0f6c1..99335209bf 100644 --- a/extensions/markdown-language-features/src/extension.ts +++ b/extensions/markdown-language-features/src/extension.ts @@ -59,6 +59,8 @@ export function activate(context: vscode.ExtensionContext) { commandManager.register(new commands.OnPreviewStyleLoadErrorCommand()); commandManager.register(new commands.OpenDocumentLinkCommand(engine)); commandManager.register(new commands.ToggleLockCommand(previewManager)); + // {{SQL CARBON EDIT}} + commandManager.register(new commands.ShowNotebookPreview(engine)); context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(() => { logger.updateConfiguration(); diff --git a/extensions/markdown-language-features/src/markdownEngine.ts b/extensions/markdown-language-features/src/markdownEngine.ts index 2571c04875..618e7edc0f 100644 --- a/extensions/markdown-language-features/src/markdownEngine.ts +++ b/extensions/markdown-language-features/src/markdownEngine.ts @@ -86,6 +86,12 @@ export class MarkdownEngine { return { text, offset }; } + // {{SQL CARBON EDIT}} + public async renderText(document: vscode.Uri, text: string): Promise { + const engine = await this.getEngine(document); + return engine.render(text); + } + public async render(document: vscode.Uri, stripFrontmatter: boolean, text: string): Promise { let offset = 0; if (stripFrontmatter) { diff --git a/package.json b/package.json index 2606015254..e2d9c8e81f 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,10 @@ "@angular/router": "~4.1.3", "@angular/upgrade": "~4.1.3", "@types/chart.js": "^2.7.31", + "@types/htmlparser2": "^3.7.31", "angular2-grid": "2.0.6", "angular2-slickgrid": "github:Microsoft/angular2-slickgrid#1.4.6", + "ansi_up": "^3.0.0", "applicationinsights": "0.18.0", "chart.js": "^2.6.0", "fast-plist": "0.1.2", @@ -64,6 +66,7 @@ "pretty-data": "^0.40.0", "reflect-metadata": "^0.1.8", "rxjs": "5.4.0", + "sanitize-html": "^1.19.1", "semver": "^5.5.0", "slickgrid": "github:anthonydresser/SlickGrid#2.3.28", "spdlog": "0.7.1", @@ -85,6 +88,7 @@ "@types/keytar": "4.0.1", "@types/minimist": "1.2.0", "@types/mocha": "2.2.39", + "@types/sanitize-html": "^1.18.2", "@types/semver": "5.3.30", "@types/sinon": "1.16.34", "@types/winreg": "^1.2.30", diff --git a/src/sql/parts/common/customInputConverter.ts b/src/sql/parts/common/customInputConverter.ts index 90301ea6d2..70b74c766b 100644 --- a/src/sql/parts/common/customInputConverter.ts +++ b/src/sql/parts/common/customInputConverter.ts @@ -3,15 +3,22 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; + +import { Registry } from 'vs/platform/registry/common/platform'; import { EditorInput, IEditorInput } from 'vs/workbench/common/editor'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { UntitledEditorInput } from 'vs/workbench/common/editor/untitledEditorInput'; import { FileEditorInput } from 'vs/workbench/parts/files/common/editors/fileEditorInput'; +import URI from 'vs/base/common/uri'; + import { QueryResultsInput } from 'sql/parts/query/common/queryResultsInput'; import { QueryInput } from 'sql/parts/query/common/queryInput'; -import URI from 'vs/base/common/uri'; import { IQueryEditorOptions } from 'sql/parts/query/common/queryEditorService'; import { QueryPlanInput } from 'sql/parts/queryPlan/queryPlanInput'; +import { NotebookInput, NotebookInputModel, NotebookInputValidator } from 'sql/parts/notebook/notebookInput'; +import { Extensions, INotebookProviderRegistry } from 'sql/services/notebook/notebookRegistry'; +import { DEFAULT_NOTEBOOK_PROVIDER } from 'sql/services/notebook/notebookService'; const fs = require('fs'); @@ -28,7 +35,7 @@ export const sqlModeId = 'sql'; * to that type. * @param input The input to check for conversion * @param options Editor options for controlling the conversion - * @param instantiationService The instatianation service to use to create the new input types + * @param instantiationService The instantiation service to use to create the new input types */ export function convertEditorInput(input: EditorInput, options: IQueryEditorOptions, instantiationService: IInstantiationService): EditorInput { let denyQueryEditor = options && options.denyQueryEditor; @@ -48,8 +55,25 @@ export function convertEditorInput(input: EditorInput, options: IQueryEditorOpti let queryPlanInput: QueryPlanInput = instantiationService.createInstance(QueryPlanInput, queryPlanXml, 'aaa', undefined); return queryPlanInput; } - } + //Notebook + let notebookValidator = instantiationService.createInstance(NotebookInputValidator); + uri = getNotebookEditorUri(input); + if(uri && notebookValidator.isNotebookEnabled()){ + //TODO: We need to pass in notebook data either through notebook input or notebook service + let fileName: string = 'untitled'; + let providerId: string = DEFAULT_NOTEBOOK_PROVIDER; + if (input) { + fileName = input.getName(); + providerId = getProviderForFileName(fileName); + } + let notebookInputModel = new NotebookInputModel(uri, undefined, false, undefined); + notebookInputModel.providerId = providerId; + //TO DO: Second parameter has to be the content. + let notebookInput: NotebookInput = instantiationService.createInstance(NotebookInput, fileName, notebookInputModel); + return notebookInput; + } + } return input; } @@ -129,6 +153,47 @@ function getQueryPlanEditorUri(input: EditorInput): URI { return undefined; } + +/** + * If input is a supported notebook editor file (.ipynb), return it's URI. Otherwise return undefined. + * @param input The EditorInput to get the URI of. + */ +function getNotebookEditorUri(input: EditorInput): URI { + if (!input || !input.getName()) { + return undefined; + } + + + + // If this editor is not already of type notebook input + if (!(input instanceof NotebookInput)) { + let uri: URI = getSupportedInputResource(input); + if (uri) { + if (hasFileExtension(getNotebookFileExtensions(), input, false)) { + return uri; + } + } + } + + return undefined; +} + +function getNotebookFileExtensions() { + let notebookRegistry = Registry.as(Extensions.NotebookProviderContribution); + return notebookRegistry.getSupportedFileExtensions(); +} + +function getProviderForFileName(fileName: string) { + let fileExt = path.extname(fileName); + if (fileExt && fileExt.startsWith('.')) { + fileExt = fileExt.slice(1,fileExt.length); + let notebookRegistry = Registry.as(Extensions.NotebookProviderContribution); + return notebookRegistry.getProviderForFileType(fileExt); + } + return DEFAULT_NOTEBOOK_PROVIDER; +} + + /** * Checks whether the given EditorInput is set to either undefined or sql mode * @param input The EditorInput to check the mode of diff --git a/src/sql/parts/modelComponents/loadingComponent.component.ts b/src/sql/parts/modelComponents/loadingComponent.component.ts index ab5687dffe..660cf0f7ba 100644 --- a/src/sql/parts/modelComponents/loadingComponent.component.ts +++ b/src/sql/parts/modelComponents/loadingComponent.component.ts @@ -5,7 +5,7 @@ import 'vs/css!./loadingComponent'; import { - Component, Input, Inject, ChangeDetectorRef, forwardRef, OnDestroy, AfterViewInit, ViewChild, ElementRef + Component, Input, Inject, ChangeDetectorRef, forwardRef, OnDestroy, AfterViewInit, ElementRef } from '@angular/core'; import * as sqlops from 'sqlops'; @@ -31,9 +31,6 @@ export default class LoadingComponent extends ComponentBase implements IComponen @Input() descriptor: IComponentDescriptor; @Input() modelStore: IModelStore; - @ViewChild('spinnerElement', { read: ElementRef }) private _spinnerElement: ElementRef; - @ViewChild('childElement', { read: ElementRef }) private _childElement: ElementRef; - constructor( @Inject(forwardRef(() => ChangeDetectorRef)) changeRef: ChangeDetectorRef, @Inject(forwardRef(() => ElementRef)) el: ElementRef) { diff --git a/src/sql/parts/modelComponents/loadingSpinner.component.ts b/src/sql/parts/modelComponents/loadingSpinner.component.ts new file mode 100644 index 0000000000..2fcd417a13 --- /dev/null +++ b/src/sql/parts/modelComponents/loadingSpinner.component.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./loadingComponent'; +import { + Component, Input, Inject, ChangeDetectorRef, forwardRef, ElementRef +} from '@angular/core'; + +import * as nls from 'vs/nls'; + +@Component({ + selector: 'loading-spinner', + template: ` +
+
+
+ ` +}) +export default class LoadingSpinner { + private readonly _loadingTitle = nls.localize('loadingMessage', 'Loading'); + + @Input() loading: boolean; + + constructor( + @Inject(forwardRef(() => ChangeDetectorRef)) changeRef: ChangeDetectorRef, + @Inject(forwardRef(() => ElementRef)) el: ElementRef) { + } +} diff --git a/src/sql/parts/notebook/cellViews/code.component.html b/src/sql/parts/notebook/cellViews/code.component.html new file mode 100644 index 0000000000..f7b12f94ce --- /dev/null +++ b/src/sql/parts/notebook/cellViews/code.component.html @@ -0,0 +1,12 @@ + +
+
+
+
+
+
diff --git a/src/sql/parts/notebook/cellViews/code.component.ts b/src/sql/parts/notebook/cellViews/code.component.ts new file mode 100644 index 0000000000..25ecc4869d --- /dev/null +++ b/src/sql/parts/notebook/cellViews/code.component.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the Source EULA. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +import 'vs/css!./code'; + +import { OnInit, Component, Input, Inject, forwardRef, ElementRef, ChangeDetectorRef, OnDestroy, ViewChild, Output, EventEmitter } from '@angular/core'; + +import { CommonServiceInterface } from 'sql/services/common/commonServiceInterface.service'; +import { AngularDisposable } from 'sql/base/common/lifecycle'; +import { QueryTextEditor } from 'sql/parts/modelComponents/queryTextEditor'; + +import { IColorTheme, IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; +import * as themeColors from 'vs/workbench/common/theme'; +import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; +import { SimpleProgressService } from 'vs/editor/standalone/browser/simpleServices'; +import { IProgressService } from 'vs/platform/progress/common/progress'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ITextModel } from 'vs/editor/common/model'; +import { UntitledEditorInput } from 'vs/workbench/common/editor/untitledEditorInput'; +import URI from 'vs/base/common/uri'; +import { Schemas } from 'vs/base/common/network'; +import * as DOM from 'vs/base/browser/dom'; +import { IModeService } from 'vs/editor/common/services/modeService'; +import { IModelService } from 'vs/editor/common/services/modelService'; +import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { ICellModel } from 'sql/parts/notebook/models/modelInterfaces'; +import { Taskbar } from 'sql/base/browser/ui/taskbar/taskbar'; +import { RunCellAction } from 'sql/parts/notebook/cellViews/codeActions'; + +export const CODE_SELECTOR: string = 'code-component'; + +@Component({ + selector: CODE_SELECTOR, + templateUrl: decodeURI(require.toUrl('./code.component.html')) +}) +export class CodeComponent extends AngularDisposable implements OnInit { + @ViewChild('toolbar', { read: ElementRef }) private toolbarElement: ElementRef; + @ViewChild('editor', { read: ElementRef }) private codeElement: ElementRef; + @Input() cellModel: ICellModel; + @Output() public onContentChanged = new EventEmitter(); + + protected _actionBar: Taskbar; + private readonly _minimumHeight = 30; + private _editor: QueryTextEditor; + private _editorInput: UntitledEditorInput; + private _editorModel: ITextModel; + private _uri: string; + + constructor( + @Inject(forwardRef(() => CommonServiceInterface)) private _bootstrapService: CommonServiceInterface, + @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, + @Inject(IWorkbenchThemeService) private themeService: IWorkbenchThemeService, + @Inject(IInstantiationService) private _instantiationService: IInstantiationService, + @Inject(IModelService) private _modelService: IModelService, + @Inject(IModeService) private _modeService: IModeService, + @Inject(IContextMenuService) private contextMenuService: IContextMenuService, + @Inject(IContextViewService) private contextViewService: IContextViewService + ) { + super(); + } + + ngOnInit() { + this._register(this.themeService.onDidColorThemeChange(this.updateTheme, this)); + this.updateTheme(this.themeService.getColorTheme()); + this.initActionBar(); + } + + ngOnChanges() { + this.updateLanguageMode(); + this.updateModel(); + } + + ngAfterContentInit(): void { + this.createEditor(); + this._register(DOM.addDisposableListener(window, DOM.EventType.RESIZE, e => { + this.layout(); + })); + } + + private createEditor(): void { + let instantiationService = this._instantiationService.createChild(new ServiceCollection([IProgressService, new SimpleProgressService()])); + this._editor = instantiationService.createInstance(QueryTextEditor); + this._editor.create(this.codeElement.nativeElement); + this._editor.setVisible(true); + this._editor.setMinimumHeight(this._minimumHeight); + let uri = this.createUri(); + this._editorInput = instantiationService.createInstance(UntitledEditorInput, uri, false, this.cellModel.language, '', ''); + this._editor.setInput(this._editorInput, undefined); + this._editorInput.resolve().then(model => { + this._editorModel = model.textEditorModel; + this._modelService.updateModel(this._editorModel, this.cellModel.source); + }); + + this._register(this._editor); + this._register(this._editorInput); + this._register(this._editorModel.onDidChangeContent(e => { + this._editor.setHeightToScrollHeight(); + this.cellModel.source = this._editorModel.getValue(); + this.onContentChanged.emit(); + })); + this.layout(); + } + + public layout(): void { + this._editor.layout(new DOM.Dimension( + DOM.getContentWidth(this.codeElement.nativeElement), + DOM.getContentHeight(this.codeElement.nativeElement))); + this._editor.setHeightToScrollHeight(); + } + + protected initActionBar() { + + let runCellAction = this._instantiationService.createInstance(RunCellAction); + + let taskbar = this.toolbarElement.nativeElement; + this._actionBar = new Taskbar(taskbar, this.contextMenuService); + this._actionBar.context = this; + this._actionBar.setContent([ + { action: runCellAction } + ]); + } + + private createUri(): URI { + let uri = URI.from({ scheme: Schemas.untitled, path: `notebook-editor-${this.cellModel.id}` }); + // Use this to set the internal (immutable) and public (shared with extension) uri properties + this.cellModel.cellUri = uri; + return uri; + } + + /// Editor Functions + private updateModel() { + if (this._editorModel) { + this._modelService.updateModel(this._editorModel, this.cellModel.source); + } + } + + private updateLanguageMode() { + if (this._editorModel && this._editor) { + this._modeService.getOrCreateMode(this.cellModel.language).then((modeValue) => { + this._modelService.setMode(this._editorModel, modeValue); + }); + } + } + + private updateTheme(theme: IColorTheme): void { + let toolbarEl = this.toolbarElement.nativeElement; + toolbarEl.style.borderRightColor = theme.getColor(themeColors.SIDE_BAR_BACKGROUND, true).toString(); + } +} diff --git a/src/sql/parts/notebook/cellViews/code.css b/src/sql/parts/notebook/cellViews/code.css new file mode 100644 index 0000000000..8785f88552 --- /dev/null +++ b/src/sql/parts/notebook/cellViews/code.css @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +code-component { + height: 100%; + width: 100%; + display: block; +} + +code-component .toolbar { + border-right-width: 1px; + border-right-style: solid; +} + +code-component .toolbarIconRun { + height: 20px; + background-image: url('../media/light/execute_cell.svg'); + padding-bottom: 10px; +} + +.vs-dark code-component .toolbarIconRun, +.hc-black code-component .toolbarIconRun { + background-image: url('../media/dark/execute_cell_inverse.svg'); +} + +code-component .carbon-taskbar .icon { + background-size: 20px; + width: 40px; +} + +code-component .carbon-taskbar.monaco-toolbar .monaco-action-bar.animated .actions-container +{ + padding-left: 10px +} \ No newline at end of file diff --git a/src/sql/parts/notebook/cellViews/codeActions.ts b/src/sql/parts/notebook/cellViews/codeActions.ts new file mode 100644 index 0000000000..292bd506e8 --- /dev/null +++ b/src/sql/parts/notebook/cellViews/codeActions.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action } from 'vs/base/common/actions'; +import { TPromise } from 'vs/base/common/winjs.base'; +import { localize } from 'vs/nls'; + +export class RunCellAction extends Action { + public static ID = 'jobaction.notebookRunCell'; + public static LABEL = 'Run cell'; + + constructor( + ) { + super(RunCellAction.ID, '', 'toolbarIconRun'); + this.tooltip = localize('runCell','Run cell'); + } + + public run(context: any): TPromise { + return new TPromise((resolve, reject) => { + try { + resolve(true); + } catch (e) { + reject(e); + } + }); + } +} \ No newline at end of file diff --git a/src/sql/parts/notebook/cellViews/codeCell.component.html b/src/sql/parts/notebook/cellViews/codeCell.component.html new file mode 100644 index 0000000000..1351e5ef2f --- /dev/null +++ b/src/sql/parts/notebook/cellViews/codeCell.component.html @@ -0,0 +1,15 @@ + +
+
+ +
+
+ + +
+
\ No newline at end of file diff --git a/src/sql/parts/notebook/cellViews/codeCell.component.ts b/src/sql/parts/notebook/cellViews/codeCell.component.ts new file mode 100644 index 0000000000..c15f3cc42d --- /dev/null +++ b/src/sql/parts/notebook/cellViews/codeCell.component.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the Source EULA. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +import 'vs/css!./codeCell'; + +import { OnInit, Component, Input, Inject, forwardRef, ElementRef, ChangeDetectorRef, OnDestroy, ViewChild } from '@angular/core'; + +import { CommonServiceInterface } from 'sql/services/common/commonServiceInterface.service'; +import { CellView } from 'sql/parts/notebook/cellViews/interfaces'; + +import { IColorTheme, IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; +import * as themeColors from 'vs/workbench/common/theme'; +import { ICellModel } from 'sql/parts/notebook/models/modelInterfaces'; + + +export const CODE_SELECTOR: string = 'code-cell-component'; + +@Component({ + selector: CODE_SELECTOR, + templateUrl: decodeURI(require.toUrl('./codeCell.component.html')) +}) +export class CodeCellComponent extends CellView implements OnInit { + @Input() cellModel: ICellModel; + constructor( + @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, + @Inject(IWorkbenchThemeService) private themeService: IWorkbenchThemeService + ) { + super(); + } + + ngOnInit() { + this._register(this.themeService.onDidColorThemeChange(this.updateTheme, this)); + this.updateTheme(this.themeService.getColorTheme()); + } + + // Todo: implement layout + public layout() { + + } + + private updateTheme(theme: IColorTheme): void { + } +} diff --git a/src/sql/parts/notebook/cellViews/codeCell.css b/src/sql/parts/notebook/cellViews/codeCell.css new file mode 100644 index 0000000000..8a9a18e0a7 --- /dev/null +++ b/src/sql/parts/notebook/cellViews/codeCell.css @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +code-cell-component { + display: block; +} + +code-cell-component .notebook-output { + border-top-width: 1px; + border-top-style: solid; +} diff --git a/src/sql/parts/notebook/cellViews/interfaces.ts b/src/sql/parts/notebook/cellViews/interfaces.ts new file mode 100644 index 0000000000..1f188b6663 --- /dev/null +++ b/src/sql/parts/notebook/cellViews/interfaces.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { OnDestroy } from '@angular/core'; +import { AngularDisposable } from 'sql/base/common/lifecycle'; + +export abstract class CellView extends AngularDisposable implements OnDestroy { + constructor() { + super(); + } + + public abstract layout(): void; +} + + diff --git a/src/sql/parts/notebook/cellViews/output.component.html b/src/sql/parts/notebook/cellViews/output.component.html new file mode 100644 index 0000000000..f05a51ae65 --- /dev/null +++ b/src/sql/parts/notebook/cellViews/output.component.html @@ -0,0 +1,11 @@ + +
+
+
+
+
diff --git a/src/sql/parts/notebook/cellViews/output.component.ts b/src/sql/parts/notebook/cellViews/output.component.ts new file mode 100644 index 0000000000..b57a58e9d9 --- /dev/null +++ b/src/sql/parts/notebook/cellViews/output.component.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the Source EULA. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +import 'vs/css!./code'; + +import { OnInit, Component, Input, Inject, forwardRef, ElementRef, ChangeDetectorRef, OnDestroy, ViewChild, Output, EventEmitter } from '@angular/core'; +import { AngularDisposable } from 'sql/base/common/lifecycle'; +import { nb } from 'sqlops'; +import { INotebookService } from 'sql/services/notebook/notebookService'; +import { MimeModel } from 'sql/parts/notebook/outputs/common/mimemodel'; +import * as outputProcessor from '../outputs/common/outputProcessor'; +import { RenderMimeRegistry } from 'sql/parts/notebook/outputs/registry'; +import 'vs/css!sql/parts/notebook/outputs/style/index'; + +export const OUTPUT_SELECTOR: string = 'output-component'; + +@Component({ + selector: OUTPUT_SELECTOR, + templateUrl: decodeURI(require.toUrl('./output.component.html')) +}) +export class OutputComponent extends AngularDisposable implements OnInit { + @ViewChild('output', { read: ElementRef }) private outputElement: ElementRef; + @Input() cellOutput: nb.ICellOutput; + @Input() trustedMode: boolean; + private readonly _minimumHeight = 30; + registry: RenderMimeRegistry; + + + constructor( + @Inject(INotebookService) private _notebookService: INotebookService + ) { + super(); + this.registry = _notebookService.getMimeRegistry(); + } + + ngOnInit() { + let node = this.outputElement.nativeElement; + let output = this.cellOutput; + let options = outputProcessor.getBundleOptions({ value: output, trusted: this.trustedMode }); + // TODO handle safe/unsafe mapping + this.createRenderedMimetype(options, node); + } + + public layout(): void { + } + + protected createRenderedMimetype(options: MimeModel.IOptions, node: HTMLElement): void { + let mimeType = this.registry.preferredMimeType( + options.data, + options.trusted ? 'any' : 'ensure' + ); + if (mimeType) { + let output = this.registry.createRenderer(mimeType); + output.node = node; + let model = new MimeModel(options); + output.renderModel(model).catch(error => { + // Manually append error message to output + output.node.innerHTML = `
Javascript Error: ${error.message}
`; + // Remove mime-type-specific CSS classes + output.node.className = 'p-Widget jp-RenderedText'; + output.node.setAttribute( + 'data-mime-type', + 'application/vnd.jupyter.stderr' + ); + }); + //this.setState({ node: node }); + } else { + // TODO Localize + node.innerHTML = + `No ${options.trusted ? '' : '(safe) '}renderer could be ` + + 'found for output. It has the following MIME types: ' + + Object.keys(options.data).join(', '); + //this.setState({ node: node }); + } + } +} diff --git a/src/sql/parts/notebook/cellViews/outputArea.component.html b/src/sql/parts/notebook/cellViews/outputArea.component.html new file mode 100644 index 0000000000..02f6d576b5 --- /dev/null +++ b/src/sql/parts/notebook/cellViews/outputArea.component.html @@ -0,0 +1,12 @@ + +
+
+ + +
+
\ No newline at end of file diff --git a/src/sql/parts/notebook/cellViews/outputArea.component.ts b/src/sql/parts/notebook/cellViews/outputArea.component.ts new file mode 100644 index 0000000000..461de0da21 --- /dev/null +++ b/src/sql/parts/notebook/cellViews/outputArea.component.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the Source EULA. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +import 'vs/css!./code'; +import { OnInit, Component, Input, Inject, forwardRef, ElementRef, ChangeDetectorRef, OnDestroy, ViewChild, Output, EventEmitter } from '@angular/core'; +import { AngularDisposable } from 'sql/base/common/lifecycle'; +import { IModeService } from 'vs/editor/common/services/modeService'; +import { ICellModel } from 'sql/parts/notebook/models/modelInterfaces'; + +export const OUTPUT_AREA_SELECTOR: string = 'output-area-component'; + +@Component({ + selector: OUTPUT_AREA_SELECTOR, + templateUrl: decodeURI(require.toUrl('./outputArea.component.html')) +}) +export class OutputAreaComponent extends AngularDisposable implements OnInit { + @Input() cellModel: ICellModel; + + private readonly _minimumHeight = 30; + + constructor( + @Inject(IModeService) private _modeService: IModeService + ) { + super(); + } + ngOnInit(): void { + + } +} diff --git a/src/sql/parts/notebook/cellViews/textCell.component.html b/src/sql/parts/notebook/cellViews/textCell.component.html new file mode 100644 index 0000000000..e770ff98aa --- /dev/null +++ b/src/sql/parts/notebook/cellViews/textCell.component.html @@ -0,0 +1,13 @@ + +
+
+ +
+
+
+
diff --git a/src/sql/parts/notebook/cellViews/textCell.component.ts b/src/sql/parts/notebook/cellViews/textCell.component.ts new file mode 100644 index 0000000000..f7299f78f7 --- /dev/null +++ b/src/sql/parts/notebook/cellViews/textCell.component.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the Source EULA. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +import 'vs/css!./textCell'; + +import { OnInit, Component, Input, Inject, forwardRef, ElementRef, ChangeDetectorRef, OnDestroy, ViewChild } from '@angular/core'; + +import { CommonServiceInterface } from 'sql/services/common/commonServiceInterface.service'; +import { CellView } from 'sql/parts/notebook/cellViews/interfaces'; + +import { IColorTheme, IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; +import * as themeColors from 'vs/workbench/common/theme'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { ICellModel } from 'sql/parts/notebook/models/modelInterfaces'; +import { ISanitizer, defaultSanitizer } from 'sql/parts/notebook/outputs/sanitizer'; +import { localize } from 'vs/nls'; + +export const TEXT_SELECTOR: string = 'text-cell-component'; + +@Component({ + selector: TEXT_SELECTOR, + templateUrl: decodeURI(require.toUrl('./textCell.component.html')) +}) +export class TextCellComponent extends CellView implements OnInit { + @ViewChild('preview', { read: ElementRef }) private output: ElementRef; + @Input() cellModel: ICellModel; + private _content: string; + private isEditMode: boolean; + private _sanitizer: ISanitizer; + + constructor( + @Inject(forwardRef(() => CommonServiceInterface)) private _bootstrapService: CommonServiceInterface, + @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, + @Inject(IWorkbenchThemeService) private themeService: IWorkbenchThemeService, + @Inject(ICommandService) private _commandService: ICommandService + ) { + super(); + this.isEditMode = false; + } + + ngOnChanges() { + this.updatePreview(); + } + + //Gets sanitizer from ISanitizer interface + private get sanitizer(): ISanitizer { + if (this._sanitizer) { + return this._sanitizer; + } + return this._sanitizer = defaultSanitizer; + } + + /** + * Updates the preview of markdown component with latest changes + * If content is empty and in non-edit mode, default it to 'Double-click to edit' + * Sanitizes the data to be shown in markdown cell + */ + private updatePreview() { + if (this._content !== this.cellModel.source) { + if (!this.cellModel.source && !this.isEditMode) { + (this.output.nativeElement).innerHTML = localize('doubleClickEdit', 'Double-click to edit'); + } else { + this._content = this.sanitizeContent(this.cellModel.source); + // todo: pass in the notebook filename instead of undefined value + this._commandService.executeCommand('notebook.showPreview', undefined, this._content).then((htmlcontent) => { + let outputElement = this.output.nativeElement; + outputElement.innerHTML = htmlcontent; + }); + } + } + } + + //Sanitizes the content based on trusted mode of Cell Model + private sanitizeContent(content: string): string { + if (this.cellModel && !this.cellModel.trustedMode) { + content = this.sanitizer.sanitize(content); + } + return content; + } + + ngOnInit() { + this.updatePreview(); + this._register(this.themeService.onDidColorThemeChange(this.updateTheme, this)); + this.updateTheme(this.themeService.getColorTheme()); + } + + // Todo: implement layout + public layout() { + } + + private updateTheme(theme: IColorTheme): void { + let outputElement = this.output.nativeElement; + outputElement.style.borderTopColor = theme.getColor(themeColors.SIDE_BAR_BACKGROUND, true).toString(); + } + + public handleContentChanged(): void { + this.updatePreview(); + } + + public toggleEditMode(): void { + this.isEditMode = !this.isEditMode; + this.updatePreview(); + this._changeRef.detectChanges(); + } +} diff --git a/src/sql/parts/notebook/cellViews/textCell.css b/src/sql/parts/notebook/cellViews/textCell.css new file mode 100644 index 0000000000..df8a1dca3e --- /dev/null +++ b/src/sql/parts/notebook/cellViews/textCell.css @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +text-cell-component { + display: block; +} + +text-cell-component .notebook-preview { + border-top-width: 1px; + border-top-style: solid; + user-select: initial; +} \ No newline at end of file diff --git a/src/sql/parts/notebook/media/dark/execute_cell_inverse.svg b/src/sql/parts/notebook/media/dark/execute_cell_inverse.svg new file mode 100644 index 0000000000..ddddf779bc --- /dev/null +++ b/src/sql/parts/notebook/media/dark/execute_cell_inverse.svg @@ -0,0 +1 @@ +execute_cell_inverse \ No newline at end of file diff --git a/src/sql/parts/notebook/media/light/add.svg b/src/sql/parts/notebook/media/light/add.svg new file mode 100644 index 0000000000..da9af07dca --- /dev/null +++ b/src/sql/parts/notebook/media/light/add.svg @@ -0,0 +1 @@ +add_16x16 \ No newline at end of file diff --git a/src/sql/parts/notebook/media/light/add_code_cell.svg b/src/sql/parts/notebook/media/light/add_code_cell.svg new file mode 100644 index 0000000000..375721a2d1 --- /dev/null +++ b/src/sql/parts/notebook/media/light/add_code_cell.svg @@ -0,0 +1 @@ + code_cell \ No newline at end of file diff --git a/src/sql/parts/notebook/media/light/add_text_cell.svg b/src/sql/parts/notebook/media/light/add_text_cell.svg new file mode 100644 index 0000000000..f6c97e09f4 --- /dev/null +++ b/src/sql/parts/notebook/media/light/add_text_cell.svg @@ -0,0 +1 @@ +text_cell \ No newline at end of file diff --git a/src/sql/parts/notebook/media/light/cell_output.svg b/src/sql/parts/notebook/media/light/cell_output.svg new file mode 100644 index 0000000000..706b5a4f05 --- /dev/null +++ b/src/sql/parts/notebook/media/light/cell_output.svg @@ -0,0 +1 @@ +cell_output \ No newline at end of file diff --git a/src/sql/parts/notebook/media/light/execute_cell.svg b/src/sql/parts/notebook/media/light/execute_cell.svg new file mode 100644 index 0000000000..2a8f889c1e --- /dev/null +++ b/src/sql/parts/notebook/media/light/execute_cell.svg @@ -0,0 +1 @@ +execute_cell \ No newline at end of file diff --git a/src/sql/parts/notebook/models/cell.ts b/src/sql/parts/notebook/models/cell.ts new file mode 100644 index 0000000000..0d277f9527 --- /dev/null +++ b/src/sql/parts/notebook/models/cell.ts @@ -0,0 +1,321 @@ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { Event, Emitter } from 'vs/base/common/event'; +import URI from 'vs/base/common/uri'; + +import { nb } from 'sqlops'; +import { ICellModelOptions, IModelFactory } from './modelInterfaces'; +import * as notebookUtils from '../notebookUtils'; +import { CellTypes, CellType, NotebookChangeType } from 'sql/parts/notebook/models/contracts'; +import { ICellModel } from 'sql/parts/notebook/models/modelInterfaces'; + +let modelId = 0; + + +export class CellModel implements ICellModel { + private static LanguageMapping: Map; + + private _cellType: nb.CellType; + private _source: string; + private _language: string; + private _future: nb.IFuture; + private _outputs: nb.ICellOutput[] = []; + private _isEditMode: boolean; + private _onOutputsChanged = new Emitter>(); + private _onCellModeChanged = new Emitter(); + public id: string; + private _isTrusted: boolean; + private _active: boolean; + private _cellUri: URI; + + constructor(private factory: IModelFactory, cellData?: nb.ICell, private _options?: ICellModelOptions) { + this.id = `${modelId++}`; + CellModel.CreateLanguageMappings(); + // Do nothing for now + if (cellData) { + this.fromJSON(cellData); + } else { + this._cellType = CellTypes.Code; + this._source = ''; + } + this._isEditMode = this._cellType !== CellTypes.Markdown; + this.setDefaultLanguage(); + if (_options && _options.isTrusted) { + this._isTrusted = true; + } else { + this._isTrusted = false; + } + } + + public equals(other: ICellModel) { + return other && other.id === this.id; + } + + public get onOutputsChanged(): Event> { + return this._onOutputsChanged.event; + } + + public get onCellModeChanged(): Event { + return this._onCellModeChanged.event; + } + + public get isEditMode(): boolean { + return this._isEditMode; + } + + public get future(): nb.IFuture { + return this._future; + } + + public set isEditMode(isEditMode: boolean) { + this._isEditMode = isEditMode; + this._onCellModeChanged.fire(this._isEditMode); + // Note: this does not require a notebook update as it does not change overall state + } + + public get trustedMode(): boolean { + return this._isTrusted; + } + + public set trustedMode(isTrusted: boolean) { + if (this._isTrusted !== isTrusted) { + this._isTrusted = isTrusted; + this._onOutputsChanged.fire(this._outputs); + } + } + + public get active(): boolean { + return this._active; + } + + public set active(value: boolean) { + this._active = value; + } + + public get cellUri(): URI { + return this._cellUri; + } + + public set cellUri(value: URI) { + this._cellUri = value; + } + + public get options(): ICellModelOptions { + return this._options; + } + + public get cellType(): CellType { + return this._cellType; + } + + public get source(): string { + return this._source; + } + + public set source(newSource: string) { + if (this._source !== newSource) { + this._source = newSource; + this.sendChangeToNotebook(NotebookChangeType.CellSourceUpdated); + } + } + + public get language(): string { + return this._language; + } + + public set language(newLanguage: string) { + this._language = newLanguage; + } + + /** + * Sets the future which will be used to update the output + * area for this cell + */ + setFuture(future: nb.IFuture): void { + if (this._future === future) { + // Nothing to do + return; + } + // Setting the future indicates the cell is running which enables trusted mode. + // See https://jupyter-notebook.readthedocs.io/en/stable/security.html + + this._isTrusted = true; + + if (this._future) { + this._future.dispose(); + } + this.clearOutputs(); + this._future = future; + future.setReplyHandler({ handle: (msg) => this.handleReply(msg) }); + future.setIOPubHandler({ handle: (msg) => this.handleIOPub(msg) }); + } + + private clearOutputs(): void { + this._outputs = []; + this.fireOutputsChanged(); + } + + private fireOutputsChanged(): void { + this._onOutputsChanged.fire(this.outputs); + this.sendChangeToNotebook(NotebookChangeType.CellOutputUpdated); + } + + private sendChangeToNotebook(change: NotebookChangeType): void { + if (this._options && this._options.notebook) { + this._options.notebook.onCellChange(this, change); + } + } + + public get outputs(): ReadonlyArray { + return this._outputs; + } + + private handleReply(msg: nb.IShellMessage): void { + // TODO #931 we should process this. There can be a payload attached which should be added to outputs. + // In all other cases, it is a no-op + let output: nb.ICellOutput = msg.content as nb.ICellOutput; + } + + private handleIOPub(msg: nb.IIOPubMessage): void { + let msgType = msg.header.msg_type; + let displayId = this.getDisplayId(msg); + let output: nb.ICellOutput; + switch (msgType) { + case 'execute_result': + case 'display_data': + case 'stream': + case 'error': + output = msg.content as nb.ICellOutput; + output.output_type = msgType; + break; + case 'clear_output': + // TODO wait until next message before clearing + // let wait = (msg as KernelMessage.IClearOutputMsg).content.wait; + this.clearOutputs(); + break; + case 'update_display_data': + output = msg.content as nb.ICellOutput; + output.output_type = 'display_data'; + // TODO #930 handle in-place update of displayed data + // targets = this._displayIdMap.get(displayId); + // if (targets) { + // for (let index of targets) { + // model.set(index, output); + // } + // } + break; + default: + break; + } + // TODO handle in-place update of displayed data + // if (displayId && msgType === 'display_data') { + // targets = this._displayIdMap.get(displayId) || []; + // targets.push(model.length - 1); + // this._displayIdMap.set(displayId, targets); + // } + if (output) { + this._outputs.push(output); + this.fireOutputsChanged(); + } + } + + private getDisplayId(msg: nb.IIOPubMessage): string | undefined { + let transient = (msg.content.transient || {}); + return transient['display_id'] as string; + } + + public toJSON(): nb.ICell { + let cellJson: Partial = { + cell_type: this._cellType, + source: this._source, + metadata: { + } + }; + if (this._cellType === CellTypes.Code) { + cellJson.metadata.language = this._language, + cellJson.outputs = this._outputs; + cellJson.execution_count = 1; // TODO: keep track of actual execution count + + } + return cellJson as nb.ICell; + } + + public fromJSON(cell: nb.ICell): void { + if (!cell) { + return; + } + this._cellType = cell.cell_type; + this._source = Array.isArray(cell.source) ? cell.source.join('') : cell.source; + this._language = (cell.metadata && cell.metadata.language) ? cell.metadata.language : 'python'; + if (cell.outputs) { + for (let output of cell.outputs) { + // For now, we're assuming it's OK to save these as-is with no modification + this.addOutput(output); + } + } + } + + private addOutput(output: nb.ICellOutput) { + this._normalize(output); + this._outputs.push(output); + } + + /** + * Normalize an output. + */ + private _normalize(value: nb.ICellOutput): void { + if (notebookUtils.isStream(value)) { + if (Array.isArray(value.text)) { + value.text = (value.text as string[]).join('\n'); + } + } + } + + private static CreateLanguageMappings(): void { + if (CellModel.LanguageMapping) { + return; + } + CellModel.LanguageMapping = new Map(); + CellModel.LanguageMapping['pyspark'] = 'python'; + CellModel.LanguageMapping['pyspark3'] = 'python'; + CellModel.LanguageMapping['python'] = 'python'; + CellModel.LanguageMapping['scala'] = 'scala'; + } + + private get languageInfo(): nb.ILanguageInfo { + if (this._options && this._options.notebook && this._options.notebook.languageInfo) { + return this._options.notebook.languageInfo; + } + return undefined; + } + + private setDefaultLanguage(): void { + this._language = 'python'; + // In languageInfo, set the language to the "name" property + // If the "name" property isn't defined, check the "mimeType" property + // Otherwise, default to python as the language + let languageInfo = this.languageInfo; + if (languageInfo) { + if (languageInfo.name) { + // check the LanguageMapping to determine if a mapping is necessary (example 'pyspark' -> 'python') + if (CellModel.LanguageMapping[languageInfo.name]) { + this._language = CellModel.LanguageMapping[languageInfo.name]; + } else { + this._language = languageInfo.name; + } + } else if (languageInfo.mimetype) { + this._language = languageInfo.mimetype; + } + } + let mimeTypePrefix = 'x-'; + if (this._language.includes(mimeTypePrefix)) { + this._language = this._language.replace(mimeTypePrefix, ''); + } + } +} diff --git a/src/sql/parts/notebook/models/clientSession.ts b/src/sql/parts/notebook/models/clientSession.ts new file mode 100644 index 0000000000..5b4154e4d5 --- /dev/null +++ b/src/sql/parts/notebook/models/clientSession.ts @@ -0,0 +1,361 @@ + +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the Source EULA. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +// This code is based on @jupyterlab/packages/apputils/src/clientsession.tsx + +'use strict'; + +import { nb } from 'sqlops'; +import * as nls from 'vs/nls'; +import URI from 'vs/base/common/uri'; +import { Event, Emitter } from 'vs/base/common/event'; + +import { IClientSession, IKernelPreference, IClientSessionOptions } from './modelInterfaces'; +import { Deferred } from 'sql/base/common/promise'; + +import * as notebookUtils from '../notebookUtils'; +import * as sparkUtils from '../spark/sparkUtils'; +import { INotebookManager } from 'sql/services/notebook/notebookService'; +import { IConnectionProfile } from 'sql/parts/connection/common/interfaces'; +import { NotebookConnection } from 'sql/parts/notebook/models/notebookConnection'; + +/** + * Implementation of a client session. This is a model over session operations, + * which may come from the session manager or a specific session. + */ +export class ClientSession implements IClientSession { + //#region private fields with public accessors + private _terminatedEmitter = new Emitter(); + private _kernelChangedEmitter = new Emitter(); + private _statusChangedEmitter = new Emitter(); + private _iopubMessageEmitter = new Emitter(); + private _unhandledMessageEmitter = new Emitter(); + private _propertyChangedEmitter = new Emitter<'path' | 'name' | 'type'>(); + private _notebookUri: URI; + private _type: string; + private _name: string; + private _isReady: boolean; + private _ready: Deferred; + private _kernelChangeCompleted: Deferred; + private _kernelPreference: IKernelPreference; + private _kernelDisplayName: string; + private _errorMessage: string; + //#endregion + + private _serverLoadFinished: Promise; + private _session: nb.ISession; + private isServerStarted: boolean; + private notebookManager: INotebookManager; + private _connection: NotebookConnection; + private _kernelConfigActions: ((kernelName: string) => Promise)[] = []; + + constructor(private options: IClientSessionOptions) { + this._notebookUri = options.notebookUri; + this.notebookManager = options.notebookManager; + this._isReady = false; + this._ready = new Deferred(); + this._kernelChangeCompleted = new Deferred(); + } + + public async initialize(connection?: NotebookConnection): Promise { + try { + this._kernelConfigActions.push((kernelName: string) => { return this.runTasksBeforeSessionStart(kernelName); }); + this._connection = connection; + this._serverLoadFinished = this.startServer(); + await this._serverLoadFinished; + await this.initializeSession(); + } catch (err) { + this._errorMessage = notebookUtils.getErrorMessage(err); + } + // Always resolving for now. It's up to callers to check for error case + this._isReady = true; + this._ready.resolve(); + this._kernelChangeCompleted.resolve(); + } + + private async startServer(): Promise { + let serverManager = this.notebookManager.serverManager; + if (serverManager && !serverManager.isStarted) { + await serverManager.startServer(); + if (!serverManager.isStarted) { + throw new Error(nls.localize('ServerNotStarted', 'Server did not start for unknown reason')); + } + this.isServerStarted = serverManager.isStarted; + } else { + this.isServerStarted = true; + } + } + + private async initializeSession(): Promise { + await this._serverLoadFinished; + if (this.isServerStarted) { + if (!this.notebookManager.sessionManager.isReady) { + await this.notebookManager.sessionManager.ready; + } + if (this._kernelPreference && this._kernelPreference.shouldStart) { + await this.startSessionInstance(this._kernelPreference.name); + } + } + } + + private async startSessionInstance(kernelName: string): Promise { + let session: nb.ISession; + try { + // TODO #3164 should use URI instead of path for startNew + session = await this.notebookManager.sessionManager.startNew({ + path: this.notebookUri.fsPath, + kernelName: kernelName + // TODO add kernel name if saved in the document + }); + session.defaultKernelLoaded = true; + } catch (err) { + // TODO move registration + if (err && err.response && err.response.status === 501) { + this.options.notificationService.warn(nls.localize('sparkKernelRequiresConnection', 'Kernel {0} was not found. The default kernel will be used instead.', kernelName)); + session = await this.notebookManager.sessionManager.startNew({ + path: this.notebookUri.fsPath, + kernelName: undefined + }); + session.defaultKernelLoaded = false; + } else { + throw err; + } + } + this._session = session; + await this.runKernelConfigActions(kernelName); + this._statusChangedEmitter.fire(session); + } + + private async runKernelConfigActions(kernelName: string): Promise { + for (let startAction of this._kernelConfigActions) { + await startAction(kernelName); + } + } + + public dispose(): void { + // No-op for now + } + + /** + * Indicates the server has finished loading. It may have failed to load in + * which case the view will be in an error state. + */ + public get serverLoadFinished(): Promise { + return this._serverLoadFinished; + } + + + //#region IClientSession Properties + public get terminated(): Event { + return this._terminatedEmitter.event; + } + public get kernelChanged(): Event { + return this._kernelChangedEmitter.event; + } + public get statusChanged(): Event { + return this._statusChangedEmitter.event; + } + public get iopubMessage(): Event { + return this._iopubMessageEmitter.event; + } + public get unhandledMessage(): Event { + return this._unhandledMessageEmitter.event; + } + public get propertyChanged(): Event<'path' | 'name' | 'type'> { + return this._propertyChangedEmitter.event; + } + public get kernel(): nb.IKernel | null { + return this._session ? this._session.kernel : undefined; + } + public get notebookUri(): URI { + return this._notebookUri; + } + public get name(): string { + return this._name; + } + public get type(): string { + return this._type; + } + public get status(): nb.KernelStatus { + if (!this.isReady) { + return 'starting'; + } + return this._session ? this._session.status : 'dead'; + } + public get isReady(): boolean { + return this._isReady; + } + public get ready(): Promise { + return this._ready.promise; + } + public get kernelChangeCompleted(): Promise { + return this._kernelChangeCompleted.promise; + } + public get kernelPreference(): IKernelPreference { + return this._kernelPreference; + } + public set kernelPreference(value: IKernelPreference) { + this._kernelPreference = value; + } + public get kernelDisplayName(): string { + return this._kernelDisplayName; + } + public get errorMessage(): string { + return this._errorMessage; + } + public get isInErrorState(): boolean { + return !!this._errorMessage; + } + //#endregion + + //#region Not Yet Implemented + /** + * Change the current kernel associated with the document. + */ + async changeKernel(options: nb.IKernelSpec): Promise { + this._kernelChangeCompleted = new Deferred(); + this._isReady = false; + let oldKernel = this.kernel; + let newKernel = this.kernel; + + let kernel = await this.doChangeKernel(options); + try { + await kernel.ready; + } catch (error) { + // Cleanup some state before re-throwing + this._isReady = kernel.isReady; + this._kernelChangeCompleted.resolve(); + throw error; + } + newKernel = this._session ? kernel : this._session.kernel; + this._isReady = kernel.isReady; + // Send resolution events to listeners + this._kernelChangeCompleted.resolve(); + this._kernelChangedEmitter.fire({ + oldValue: oldKernel, + newValue: newKernel + }); + return kernel; + } + + /** + * Helper method to either call ChangeKernel on current session, or start a new session + * @param options + */ + private async doChangeKernel(options: nb.IKernelSpec): Promise { + let kernel: nb.IKernel; + if (this._session) { + kernel = await this._session.changeKernel(options); + await this.runKernelConfigActions(kernel.name); + } else { + kernel = await this.startSessionInstance(options.name).then(() => this.kernel); + } + return kernel; + } + + public async runTasksBeforeSessionStart(kernelName: string): Promise { + // TODO we should move all Spark-related code to SparkMagicContext + if (this._session && this._connection && this.isSparkKernel(kernelName)) { + // TODO may need to reenable a way to get the credential + // await this._connection.getCredential(); + // %_do_not_call_change_endpoint is a SparkMagic command that lets users change endpoint options, + // such as user/profile/host name/auth type + + let server = URI.parse(sparkUtils.getLivyUrl(this._connection.host, this._connection.knoxport)).toString(); + let doNotCallChangeEndpointParams = + `%_do_not_call_change_endpoint --username=${this._connection.user} --password=${this._connection.password} --server=${server} --auth=Basic_Access`; + let future = this._session.kernel.requestExecute({ + code: doNotCallChangeEndpointParams + }, true); + await future.done; + } + } + + public async updateConnection(connection: NotebookConnection): Promise { + if (!this.kernel) { + // TODO is there any case where skipping causes errors? Do far it seems like it gets called twice + return; + } + this._connection = (connection.connectionProfile.id !== '-1') ? connection : this._connection; + // if kernel is not set, don't run kernel config actions + // this should only occur when a cell is cancelled, which interrupts the kernel + if (this.kernel && this.kernel.name) { + await this.runKernelConfigActions(this.kernel.name); + } + } + + isSparkKernel(kernelName: string): any { + return kernelName && kernelName.toLowerCase().indexOf('spark') > -1; + } + + /** + * Kill the kernel and shutdown the session. + * + * @returns A promise that resolves when the session is shut down. + */ + public async shutdown(): Promise { + // Always try to shut down session + if (this._session && this._session.id) { + this.notebookManager.sessionManager.shutdown(this._session.id); + } + let serverManager = this.notebookManager.serverManager; + if (serverManager) { + await serverManager.stopServer(); + } + } + + /** + * Select a kernel for the session. + */ + selectKernel(): Promise { + throw new Error('Not implemented'); + } + + /** + * Restart the session. + * + * @returns A promise that resolves with whether the kernel has restarted. + * + * #### Notes + * If there is a running kernel, present a dialog. + * If there is no kernel, we start a kernel with the last run + * kernel name and resolves with `true`. If no kernel has been started, + * this is a no-op, and resolves with `false`. + */ + restart(): Promise { + throw new Error('Not implemented'); + } + + /** + * Change the session path. + * + * @param path - The new session path. + * + * @returns A promise that resolves when the session has renamed. + * + * #### Notes + * This uses the Jupyter REST API, and the response is validated. + * The promise is fulfilled on a valid response and rejected otherwise. + */ + setPath(path: string): Promise { + throw new Error('Not implemented'); + } + + /** + * Change the session name. + */ + setName(name: string): Promise { + throw new Error('Not implemented'); + } + + /** + * Change the session type. + */ + setType(type: string): Promise { + throw new Error('Not implemented'); + } + //#endregion +} diff --git a/src/sql/parts/notebook/models/contracts.ts b/src/sql/parts/notebook/models/contracts.ts new file mode 100644 index 0000000000..9a918d5730 --- /dev/null +++ b/src/sql/parts/notebook/models/contracts.ts @@ -0,0 +1,47 @@ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +export type CellType = 'code' | 'markdown' | 'raw'; + +export class CellTypes { + public static readonly Code = 'code'; + public static readonly Markdown = 'markdown'; + public static readonly Raw = 'raw'; +} + +// to do: add all mime types +export type MimeType = 'text/plain' | 'text/html'; + +// to do: add all mime types +export class MimeTypes { + public static readonly PlainText = 'text/plain'; + public static readonly HTML = 'text/html'; +} + +export type OutputType = + | 'execute_result' + | 'display_data' + | 'stream' + | 'error' + | 'update_display_data'; + +export class OutputTypes { + public static readonly ExecuteResult = 'execute_result'; + public static readonly DisplayData = 'display_data'; + public static readonly Stream = 'stream'; + public static readonly Error = 'error'; + public static readonly UpdateDisplayData = 'update_display_data'; +} + +export enum NotebookChangeType { + CellsAdded, + CellDeleted, + CellSourceUpdated, + CellOutputUpdated, + DirtyStateChanged +} diff --git a/src/sql/parts/notebook/models/modelFactory.ts b/src/sql/parts/notebook/models/modelFactory.ts new file mode 100644 index 0000000000..37dfca2639 --- /dev/null +++ b/src/sql/parts/notebook/models/modelFactory.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { nb } from 'sqlops'; + +import { CellModel } from './cell'; +import { IClientSession, IClientSessionOptions, ICellModelOptions, ICellModel, IModelFactory } from './modelInterfaces'; +import { ClientSession } from './clientSession'; + +export class ModelFactory implements IModelFactory { + + public createCell(cell: nb.ICell, options: ICellModelOptions): ICellModel { + return new CellModel(this, cell, options); + } + + public createClientSession(options: IClientSessionOptions): IClientSession { + return new ClientSession(options); + } +} diff --git a/src/sql/parts/notebook/models/modelInterfaces.ts b/src/sql/parts/notebook/models/modelInterfaces.ts new file mode 100644 index 0000000000..93d68fee70 --- /dev/null +++ b/src/sql/parts/notebook/models/modelInterfaces.ts @@ -0,0 +1,377 @@ + +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the Source EULA. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +// This code is based on @jupyterlab/packages/apputils/src/clientsession.tsx + +'use strict'; + +import { nb } from 'sqlops'; +import { Event } from 'vs/base/common/event'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import URI from 'vs/base/common/uri'; +import { INotificationService } from 'vs/platform/notification/common/notification'; + +import { CellType, NotebookChangeType } from 'sql/parts/notebook/models/contracts'; +import { INotebookManager } from 'sql/services/notebook/notebookService'; +import { IConnectionProfile } from 'sql/parts/connection/common/interfaces'; +import { NotebookConnection } from 'sql/parts/notebook/models/notebookConnection'; +import { IConnectionManagementService } from 'sql/parts/connection/common/connectionManagement'; + +export interface IClientSessionOptions { + notebookUri: URI; + notebookManager: INotebookManager; + notificationService: INotificationService; +} + +/** + * The interface of client session object. + * + * The client session represents the link between + * a path and its kernel for the duration of the lifetime + * of the session object. The session can have no current + * kernel, and can start a new kernel at any time. + */ +export interface IClientSession extends IDisposable { + /** + * A signal emitted when the session is shut down. + */ + readonly terminated: Event; + + /** + * A signal emitted when the kernel changes. + */ + readonly kernelChanged: Event; + + /** + * A signal emitted when the kernel status changes. + */ + readonly statusChanged: Event; + + /** + * A signal emitted for a kernel messages. + */ + readonly iopubMessage: Event; + + /** + * A signal emitted for an unhandled kernel message. + */ + readonly unhandledMessage: Event; + + /** + * A signal emitted when a session property changes. + */ + readonly propertyChanged: Event<'path' | 'name' | 'type'>; + + /** + * The current kernel associated with the document. + */ + readonly kernel: nb.IKernel | null; + + /** + * The current path associated with the client session. + */ + readonly notebookUri: URI; + + /** + * The current name associated with the client session. + */ + readonly name: string; + + /** + * The type of the client session. + */ + readonly type: string; + + /** + * The current status of the client session. + */ + readonly status: nb.KernelStatus; + + /** + * Whether the session is ready. + */ + readonly isReady: boolean; + + /** + * Whether the session is in an unusable state + */ + readonly isInErrorState: boolean; + /** + * The error information, if this session is in an error state + */ + readonly errorMessage: string; + + /** + * A promise that is fulfilled when the session is ready. + */ + readonly ready: Promise; + + /** + * A promise that is fulfilled when the session completes a kernel change. + */ + readonly kernelChangeCompleted: Promise; + + /** + * The kernel preference. + */ + kernelPreference: IKernelPreference; + + /** + * The display name of the kernel. + */ + readonly kernelDisplayName: string; + + /** + * Initializes the ClientSession, by starting the server and + * connecting to the SessionManager. + * This will optionally start a session if the kernel preferences + * indicate this is desired + */ + initialize(connection?: NotebookConnection): Promise; + + /** + * Change the current kernel associated with the document. + */ + changeKernel( + options: nb.IKernelSpec + ): Promise; + + /** + * Kill the kernel and shutdown the session. + * + * @returns A promise that resolves when the session is shut down. + */ + shutdown(): Promise; + + /** + * Select a kernel for the session. + */ + selectKernel(): Promise; + + /** + * Restart the session. + * + * @returns A promise that resolves with whether the kernel has restarted. + * + * #### Notes + * If there is a running kernel, present a dialog. + * If there is no kernel, we start a kernel with the last run + * kernel name and resolves with `true`. If no kernel has been started, + * this is a no-op, and resolves with `false`. + */ + restart(): Promise; + + /** + * Change the session path. + * + * @param path - The new session path. + * + * @returns A promise that resolves when the session has renamed. + * + * #### Notes + * This uses the Jupyter REST API, and the response is validated. + * The promise is fulfilled on a valid response and rejected otherwise. + */ + setPath(path: string): Promise; + + /** + * Change the session name. + */ + setName(name: string): Promise; + + /** + * Change the session type. + */ + setType(type: string): Promise; + + /** + * Updates the connection + */ + updateConnection(connection: NotebookConnection): void; +} + +export interface IDefaultConnection { + defaultConnection: IConnectionProfile; + otherConnections: IConnectionProfile[]; +} + +/** + * A kernel preference. + */ +export interface IKernelPreference { + /** + * The name of the kernel. + */ + readonly name?: string; + + /** + * The preferred kernel language. + */ + readonly language?: string; + + /** + * The id of an existing kernel. + */ + readonly id?: string; + + /** + * Whether to prefer starting a kernel. + */ + readonly shouldStart?: boolean; + + /** + * Whether a kernel can be started. + */ + readonly canStart?: boolean; + + /** + * Whether to auto-start the default kernel if no matching kernel is found. + */ + readonly autoStartDefault?: boolean; +} + +export interface INotebookModel { + /** + * Cell List for this model + */ + readonly cells: ReadonlyArray; + /** + * Client Session in the notebook, used for sending requests to the notebook service + */ + readonly clientSession: IClientSession; + /** + * LanguageInfo saved in the query book + */ + readonly languageInfo: nb.ILanguageInfo; + + /** + * The notebook service used to call backend APIs + */ + readonly notebookManager: INotebookManager; + + /** + * Event fired on first initialization of the kernel and + * on subsequent change events + */ + readonly kernelChanged: Event; + + /** + * Event fired on first initialization of the kernels and + * on subsequent change events + */ + readonly kernelsChanged: Event; + + /** + * Default kernel + */ + defaultKernel?: nb.IKernelSpec; + + /** + * Event fired on first initialization of the contexts and + * on subsequent change events + */ + readonly contextsChanged: Event; + + /** + * The specs for available kernels, or undefined if these have + * not been loaded yet + */ + readonly specs: nb.IAllKernels | undefined; + + /** + * The specs for available contexts, or undefined if these have + * not been loaded yet + */ + readonly contexts: IDefaultConnection | undefined; + + /** + * The trusted mode of the NoteBook + */ + trustedMode: boolean; + + /** + * Change the current kernel from the Kernel dropdown + * @param displayName kernel name (as displayed in Kernel dropdown) + */ + changeKernel(displayName: string): void; + + /** + * Change the current context (if applicable) + */ + changeContext(host: string): void; + + /** + * Adds a cell to the end of the model + */ + addCell(cellType: CellType): void; + + /** + * Deletes a cell + */ + deleteCell(cellModel: ICellModel): void; + + /** + * Save the model to its backing content manager. + * Serializes the model and then calls through to save it + */ + saveModel(): Promise; + + /** + * Notifies the notebook of a change in the cell + */ + onCellChange(cell: ICellModel, change: NotebookChangeType): void; +} + +export interface ICellModelOptions { + notebook: INotebookModel; + isTrusted: boolean; +} + +export interface ICellModel { + cellUri: URI; + id: string; + language: string; + source: string; + cellType: CellType; + trustedMode: boolean; + active: boolean; + readonly outputs: ReadonlyArray; + equals(cellModel: ICellModel): boolean; + toJSON(): nb.ICell; +} + +export interface IModelFactory { + + createCell(cell: nb.ICell, options: ICellModelOptions): ICellModel; + createClientSession(options: IClientSessionOptions): IClientSession; +} + + +export interface INotebookModelOptions { + /** + * Path to the local or remote notebook + */ + notebookUri: URI; + + /** + * Factory for creating cells and client sessions + */ + factory: IModelFactory; + + notebookManager: INotebookManager; + + notificationService: INotificationService; + connectionService: IConnectionManagementService; +} + +// TODO would like to move most of these constants to an extension +export namespace notebookConstants { + export const hadoopKnoxProviderName = 'HADOOP_KNOX'; + export const python3 = 'python3'; + export const python3DisplayName = 'Python 3'; + export const defaultSparkKernel = 'pyspark3kernel'; + +} \ No newline at end of file diff --git a/src/sql/parts/notebook/models/notebookConnection.ts b/src/sql/parts/notebook/models/notebookConnection.ts new file mode 100644 index 0000000000..60b778669b --- /dev/null +++ b/src/sql/parts/notebook/models/notebookConnection.ts @@ -0,0 +1,94 @@ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { localize } from 'vs/nls'; + +import { IConnectionProfile } from 'sql/parts/connection/common/interfaces'; + +export namespace constants { + export const hostPropName = 'host'; + export const userPropName = 'user'; + export const knoxPortPropName = 'knoxport'; + export const clusterPropName = 'clustername'; + export const passwordPropName = 'password'; + export const defaultKnoxPort = '30443'; +} +/** + * This is a temporary connection definition, with known properties for Knox gateway connections. + * Long term this should be refactored to an extension contribution + * + * @export + * @class NotebookConnection + */ +export class NotebookConnection { + private _host: string; + private _knoxPort: string; + + constructor(private _connectionProfile: IConnectionProfile) { + if (!this._connectionProfile) { + throw new Error(localize('connectionInfoMissing', 'connectionInfo is required')); + } + } + + public get connectionProfile(): IConnectionProfile { + return this._connectionProfile; + } + + + public get host(): string { + if (!this._host) { + this.ensureHostAndPort(); + } + return this._host; + } + + /** + * Sets host and port values, using any ',' or ':' delimited port in the hostname in + * preference to the built in port. + */ + private ensureHostAndPort(): void { + this._host = this.connectionProfile.options[constants.hostPropName]; + this._knoxPort = NotebookConnection.getKnoxPortOrDefault(this.connectionProfile); + // determine whether the host has either a ',' or ':' in it + this.setHostAndPort(','); + this.setHostAndPort(':'); + } + + // set port and host correctly after we've identified that a delimiter exists in the host name + private setHostAndPort(delimeter: string): void { + let originalHost = this._host; + let index = originalHost.indexOf(delimeter); + if (index > -1) { + this._host = originalHost.slice(0, index); + this._knoxPort = originalHost.slice(index + 1); + } + } + + public get user(): string { + return this._connectionProfile.options[constants.userPropName]; + } + + public get password(): string { + return this._connectionProfile.options[constants.passwordPropName]; + } + + public get knoxport(): string { + if (!this._knoxPort) { + this.ensureHostAndPort(); + } + return this._knoxPort; + } + + private static getKnoxPortOrDefault(connectionProfile: IConnectionProfile): string { + let port = connectionProfile.options[constants.knoxPortPropName]; + if (!port) { + port = constants.defaultKnoxPort; + } + return port; + } +} diff --git a/src/sql/parts/notebook/models/notebookModel.ts b/src/sql/parts/notebook/models/notebookModel.ts new file mode 100644 index 0000000000..17520a2899 --- /dev/null +++ b/src/sql/parts/notebook/models/notebookModel.ts @@ -0,0 +1,479 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { nb } from 'sqlops'; + +import { localize } from 'vs/nls'; +import { Event, Emitter } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; + +import { CellModel } from './cell'; +import { IClientSession, INotebookModel, IDefaultConnection, INotebookModelOptions, ICellModel, notebookConstants } from './modelInterfaces'; +import { NotebookChangeType, CellTypes, CellType } from 'sql/parts/notebook/models/contracts'; +import { nbversion } from '../notebookConstants'; +import * as notebookUtils from '../notebookUtils'; +import { INotebookManager } from 'sql/services/notebook/notebookService'; +import { SparkMagicContexts } from 'sql/parts/notebook/models/sparkMagicContexts'; +import { IConnectionProfile } from 'sql/parts/connection/common/interfaces'; +import { NotebookConnection } from 'sql/parts/notebook/models/notebookConnection'; +import { INotification, Severity } from 'vs/platform/notification/common/notification'; +import { Schemas } from 'vs/base/common/network'; + +/* +* Used to control whether a message in a dialog/wizard is displayed as an error, +* warning, or informational message. Default is error. +*/ +export enum MessageLevel { + Error = 0, + Warning = 1, + Information = 2 +} + +export class ErrorInfo { + constructor(public readonly message: string, public readonly severity: MessageLevel) { + } +} +export interface NotebookContentChange { + /** + * What was the change that occurred? + */ + changeType: NotebookChangeType; + /** + * Optional cells that were changed + */ + cells?: ICellModel | ICellModel[]; + /** + * Optional index of the change, indicating the cell at which an insert or + * delete occurred + */ + cellIndex?: number; + /** + * Optional value indicating if the notebook is in a dirty or clean state after this change + * + * @type {boolean} + * @memberof NotebookContentChange + */ + isDirty?: boolean; +} + +export class NotebookModel extends Disposable implements INotebookModel { + private _contextsChangedEmitter = new Emitter(); + private _contentChangedEmitter = new Emitter(); + private _kernelsChangedEmitter = new Emitter(); + private _inErrorState: boolean = false; + private _clientSession: IClientSession; + private _sessionLoadFinished: Promise; + private _onClientSessionReady = new Emitter(); + private _activeContexts: IDefaultConnection; + private _trustedMode: boolean; + + private _cells: ICellModel[]; + private _defaultLanguageInfo: nb.ILanguageInfo; + private onErrorEmitter = new Emitter(); + private _savedKernelInfo: nb.IKernelInfo; + private readonly _nbformat: number = nbversion.MAJOR_VERSION; + private readonly _nbformatMinor: number = nbversion.MINOR_VERSION; + private _hadoopConnection: NotebookConnection; + private _defaultKernel: nb.IKernelSpec; + + constructor(private notebookOptions: INotebookModelOptions, startSessionImmediately?: boolean, private connectionProfile?: IConnectionProfile) { + super(); + if (!notebookOptions || !notebookOptions.notebookUri || !notebookOptions.notebookManager) { + throw new Error('path or notebook service not defined'); + } + if (startSessionImmediately) { + this.backgroundStartSession(); + } + this._trustedMode = false; + } + + public get notebookManager(): INotebookManager { + return this.notebookOptions.notebookManager; + } + + public get hasServerManager(): boolean { + // If the service has a server manager, then we can show the start button + return !!this.notebookManager.serverManager; + } + + public get contentChanged(): Event { + return this._contentChangedEmitter.event; + } + + public get isSessionReady(): boolean { + return !!this._clientSession; + } + + /** + * ClientSession object which handles management of a session instance, + * plus startup of the session manager which can return key metadata about the + * notebook environment + */ + public get clientSession(): IClientSession { + return this._clientSession; + } + + public get kernelChanged(): Event { + return this.clientSession.kernelChanged; + } + + public get kernelsChanged(): Event { + return this._kernelsChangedEmitter.event; + } + + public get defaultKernel(): nb.IKernelSpec { + return this._defaultKernel; + } + + public get contextsChanged(): Event { + return this._contextsChangedEmitter.event; + } + + public get cells(): ICellModel[] { + return this._cells; + } + + public get contexts(): IDefaultConnection { + return this._activeContexts; + } + + public get specs(): nb.IAllKernels | undefined { + return this.notebookManager.sessionManager.specs; + } + + public get inErrorState(): boolean { + return this._inErrorState; + } + + public get onError(): Event { + return this.onErrorEmitter.event; + } + + public get trustedMode(): boolean { + return this._trustedMode; + } + + public set trustedMode(isTrusted: boolean) { + this._trustedMode = isTrusted; + if (this._cells) { + this._cells.forEach(c => { + c.trustedMode = this._trustedMode; + }); + } + } + + /** + * Indicates the server has finished loading. It may have failed to load in + * which case the view will be in an error state. + */ + public get sessionLoadFinished(): Promise { + return this._sessionLoadFinished; + } + + /** + * Notifies when the client session is ready for use + */ + public get onClientSessionReady(): Event { + return this._onClientSessionReady.event; + } + + public async requestModelLoad(isTrusted: boolean = false): Promise { + try { + this._trustedMode = isTrusted; + let contents = null; + if(this.notebookOptions.notebookUri.scheme !== Schemas.untitled) { + contents = await this.notebookManager.contentManager.getNotebookContents(this.notebookOptions.notebookUri); + } + let factory = this.notebookOptions.factory; + // if cells already exist, create them with language info (if it is saved) + this._cells = undefined; + if (contents) { + this._defaultLanguageInfo = this.getDefaultLanguageInfo(contents); + this._savedKernelInfo = this.getSavedKernelInfo(contents); + if (contents.cells && contents.cells.length > 0) { + this._cells = contents.cells.map(c => factory.createCell(c, { notebook: this, isTrusted: isTrusted })); + } + } + if (!this._cells) { + this._cells = [this.createCell(CellTypes.Code)]; + } + } catch (error) { + this._inErrorState = true; + throw error; + } + } + + public addCell(cellType: CellType): void { + if (this.inErrorState || !this._cells) { + return; + } + let cell = this.createCell(cellType); + this._cells.push(cell); + this._contentChangedEmitter.fire({ + changeType: NotebookChangeType.CellsAdded, + cells: [cell] + }); + } + + private createCell(cellType: CellType): ICellModel { + let singleCell: nb.ICell = { + cell_type: cellType, + source: '', + metadata: {}, + execution_count: 1 + }; + return this.notebookOptions.factory.createCell(singleCell, { notebook: this, isTrusted: true }); + } + + deleteCell(cellModel: CellModel): void { + if (this.inErrorState || !this._cells) { + return; + } + let index = this._cells.findIndex((cell) => cell.equals(cellModel)); + if (index > -1) { + this._cells.splice(index, 1); + this._contentChangedEmitter.fire({ + changeType: NotebookChangeType.CellDeleted, + cells: [cellModel], + cellIndex: index + }); + } else { + this.notifyError(localize('deleteCellFailed', 'Failed to delete cell.')); + } + } + + private notifyError(error: string): void { + this.onErrorEmitter.fire({ message: error, severity: Severity.Error }); + } + + public backgroundStartSession(): void { + this._clientSession = this.notebookOptions.factory.createClientSession({ + notebookUri: this.notebookOptions.notebookUri, + notebookManager: this.notebookManager, + notificationService: this.notebookOptions.notificationService + }); + let id: string = this.connectionProfile ? this.connectionProfile.id : undefined; + + this._hadoopConnection = this.connectionProfile ? new NotebookConnection(this.connectionProfile) : undefined; + this._clientSession.initialize(this._hadoopConnection); + this._sessionLoadFinished = this._clientSession.ready.then(async () => { + if (this._clientSession.isInErrorState) { + this.setErrorState(this._clientSession.errorMessage); + } else { + this._onClientSessionReady.fire(this._clientSession); + // Once session is loaded, can use the session manager to retrieve useful info + this.loadKernelInfo(); + await this.loadActiveContexts(undefined); + } + }); + } + + public get languageInfo(): nb.ILanguageInfo { + return this._defaultLanguageInfo; + } + + private updateLanguageInfo(info: nb.ILanguageInfo) { + if (info) { + this._defaultLanguageInfo = info; + } + } + + public changeKernel(displayName: string): void { + let spec = this.getSpecNameFromDisplayName(displayName); + this.doChangeKernel(spec); + } + + private doChangeKernel(kernelSpec: nb.IKernelSpec): void { + this._clientSession.changeKernel(kernelSpec) + .then((kernel) => { + kernel.ready.then(() => { + if (kernel.info) { + this.updateLanguageInfo(kernel.info.language_info); + } + }, err => undefined); + return this.updateKernelInfo(kernel); + }).catch((err) => { + this.notifyError(localize('changeKernelFailed', 'Failed to change kernel: {0}', notebookUtils.getErrorMessage(err))); + // TODO should revert kernels dropdown + }); + + } + + public changeContext(host: string): void { + try { + let newConnection: IConnectionProfile = this._activeContexts.otherConnections.find((connection) => connection.options['host'] === host); + if (!newConnection && this._activeContexts.defaultConnection.options['host'] === host) { + newConnection = this._activeContexts.defaultConnection; + } + if (newConnection) { + SparkMagicContexts.configureContext(newConnection, this.notebookOptions); + this._hadoopConnection = new NotebookConnection(newConnection); + this._clientSession.updateConnection(this._hadoopConnection); + } + } catch (err) { + let msg = notebookUtils.getErrorMessage(err); + this.notifyError(localize('changeContextFailed', 'Changing context failed: {0}', msg)); + } + } + + private loadKernelInfo(): void { + this.clientSession.kernelChanged(async (e) => { + await this.loadActiveContexts(e); + }); + try { + let sessionManager = this.notebookManager.sessionManager; + if (sessionManager) { + let defaultKernel = SparkMagicContexts.getDefaultKernel(sessionManager.specs, this.connectionProfile, this._savedKernelInfo, this.notebookOptions.notificationService); + this._defaultKernel = defaultKernel; + this._clientSession.statusChanged(async (session) => { + if (session && session.defaultKernelLoaded === true) { + this._kernelsChangedEmitter.fire(defaultKernel); + } else if (session && !session.defaultKernelLoaded) { + this._kernelsChangedEmitter.fire({ name: notebookConstants.python3, display_name: notebookConstants.python3DisplayName }); + } + }); + this.doChangeKernel(defaultKernel); + } + } catch (err) { + let msg = notebookUtils.getErrorMessage(err); + this.notifyError(localize('loadKernelFailed', 'Loading kernel info failed: {0}', msg)); + } + } + + // Get default language if saved in notebook file + // Otherwise, default to python + private getDefaultLanguageInfo(notebook: nb.INotebook): nb.ILanguageInfo { + return notebook!.metadata!.language_info || { + name: 'python', + version: '', + mimetype: 'x-python' + }; + } + + // Get default kernel info if saved in notebook file + private getSavedKernelInfo(notebook: nb.INotebook): nb.IKernelInfo { + return notebook!.metadata!.kernelspec; + } + + private getSpecNameFromDisplayName(displayName: string): nb.IKernelSpec { + displayName = this.sanitizeDisplayName(displayName); + let kernel: nb.IKernelSpec = this.specs.kernels.find(k => k.display_name.toLowerCase() === displayName.toLowerCase()); + if (!kernel) { + return undefined; // undefined is handled gracefully in the session to default to the default kernel + } else if (!kernel.name) { + kernel.name = this.specs.defaultKernel; + } + return kernel; + } + + private setErrorState(errMsg: string): void { + this._inErrorState = true; + let msg = localize('startSessionFailed', 'Could not start session: {0}', errMsg); + this.notifyError(msg); + + } + + public dispose(): void { + super.dispose(); + this.handleClosed(); + } + + public async handleClosed(): Promise { + try { + if (this._clientSession) { + await this._clientSession.shutdown(); + this._clientSession = undefined; + } + } catch (err) { + this.notifyError(localize('shutdownError', 'An error occurred when closing the notebook: {0}', err)); + } + } + + private async loadActiveContexts(kernelChangedArgs: nb.IKernelChangedArgs): Promise { + this._activeContexts = await SparkMagicContexts.getContextsForKernel(this.notebookOptions.connectionService, kernelChangedArgs, this.connectionProfile); + this._contextsChangedEmitter.fire(); + let defaultHadoopConnection = new NotebookConnection(this.contexts.defaultConnection); + this.changeContext(defaultHadoopConnection.host); + } + + /** + * Sanitizes display name to remove IP address in order to fairly compare kernels + * In some notebooks, display name is in the format () + * example: PySpark (25.23.32.4) + * @param displayName Display Name for the kernel + */ + public sanitizeDisplayName(displayName: string): string { + let name = displayName; + if (name) { + let index = name.indexOf('('); + name = (index > -1) ? name.substr(0, index - 1).trim() : name; + } + return name; + } + + public async saveModel(): Promise { + let notebook = this.toJSON(); + if (!notebook) { + return false; + } + await this.notebookManager.contentManager.save(this.notebookOptions.notebookUri, notebook); + this._contentChangedEmitter.fire({ + changeType: NotebookChangeType.DirtyStateChanged, + isDirty: false + }); + return true; + } + + private async updateKernelInfo(kernel: nb.IKernel): Promise { + if (kernel) { + try { + let spec = await kernel.getSpec(); + this._savedKernelInfo = { + name: kernel.name, + display_name: spec.display_name, + language: spec.language + }; + } catch (err) { + // Don't worry about this for now. Just use saved values + } + } + } + /** + * Serialize the model to JSON. + */ + toJSON(): nb.INotebook { + let cells: nb.ICell[] = this.cells.map(c => c.toJSON()); + let metadata = Object.create(null) as nb.INotebookMetadata; + // TODO update language and kernel when these change + metadata.kernelspec = this._savedKernelInfo; + metadata.language_info = this.languageInfo; + return { + metadata, + nbformat_minor: this._nbformatMinor, + nbformat: this._nbformat, + cells + }; + } + + onCellChange(cell: CellModel, change: NotebookChangeType): void { + let changeInfo: NotebookContentChange = { + changeType: change, + cells: [cell] + }; + switch (change) { + case NotebookChangeType.CellOutputUpdated: + case NotebookChangeType.CellSourceUpdated: + changeInfo.changeType = NotebookChangeType.DirtyStateChanged; + changeInfo.isDirty = true; + break; + default: + // Do nothing for now + } + this._contentChangedEmitter.fire(changeInfo); + } + +} diff --git a/src/sql/parts/notebook/models/sparkMagicContexts.ts b/src/sql/parts/notebook/models/sparkMagicContexts.ts new file mode 100644 index 0000000000..2f9854233a --- /dev/null +++ b/src/sql/parts/notebook/models/sparkMagicContexts.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as path from 'path'; +import { nb } from 'sqlops'; + +import * as json from 'vs/base/common/json'; +import * as pfs from 'vs/base/node/pfs'; +import { localize } from 'vs/nls'; +import { IConnectionProfile } from 'sql/parts/connection/common/interfaces'; +import { IDefaultConnection, notebookConstants, INotebookModelOptions } from 'sql/parts/notebook/models/modelInterfaces'; +import * as notebookUtils from '../notebookUtils'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { IConnectionManagementService } from 'sql/parts/connection/common/connectionManagement'; + +export class SparkMagicContexts { + + public static get DefaultContext(): IDefaultConnection { + // TODO NOTEBOOK REFACTOR fix default connection handling + let defaultConnection: IConnectionProfile = { + providerName: notebookConstants.hadoopKnoxProviderName, + id: '-1', + options: + { + host: localize('selectConnection', 'Select Connection') + } + }; + + return { + // default context if no other contexts are applicable + defaultConnection: defaultConnection, + otherConnections: [defaultConnection] + }; + } + + /** + * Get all of the applicable contexts for a given kernel + * @param apiWrapper ApiWrapper + * @param kernelChangedArgs kernel changed args (both old and new kernel info) + * @param profile current connection profile + */ + public static async getContextsForKernel(connectionService: IConnectionManagementService, kernelChangedArgs?: nb.IKernelChangedArgs, profile?: IConnectionProfile): Promise { + let connections: IDefaultConnection = this.DefaultContext; + if (!profile) { + if (!kernelChangedArgs || !kernelChangedArgs.newValue || + (kernelChangedArgs.oldValue && kernelChangedArgs.newValue.id === kernelChangedArgs.oldValue.id)) { + // nothing to do, kernels are the same or new kernel is undefined + return connections; + } + } + if (kernelChangedArgs && kernelChangedArgs.newValue && kernelChangedArgs.newValue.name) { + switch (kernelChangedArgs.newValue.name) { + case (notebookConstants.python3): + // python3 case, use this.DefaultContext for the only connection + break; + //TO DO: Handle server connections based on kernel type. Right now, we call the same method for all kernel types. + default: + connections = await this.getActiveContexts(connectionService, profile); + } + } else { + connections = await this.getActiveContexts(connectionService, profile); + } + return connections; + } + + /** + * Get all active contexts and sort them + * @param apiWrapper ApiWrapper + * @param profile current connection profile + */ + public static async getActiveContexts(connectionService: IConnectionManagementService, profile: IConnectionProfile): Promise { + let defaultConnection: IConnectionProfile = SparkMagicContexts.DefaultContext.defaultConnection; + let activeConnections: IConnectionProfile[] = await connectionService.getActiveConnections(); + // If no connections exist, only show 'n/a' + if (activeConnections.length === 0) { + return SparkMagicContexts.DefaultContext; + } + activeConnections = activeConnections.filter(conn => conn.providerName === notebookConstants.hadoopKnoxProviderName); + + // If launched from the right click or server dashboard, connection profile data exists, so use that as default + if (profile && profile.options) { + let profileConnection = activeConnections.filter(conn => conn.options['host'] === profile.options['host']); + if (profileConnection) { + defaultConnection = profileConnection[0]; + } + } else { + if (activeConnections.length > 0) { + defaultConnection = activeConnections[0]; + } else { + // TODO NOTEBOOK REFACTOR change this so it's no longer incompatible with IConnectionProfile + defaultConnection = { + providerName: notebookConstants.hadoopKnoxProviderName, + id: '-1', + options: + { + host: localize('addConnection', 'Add new connection') + } + }; + activeConnections.push(defaultConnection); + } + } + return { + otherConnections: activeConnections, + defaultConnection: defaultConnection + }; + } + + public static async configureContext(connection: IConnectionProfile, options: INotebookModelOptions): Promise { + let sparkmagicConfDir = path.join(notebookUtils.getUserHome(), '.sparkmagic'); + // TODO NOTEBOOK REFACTOR re-enable this or move to extension. Requires config files to be available in order to work + // await notebookUtils.mkDir(sparkmagicConfDir); + + // let hadoopConnection = new Connection({ options: connection.options }, undefined, connection.connectionId); + // await hadoopConnection.getCredential(); + // // Default to localhost in config file. + // let creds: ICredentials = { + // 'url': 'http://localhost:8088' + // }; + + // let configPath = notebookUtils.getTemplatePath(options.extensionContext.extensionPath, path.join('jupyter_config', 'sparkmagic_config.json')); + // let fileBuffer: Buffer = await pfs.readFile(configPath); + // let fileContents: string = fileBuffer.toString(); + // let config: ISparkMagicConfig = json.parse(fileContents); + // SparkMagicContexts.updateConfig(config, creds, sparkmagicConfDir); + + // let configFilePath = path.join(sparkmagicConfDir, 'config.json'); + // await pfs.writeFile(configFilePath, JSON.stringify(config)); + + return {'SPARKMAGIC_CONF_DIR': sparkmagicConfDir}; + } + /** + * + * @param specs kernel specs (comes from session manager) + * @param connectionInfo connection profile + * @param savedKernelInfo kernel info loaded from + */ + public static getDefaultKernel(specs: nb.IAllKernels, connectionInfo: IConnectionProfile, savedKernelInfo: nb.IKernelInfo, notificationService: INotificationService): nb.IKernelSpec { + let defaultKernel = specs.kernels.find((kernel) => kernel.name === specs.defaultKernel); + let profile = connectionInfo as IConnectionProfile; + if (specs && connectionInfo && profile.providerName === notebookConstants.hadoopKnoxProviderName) { + // set default kernel to default spark kernel if profile exists + // otherwise, set default to kernel info loaded from existing file + defaultKernel = !savedKernelInfo ? specs.kernels.find((spec) => spec.name === notebookConstants.defaultSparkKernel) : savedKernelInfo; + } else { + // Handle kernels + if (savedKernelInfo && savedKernelInfo.name.toLowerCase().indexOf('spark') > -1) { + notificationService.warn(localize('sparkKernelRequiresConnection', 'Cannot use kernel {0} as no connection is active. The default kernel of {1} will be used instead.', savedKernelInfo.display_name, defaultKernel.display_name)); + } + } + + // If no default kernel specified (should never happen), default to python3 + if (!defaultKernel) { + defaultKernel = { + name: notebookConstants.python3, + display_name: notebookConstants.python3DisplayName + }; + } + return defaultKernel; + } + + private static updateConfig(config: ISparkMagicConfig, creds: ICredentials, homePath: string): void { + config.kernel_python_credentials = creds; + config.kernel_scala_credentials = creds; + config.kernel_r_credentials = creds; + config.logging_config.handlers.magicsHandler.home_path = homePath; + } +} + +interface ICredentials { + 'url': string; +} + +interface ISparkMagicConfig { + kernel_python_credentials: ICredentials; + kernel_scala_credentials: ICredentials; + kernel_r_credentials: ICredentials; + logging_config: { + handlers: { + magicsHandler: { + home_path: string; + } + } + }; + +} + +export interface IKernelJupyterID { + id: string; + jupyterId: string; +} diff --git a/src/sql/parts/notebook/notebook.component.html b/src/sql/parts/notebook/notebook.component.html new file mode 100644 index 0000000000..49db5b2fe0 --- /dev/null +++ b/src/sql/parts/notebook/notebook.component.html @@ -0,0 +1,19 @@ + +
+
+
+
+ +
+ + + + +
+
+
diff --git a/src/sql/parts/notebook/notebook.component.ts b/src/sql/parts/notebook/notebook.component.ts new file mode 100644 index 0000000000..55357a4dc4 --- /dev/null +++ b/src/sql/parts/notebook/notebook.component.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the Source EULA. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import './notebookStyles'; + +import { nb } from 'sqlops'; + +import { OnInit, Component, Inject, forwardRef, ElementRef, ChangeDetectorRef, ViewChild } from '@angular/core'; + +import URI from 'vs/base/common/uri'; +import { IColorTheme, IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; +import * as themeColors from 'vs/workbench/common/theme'; +import { INotificationService, INotification } from 'vs/platform/notification/common/notification'; +import { localize } from 'vs/nls'; + +import { CommonServiceInterface } from 'sql/services/common/commonServiceInterface.service'; +import { AngularDisposable } from 'sql/base/common/lifecycle'; + +import { CellTypes, CellType, NotebookChangeType } from 'sql/parts/notebook/models/contracts'; +import { ICellModel, INotebookModel, IModelFactory, INotebookModelOptions } from 'sql/parts/notebook/models/modelInterfaces'; +import { IConnectionManagementService } from 'sql/parts/connection/common/connectionManagement'; +import { INotebookService, INotebookParams, INotebookManager } from 'sql/services/notebook/notebookService'; +import { IBootstrapParams } from 'sql/services/bootstrap/bootstrapService'; +import { NotebookModel, ErrorInfo, MessageLevel, NotebookContentChange } from 'sql/parts/notebook/models/notebookModel'; +import { ModelFactory } from 'sql/parts/notebook/models/modelFactory'; +import * as notebookUtils from './notebookUtils'; +import { Deferred } from 'sql/base/common/promise'; +import { IConnectionProfile } from 'sql/parts/connection/common/interfaces'; +import { Taskbar } from 'sql/base/browser/ui/taskbar/taskbar'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { KernelsDropdown, AttachToDropdown, AddCellAction } from 'sql/parts/notebook/notebookActions'; +import { attachSelectBoxStyler } from 'vs/platform/theme/common/styler'; + +export const NOTEBOOK_SELECTOR: string = 'notebook-component'; + + +@Component({ + selector: NOTEBOOK_SELECTOR, + templateUrl: decodeURI(require.toUrl('./notebook.component.html')) +}) +export class NotebookComponent extends AngularDisposable implements OnInit { + @ViewChild('toolbar', { read: ElementRef }) private toolbar: ElementRef; + private _model: NotebookModel; + private _isInErrorState: boolean = false; + private _errorMessage: string; + protected _actionBar: Taskbar; + private _activeCell: ICellModel; + protected isLoading: boolean; + private notebookManager: INotebookManager; + private _modelReadyDeferred = new Deferred(); + private _modelRegisteredDeferred = new Deferred(); + private profile: IConnectionProfile; + + + constructor( + @Inject(forwardRef(() => CommonServiceInterface)) private _bootstrapService: CommonServiceInterface, + @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, + @Inject(IWorkbenchThemeService) private themeService: IWorkbenchThemeService, + @Inject(IConnectionManagementService) private connectionManagementService: IConnectionManagementService, + @Inject(INotificationService) private notificationService: INotificationService, + @Inject(INotebookService) private notebookService: INotebookService, + @Inject(IBootstrapParams) private notebookParams: INotebookParams, + @Inject(IInstantiationService) private instantiationService: IInstantiationService, + @Inject(IContextMenuService) private contextMenuService: IContextMenuService, + @Inject(IContextViewService) private contextViewService: IContextViewService + ) { + super(); + this.profile = this.notebookParams!.profile; + this.isLoading = true; + } + + ngOnInit() { + this._register(this.themeService.onDidColorThemeChange(this.updateTheme, this)); + this.updateTheme(this.themeService.getColorTheme()); + this.initActionBar(); + this.doLoad(); + } + + public get modelRegistered(): Promise { + return this._modelRegisteredDeferred.promise; + } + + protected get cells(): ReadonlyArray { + return this._model ? this._model.cells : []; + } + + private updateTheme(theme: IColorTheme): void { + let toolbarEl = this.toolbar.nativeElement; + toolbarEl.style.borderBottomColor = theme.getColor(themeColors.SIDE_BAR_BACKGROUND, true).toString(); + } + + public selectCell(cell: ICellModel) { + if (cell !== this._activeCell) { + if (this._activeCell) { + this._activeCell.active = false; + } + this._activeCell = cell; + this._activeCell.active = true; + this._changeRef.detectChanges(); + } + } + + //Add cell based on cell type + public addCell(cellType: CellType) + { + this._model.addCell(cellType); + } + + public onKeyDown(event) { + switch (event.key) { + case 'ArrowDown': + case 'ArrowRight': + let nextIndex = (this.findCellIndex(this._activeCell) + 1) % this.cells.length; + this.selectCell(this.cells[nextIndex]); + break; + case 'ArrowUp': + case 'ArrowLeft': + let index = this.findCellIndex(this._activeCell); + if (index === 0) { + index = this.cells.length; + } + this.selectCell(this.cells[--index]); + break; + default: + break; + } + } + + private async doLoad(): Promise { + try { + await this.loadModel(); + this.setLoading(false); + this._modelReadyDeferred.resolve(this._model); + } catch (error) { + this.setViewInErrorState(localize('displayFailed', 'Could not display contents: {0}', error)); + this.setLoading(false); + this._modelReadyDeferred.reject(error); + } + } + + private setLoading(isLoading: boolean): void { + this.isLoading = isLoading; + this._changeRef.detectChanges(); + } + + private async loadModel(): Promise { + this.notebookManager = await this.notebookService.getOrCreateNotebookManager(this.notebookParams.providerId, this.notebookParams.notebookUri); + let model = new NotebookModel({ + factory: this.modelFactory, + notebookUri: this.notebookParams.notebookUri, + connectionService: this.connectionManagementService, + notificationService: this.notificationService, + notebookManager: this.notebookManager + }, false, this.profile); + model.onError((errInfo: INotification) => this.handleModelError(errInfo)); + await model.requestModelLoad(this.notebookParams.isTrusted); + model.contentChanged((change) => this.handleContentChanged(change)); + this._model = model; + this._register(model); + this._modelRegisteredDeferred.resolve(this._model); + model.backgroundStartSession(); + this._changeRef.detectChanges(); + } + + private get modelFactory(): IModelFactory { + if (!this.notebookParams.modelFactory) { + this.notebookParams.modelFactory = new ModelFactory(); + } + return this.notebookParams.modelFactory; + } + private handleModelError(notification: INotification): void { + this.notificationService.notify(notification); + } + + private handleContentChanged(change: NotebookContentChange) { + // Note: for now we just need to set dirty state and refresh the UI. + this.setDirty(true); + this._changeRef.detectChanges(); + } + + findCellIndex(cellModel: ICellModel): number { + return this._model.cells.findIndex((cell) => cell.id === cellModel.id); + } + + private setViewInErrorState(error: any): any { + this._isInErrorState = true; + this._errorMessage = notebookUtils.getErrorMessage(error); + // For now, send message as error notification #870 covers having dedicated area for this + this.notificationService.error(error); + } + + protected initActionBar() { + let kernelInfoText = document.createElement('div'); + kernelInfoText.className = 'notebook-info-label'; + kernelInfoText.innerText = 'Kernel: '; + + let kernelsDropdown = new KernelsDropdown(this.contextViewService, this.modelRegistered); + let kernelsDropdownTemplateContainer = document.createElement('div'); + kernelsDropdownTemplateContainer.className = 'notebook-toolbar-dropdown'; + kernelsDropdown.render(kernelsDropdownTemplateContainer); + attachSelectBoxStyler(kernelsDropdown, this.themeService); + + let attachToDropdown = new AttachToDropdown(this.contextViewService); + let attachToDropdownTemplateContainer = document.createElement('div'); + attachToDropdownTemplateContainer.className = 'notebook-toolbar-dropdown'; + attachToDropdown.render(attachToDropdownTemplateContainer); + attachSelectBoxStyler(attachToDropdown, this.themeService); + + let attachToInfoText = document.createElement('div'); + attachToInfoText.className = 'notebook-info-label'; + attachToInfoText.innerText = 'Attach To: '; + + let addCodeCellButton = new AddCellAction('notebook.AddCodeCell', localize('code', 'Code'), 'notebook-info-button'); + addCodeCellButton.cellType = CellTypes.Code; + + let addTextCellButton = new AddCellAction('notebook.AddTextCell',localize('text', 'Text'), 'notebook-info-button'); + addTextCellButton.cellType = CellTypes.Markdown; + + let taskbar = this.toolbar.nativeElement; + this._actionBar = new Taskbar(taskbar, this.contextMenuService); + this._actionBar.context = this; + this._actionBar.setContent([ + { element: kernelInfoText }, + { element: kernelsDropdownTemplateContainer }, + { element: attachToInfoText }, + { element: attachToDropdownTemplateContainer }, + { action: addCodeCellButton}, + { action: addTextCellButton} + ]); + } + + public async save(): Promise { + try { + let saved = await this._model.saveModel(); + return saved; + } catch (err) { + this.notificationService.error(localize('saveFailed', 'Failed to save notebook: {0}', notebookUtils.getErrorMessage(err))); + return false; + } + } + + private setDirty(isDirty: boolean): void { + // TODO reenable handling of isDirty + // if (this.editor) { + // this.editor.isDirty = isDirty; + // } + } + + +} diff --git a/src/sql/parts/notebook/notebook.contribution.ts b/src/sql/parts/notebook/notebook.contribution.ts new file mode 100644 index 0000000000..8b681c0334 --- /dev/null +++ b/src/sql/parts/notebook/notebook.contribution.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Registry } from 'vs/platform/registry/common/platform'; +import { EditorDescriptor, IEditorRegistry, Extensions as EditorExtensions } from 'vs/workbench/browser/editor'; +import { IConfigurationRegistry, Extensions as ConfigExtensions } from 'vs/platform/configuration/common/configurationRegistry'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { MenuRegistry, MenuId } from 'vs/platform/actions/common/actions'; +import { Action } from 'vs/base/common/actions'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { TPromise } from 'vs/base/common/winjs.base'; +import { Schemas } from 'vs/base/common/network'; +import URI from 'vs/base/common/uri'; +import { localize } from 'vs/nls'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; + +import { NotebookInput, NotebookInputModel, notebooksEnabledCondition } from 'sql/parts/notebook/notebookInput'; +import { NotebookEditor } from 'sql/parts/notebook/notebookEditor'; +import { CommandsRegistry } from 'vs/platform/commands/common/commands'; + + +let counter = 0; + +/** + * todo: Will remove this code. + * This is the entry point to open the new Notebook + */ +export class NewNotebookAction extends Action { + + public static ID = 'workbench.action.newnotebook'; + public static LABEL = localize('workbench.action.newnotebook.description', 'New Notebook'); + + constructor( + id: string, + label: string, + @IEditorService private _editorService: IEditorService, + @IInstantiationService private _instantiationService: IInstantiationService + ) { + super(id, label); + } + + public run(): TPromise { + let title = `Untitled-${counter++}`; + let untitledUri = URI.from({ scheme: Schemas.untitled, path: title }); + let model = new NotebookInputModel(untitledUri, undefined, false, undefined); + let input = this._instantiationService.createInstance(NotebookInput, title, model); + return this._editorService.openEditor(input, { pinned: true }).then(() => undefined); + } +} + +// Model View editor registration +const viewModelEditorDescriptor = new EditorDescriptor( + NotebookEditor, + NotebookEditor.ID, + 'Notebook' +); + +Registry.as(EditorExtensions.Editors) + .registerEditor(viewModelEditorDescriptor, [new SyncDescriptor(NotebookInput)]); + +// Feature flag for built-in Notebooks. Will be removed in the future. +const configurationRegistry = Registry.as(ConfigExtensions.Configuration); +configurationRegistry.registerConfiguration({ + 'id': 'notebook', + 'title': 'Notebook', + 'type': 'object', + 'properties': { + 'notebook.enabled': { + 'type': 'boolean', + 'default': false, + 'description': localize('notebook.enabledDescription', 'Enable viewing notebook files using built-in notebook editor.') + } + } +}); + +// this is the entry point to open the new Notebook +CommandsRegistry.registerCommand(NewNotebookAction.ID, serviceAccessor => { + serviceAccessor.get(IInstantiationService).createInstance(NewNotebookAction, NewNotebookAction.ID, NewNotebookAction.LABEL).run(); +}); + +MenuRegistry.appendMenuItem(MenuId.CommandPalette, { + command: { + id: NewNotebookAction.ID, + title:NewNotebookAction.LABEL, + }, + when: notebooksEnabledCondition +}); \ No newline at end of file diff --git a/src/sql/parts/notebook/notebook.css b/src/sql/parts/notebook/notebook.css new file mode 100644 index 0000000000..efcfbc6bca --- /dev/null +++ b/src/sql/parts/notebook/notebook.css @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +.notebookEditor .editor-toolbar { + border-bottom-width: 1px; + border-bottom-style: solid; +} + +.notebookEditor .notebook-cell { + margin: 10px 20px 10px; + border-width: 1px; + border-style: solid; +} + +.notebookEditor .notebook-toolbar-dropdown { + width: 150px; + padding-right: 10px; +} + +.notebookEditor .notebook-info-label { + padding-right: 5px; + text-align: center; + display: flex; + align-items: center; +} + +.notebookEditor .actionbar-container .monaco-action-bar > ul.actions-container { + padding-top: 0px; +} + +.notebookEditor .notebook-info-button { + display: inline-block; + width: 100%; + padding: 4px; + text-align: center; + cursor: pointer; + padding-left: 15px; + background-size: 11px; + margin-right: 0.3em; + font-size: 13px; + background-image: url("./media/light/add.svg") +} \ No newline at end of file diff --git a/src/sql/parts/notebook/notebook.module.ts b/src/sql/parts/notebook/notebook.module.ts new file mode 100644 index 0000000000..d60a84edbe --- /dev/null +++ b/src/sql/parts/notebook/notebook.module.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the Source EULA. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { forwardRef, NgModule, ComponentFactoryResolver, Inject, ApplicationRef } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { CommonModule, APP_BASE_HREF } from '@angular/common'; +import { BrowserModule } from '@angular/platform-browser'; + + +import { Extensions, IComponentRegistry } from 'sql/platform/dashboard/common/modelComponentRegistry'; +import { ComponentHostDirective } from 'sql/parts/dashboard/common/componentHost.directive'; +import { IBootstrapParams, ISelector, providerIterator } from 'sql/services/bootstrap/bootstrapService'; +import { CommonServiceInterface } from 'sql/services/common/commonServiceInterface.service'; +import { Checkbox } from 'sql/base/browser/ui/checkbox/checkbox.component'; +import { SelectBox } from 'sql/base/browser/ui/selectBox/selectBox.component'; +import { EditableDropDown } from 'sql/base/browser/ui/editableDropdown/editableDropdown.component'; +import { InputBox } from 'sql/base/browser/ui/inputBox/inputBox.component'; +import { NotebookComponent } from 'sql/parts/notebook/notebook.component'; + +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { CodeComponent } from 'sql/parts/notebook/cellViews/code.component'; +import { CodeCellComponent } from 'sql/parts/notebook/cellViews/codeCell.component'; +import { TextCellComponent } from 'sql/parts/notebook/cellViews/textCell.component'; +import { OutputAreaComponent } from 'sql/parts/notebook/cellViews/outputArea.component'; +import { OutputComponent } from 'sql/parts/notebook/cellViews/output.component'; +import LoadingSpinner from 'sql/parts/modelComponents/loadingSpinner.component'; + +export const NotebookModule = (params, selector: string, instantiationService: IInstantiationService): any => { + @NgModule({ + declarations: [ + Checkbox, + SelectBox, + EditableDropDown, + InputBox, + LoadingSpinner, + CodeComponent, + CodeCellComponent, + TextCellComponent, + NotebookComponent, + ComponentHostDirective, + OutputAreaComponent, + OutputComponent + ], + entryComponents: [NotebookComponent], + imports: [ + FormsModule, + CommonModule, + BrowserModule + ], + providers: [ + { provide: APP_BASE_HREF, useValue: '/' }, + CommonServiceInterface, + { provide: IBootstrapParams, useValue: params }, + { provide: ISelector, useValue: selector }, + ...providerIterator(instantiationService) + ] + }) + class ModuleClass { + + constructor( + @Inject(forwardRef(() => ComponentFactoryResolver)) private _resolver: ComponentFactoryResolver, + @Inject(ISelector) private selector: string + ) { + } + + ngDoBootstrap(appRef: ApplicationRef) { + const factoryWrapper: any = this._resolver.resolveComponentFactory(NotebookComponent); + factoryWrapper.factory.selector = this.selector; + appRef.bootstrap(factoryWrapper); + } + } + + return ModuleClass; +}; diff --git a/src/sql/parts/notebook/notebookActions.ts b/src/sql/parts/notebook/notebookActions.ts new file mode 100644 index 0000000000..ec6dd103ef --- /dev/null +++ b/src/sql/parts/notebook/notebookActions.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + import * as sqlops from 'sqlops'; + +import { Action } from 'vs/base/common/actions'; +import { TPromise } from 'vs/base/common/winjs.base'; +import { localize } from 'vs/nls'; +import { IContextViewProvider } from 'vs/base/browser/ui/contextview/contextview'; + +import { SelectBox } from 'sql/base/browser/ui/selectBox/selectBox'; +import { INotebookModel } from 'sql/parts/notebook/models/modelInterfaces'; +import { CellTypes, CellType } from 'sql/parts/notebook/models/contracts'; +import { NotebookComponent } from 'sql/parts/notebook/notebook.component'; + +const msgLoading = localize('loading', 'Loading kernels...'); + +//Action to add a cell to notebook based on cell type(code/markdown). +export class AddCellAction extends Action { + public cellType: CellType; + + constructor( + id: string, label: string, cssClass: string + ) { + super(id, label, cssClass); + } + public run(context: NotebookComponent): TPromise { + return new TPromise((resolve, reject) => { + try { + context.addCell(this.cellType); + resolve(true); + } catch (e) { + reject(e); + } + }); + } +} + +export class KernelsDropdown extends SelectBox { + private model: INotebookModel; + constructor(contextViewProvider: IContextViewProvider, modelRegistered: Promise + ) { + super( [msgLoading], msgLoading, contextViewProvider); + if (modelRegistered) { + modelRegistered + .then((model) => this.updateModel(model)) + .catch((err) => { + // No-op for now + }); + } + + this.onDidSelect(e => this.doChangeKernel(e.selected)); + } + + updateModel(model: INotebookModel): void { + this.model = model; + model.kernelsChanged((defaultKernel) => { + this.updateKernel(defaultKernel); + }); + if (model.clientSession) { + model.clientSession.kernelChanged((changedArgs: sqlops.nb.IKernelChangedArgs) => { + if (changedArgs.newValue) { + this.updateKernel(changedArgs.newValue); + } + }); + } + } + + // Update SelectBox values + private updateKernel(defaultKernel: sqlops.nb.IKernelSpec) { + let specs = this.model.specs; + if (specs && specs.kernels) { + let index = specs.kernels.findIndex((kernel => kernel.name === defaultKernel.name)); + this.setOptions(specs.kernels.map(kernel => kernel.display_name), index); + } + } + + public doChangeKernel(displayName: string): void { + this.model.changeKernel(displayName); + } +} + +export class AttachToDropdown extends SelectBox { + constructor(contextViewProvider: IContextViewProvider + ) { + let options: string[] = ['localhost']; + super(options, 'localhost', contextViewProvider); + } +} \ No newline at end of file diff --git a/src/sql/parts/notebook/notebookConstants.ts b/src/sql/parts/notebook/notebookConstants.ts new file mode 100644 index 0000000000..54ff5d1527 --- /dev/null +++ b/src/sql/parts/notebook/notebookConstants.ts @@ -0,0 +1,19 @@ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +export namespace nbversion { + /** + * The major version of the notebook format. + */ + export const MAJOR_VERSION: number = 4; + + /** + * The minor version of the notebook format. + */ + export const MINOR_VERSION: number = 2; +} \ No newline at end of file diff --git a/src/sql/parts/notebook/notebookEditor.ts b/src/sql/parts/notebook/notebookEditor.ts new file mode 100644 index 0000000000..3df4253354 --- /dev/null +++ b/src/sql/parts/notebook/notebookEditor.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { TPromise } from 'vs/base/common/winjs.base'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor'; +import { EditorOptions } from 'vs/workbench/common/editor'; +import * as DOM from 'vs/base/browser/dom'; +import { $ } from 'vs/base/browser/builder'; +import { bootstrapAngular } from 'sql/services/bootstrap/bootstrapService'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { NotebookInput } from 'sql/parts/notebook/notebookInput'; +import { NotebookModule } from 'sql/parts/notebook/notebook.module'; +import { NOTEBOOK_SELECTOR } from 'sql/parts/notebook/notebook.component'; +import { INotebookParams, DEFAULT_NOTEBOOK_PROVIDER } from 'sql/services/notebook/notebookService'; + +export class NotebookEditor extends BaseEditor { + + public static ID: string = 'workbench.editor.notebookEditor'; + private _notebookContainer: HTMLElement; + protected _input: NotebookInput; + + constructor( + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IInstantiationService private instantiationService: IInstantiationService, + ) { + super(NotebookEditor.ID, telemetryService, themeService); + } + + public get input(): NotebookInput { + return this._input; + } + + /** + * Called to create the editor in the parent element. + */ + public createEditor(parent: HTMLElement): void { + } + + /** + * Sets focus on this editor. Specifically, it sets the focus on the hosted text editor. + */ + public focus(): void { + } + + /** + * Updates the internal variable keeping track of the editor's size, and re-calculates the sash position. + * To be called when the container of this editor changes size. + */ + public layout(dimension: DOM.Dimension): void { + } + + public setInput(input: NotebookInput, options: EditorOptions): TPromise { + if (this.input && this.input.matches(input)) { + return TPromise.as(undefined); + } + + const parentElement = this.getContainer(); + + super.setInput(input, options, CancellationToken.None); + + $(parentElement).clearChildren(); + + if (!input.hasBootstrapped) { + let container = DOM.$('.notebookEditor'); + container.style.height = '100%'; + this._notebookContainer = DOM.append(parentElement, container); + this.input.container = this._notebookContainer; + return TPromise.wrap(this.bootstrapAngular(input)); + } else { + this._notebookContainer = DOM.append(parentElement, this.input.container); + return TPromise.wrap(null); + } + } + + /** + * Load the angular components and record for this input that we have done so + */ + private bootstrapAngular(input: NotebookInput): void { + // Get the bootstrap params and perform the bootstrap + input.hasBootstrapped = true; + let params: INotebookParams = { + notebookUri: input.notebookUri, + providerId: input.providerId ? input.providerId : DEFAULT_NOTEBOOK_PROVIDER, + isTrusted: input.isTrusted + }; + bootstrapAngular(this.instantiationService, + NotebookModule, + this._notebookContainer, + NOTEBOOK_SELECTOR, + params, + input + ); + } +} + diff --git a/src/sql/parts/notebook/notebookInput.ts b/src/sql/parts/notebook/notebookInput.ts new file mode 100644 index 0000000000..c65d0632e3 --- /dev/null +++ b/src/sql/parts/notebook/notebookInput.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { TPromise } from 'vs/base/common/winjs.base'; +import { IEditorModel } from 'vs/platform/editor/common/editor'; +import { EditorInput, EditorModel, ConfirmResult } from 'vs/workbench/common/editor'; +import { Emitter, Event } from 'vs/base/common/event'; +import URI from 'vs/base/common/uri'; +import { IContextKeyService, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; + +import { INotebookService } from 'sql/services/notebook/notebookService'; + +export type ModeViewSaveHandler = (handle: number) => Thenable; + +export let notebooksEnabledCondition = ContextKeyExpr.equals('config.notebook.enabled', true); + + +export class NotebookInputModel extends EditorModel { + private dirty: boolean; + private readonly _onDidChangeDirty: Emitter = this._register(new Emitter()); + private _providerId: string; + constructor(public readonly notebookUri: URI, private readonly handle: number, private _isTrusted: boolean = false, private saveHandler?: ModeViewSaveHandler) { + super(); + this.dirty = false; + } + + public get providerId(): string { + return this._providerId; + } + + public set providerId(value: string) { + this._providerId = value; + } + + get isTrusted(): boolean { + return this._isTrusted; + } + + get onDidChangeDirty(): Event { + return this._onDidChangeDirty.event; + } + + get isDirty(): boolean { + return this.dirty; + } + + public setDirty(dirty: boolean): void { + if (this.dirty === dirty) { + return; + } + + this.dirty = dirty; + this._onDidChangeDirty.fire(); + } + + save(): TPromise { + if (this.saveHandler) { + return TPromise.wrap(this.saveHandler(this.handle)); + } + return TPromise.wrap(true); + } +} + +export class NotebookInputValidator { + + constructor(@IContextKeyService private readonly _contextKeyService: IContextKeyService) {} + + public isNotebookEnabled(): boolean { + return this._contextKeyService.contextMatchesRules(notebooksEnabledCondition); + } +} + +export class NotebookInput extends EditorInput { + + public static ID: string = 'workbench.editorinputs.notebookInput'; + + public hasBootstrapped = false; + // Holds the HTML content for the editor when the editor discards this input and loads another + private _parentContainer: HTMLElement; + + constructor(private _title: string, + private _model: NotebookInputModel, + @INotebookService private notebookService: INotebookService + ) { + super(); + this._model.onDidChangeDirty(() => this._onDidChangeDirty.fire()); + this.onDispose(() => { + if (this.notebookService) { + this.notebookService.handleNotebookClosed(this.notebookUri); + } + }); + } + + public get title(): string { + return this._title; + } + + public get notebookUri(): URI { + return this._model.notebookUri; + } + + public get providerId(): string { + return this._model.providerId; + } + + public getTypeId(): string { + return NotebookInput.ID; + } + + public resolve(refresh?: boolean): TPromise { + return undefined; + } + + public getName(): string { + return this._title; + } + + public get isTrusted(): boolean { + return this._model.isTrusted; + } + + public dispose(): void { + this._disposeContainer(); + super.dispose(); + } + + private _disposeContainer() { + if (!this._parentContainer) { + return; + } + + let parentNode = this._parentContainer.parentNode; + if (parentNode) { + parentNode.removeChild(this._parentContainer); + this._parentContainer = null; + } + } + + set container(container: HTMLElement) { + this._disposeContainer(); + this._parentContainer = container; + } + + get container(): HTMLElement { + return this._parentContainer; + } + + /** + * An editor that is dirty will be asked to be saved once it closes. + */ + isDirty(): boolean { + return this._model.isDirty; + } + + /** + * Subclasses should bring up a proper dialog for the user if the editor is dirty and return the result. + */ + confirmSave(): TPromise { + // TODO #2530 support save on close / confirm save. This is significantly more work + // as we need to either integrate with textFileService (seems like this isn't viable) + // or register our own complimentary service that handles the lifecycle operations such + // as close all, auto save etc. + return TPromise.wrap(ConfirmResult.DONT_SAVE); + } + + /** + * Saves the editor if it is dirty. Subclasses return a promise with a boolean indicating the success of the operation. + */ + save(): TPromise { + return this._model.save(); + } +} \ No newline at end of file diff --git a/src/sql/parts/notebook/notebookStyles.ts b/src/sql/parts/notebook/notebookStyles.ts new file mode 100644 index 0000000000..82a1aafa07 --- /dev/null +++ b/src/sql/parts/notebook/notebookStyles.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import 'vs/css!./notebook'; + +import { registerThemingParticipant, ITheme, ICssStyleCollector } from 'vs/platform/theme/common/themeService'; +import { SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; +import { activeContrastBorder, buttonBackground } from 'vs/platform/theme/common/colorRegistry'; + +registerThemingParticipant((theme: ITheme, collector: ICssStyleCollector) => { + + // Active border + const activeBorder = theme.getColor(buttonBackground); + if (activeBorder) { + collector.addRule(` + .notebookEditor .notebook-cell.active { + border-color: ${activeBorder}; + border-width: 2px; + } + `); + } + + // Inactive border + const inactiveBorder = theme.getColor(SIDE_BAR_BACKGROUND); + if (inactiveBorder) { + collector.addRule(` + .notebookEditor .notebook-cell { + border-color: ${inactiveBorder}; + border-width: 1px; + } + `); + } + + // Styling with Outline color (e.g. high contrast theme) + const outline = theme.getColor(activeContrastBorder); + if (outline) { + collector.addRule(` + .notebookEditor .notebook-cell.active { + outline-color: ${outline}; + outline-width: 1px; + outline-style: solid; + } + + .notebookEditor .notebook-cell:hover:not(.active) { + outline-style: dashed; + } + `); + } +}); diff --git a/src/sql/parts/notebook/notebookUtils.ts b/src/sql/parts/notebook/notebookUtils.ts new file mode 100644 index 0000000000..d250f5c4bb --- /dev/null +++ b/src/sql/parts/notebook/notebookUtils.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { nb } from 'sqlops'; +import * as os from 'os'; +import * as pfs from 'vs/base/node/pfs'; +import { localize } from 'vs/nls'; +import { IOutputChannel } from 'vs/workbench/parts/output/common/output'; + + +/** + * Test whether an output is from a stream. + */ +export function isStream(output: nb.ICellOutput): output is nb.IStreamResult { + return output.output_type === 'stream'; +} + +export function getErrorMessage(error: Error | string): string { + return (error instanceof Error) ? error.message : error; +} + +export function getUserHome(): string { + return process.env.HOME || process.env.USERPROFILE; +} + +export async function mkDir(dirPath: string, outputChannel?: IOutputChannel): Promise { + let exists = await pfs.dirExists(dirPath); + if (!exists) { + if (outputChannel) { + outputChannel.append(localize('mkdirOutputMsg', '... Creating {0}', dirPath) + os.EOL); + } + await pfs.mkdirp(dirPath); + } +} diff --git a/src/sql/parts/notebook/outputs/common/jsonext.ts b/src/sql/parts/notebook/outputs/common/jsonext.ts new file mode 100644 index 0000000000..95de8717f1 --- /dev/null +++ b/src/sql/parts/notebook/outputs/common/jsonext.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; +/** + * A type alias for a JSON primitive. + */ +export declare type JSONPrimitive = boolean | number | string | null; +/** + * A type alias for a JSON value. + */ +export declare type JSONValue = JSONPrimitive | JSONObject | JSONArray; +/** + * A type definition for a JSON object. + */ +export interface JSONObject { + [key: string]: JSONValue; +} +/** + * A type definition for a JSON array. + */ +export interface JSONArray extends Array { +} +/** + * A type definition for a readonly JSON object. + */ +export interface ReadonlyJSONObject { + readonly [key: string]: ReadonlyJSONValue; +} +/** + * A type definition for a readonly JSON array. + */ +export interface ReadonlyJSONArray extends ReadonlyArray { +} +/** + * A type alias for a readonly JSON value. + */ +export declare type ReadonlyJSONValue = JSONPrimitive | ReadonlyJSONObject | ReadonlyJSONArray; +/** + * Test whether a JSON value is a primitive. + * + * @param value - The JSON value of interest. + * + * @returns `true` if the value is a primitive,`false` otherwise. + */ +export function isPrimitive(value: any): boolean { + return ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ); +} diff --git a/src/sql/parts/notebook/outputs/common/mimemodel.ts b/src/sql/parts/notebook/outputs/common/mimemodel.ts new file mode 100644 index 0000000000..3b88c489d7 --- /dev/null +++ b/src/sql/parts/notebook/outputs/common/mimemodel.ts @@ -0,0 +1,87 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ +import { IRenderMime } from './renderMimeInterfaces'; +import { ReadonlyJSONObject } from './jsonext'; + +/** + * The default mime model implementation. + */ +export class MimeModel implements IRenderMime.IMimeModel { + /** + * Construct a new mime model. + */ + constructor(options: MimeModel.IOptions = {}) { + this.trusted = !!options.trusted; + this._data = options.data || {}; + this._metadata = options.metadata || {}; + this._callback = options.callback; + } + + /** + * Whether the model is trusted. + */ + readonly trusted: boolean; + + /** + * The data associated with the model. + */ + get data(): ReadonlyJSONObject { + return this._data; + } + + /** + * The metadata associated with the model. + */ + get metadata(): ReadonlyJSONObject { + return this._metadata; + } + + /** + * Set the data associated with the model. + * + * #### Notes + * Depending on the implementation of the mime model, + * this call may or may not have deferred effects, + */ + setData(options: IRenderMime.ISetDataOptions): void { + this._data = options.data || this._data; + this._metadata = options.metadata || this._metadata; + this._callback(options); + } + + private _callback: (options: IRenderMime.ISetDataOptions) => void; + private _data: ReadonlyJSONObject; + private _metadata: ReadonlyJSONObject; +} + +/** + * The namespace for MimeModel class statics. + */ +export namespace MimeModel { + /** + * The options used to create a mime model. + */ + export interface IOptions { + /** + * Whether the model is trusted. Defaults to `false`. + */ + trusted?: boolean; + + /** + * A callback function for when the data changes. + */ + callback?: (options: IRenderMime.ISetDataOptions) => void; + + /** + * The initial mime data. + */ + data?: ReadonlyJSONObject; + + /** + * The initial mime metadata. + */ + metadata?: ReadonlyJSONObject; + } +} \ No newline at end of file diff --git a/src/sql/parts/notebook/outputs/common/nbformat.ts b/src/sql/parts/notebook/outputs/common/nbformat.ts new file mode 100644 index 0000000000..12ebc816cb --- /dev/null +++ b/src/sql/parts/notebook/outputs/common/nbformat.ts @@ -0,0 +1,494 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +// Notebook format interfaces +// https://nbformat.readthedocs.io/en/latest/format_description.html +// https://github.com/jupyter/nbformat/blob/master/nbformat/v4/nbformat.v4.schema.json + + +import { JSONObject } from './jsonext'; +import { nb } from 'sqlops'; + +/** + * A namespace for nbformat interfaces. + */ +export namespace nbformat { + /** + * The major version of the notebook format. + */ + export const MAJOR_VERSION: number = 4; + + /** + * The minor version of the notebook format. + */ + export const MINOR_VERSION: number = 2; + + /** + * The kernelspec metadata. + */ + export interface IKernelspecMetadata extends JSONObject { + name: string; + display_name: string; + } + + /** + * The language info metatda + */ + export interface ILanguageInfoMetadata extends JSONObject { + name: string; + codemirror_mode?: string | JSONObject; + file_extension?: string; + mimetype?: string; + pygments_lexer?: string; + } + + /** + * The default metadata for the notebook. + */ + export interface INotebookMetadata extends JSONObject { + kernelspec?: IKernelspecMetadata; + language_info?: ILanguageInfoMetadata; + orig_nbformat: number; + } + + /** + * The notebook content. + */ + export interface INotebookContent { + metadata: INotebookMetadata; + nbformat_minor: number; + nbformat: number; + cells: ICell[]; + } + + /** + * A multiline string. + */ + export type MultilineString = string | string[]; + + /** + * A mime-type keyed dictionary of data. + */ + export interface IMimeBundle extends JSONObject { + [key: string]: MultilineString | JSONObject; + } + + /** + * Media attachments (e.g. inline images). + */ + export interface IAttachments { + [key: string]: IMimeBundle; + } + + /** + * The code cell's prompt number. Will be null if the cell has not been run. + */ + export type ExecutionCount = number | null; + + /** + * Cell output metadata. + */ + export type OutputMetadata = JSONObject; + + /** + * Validate a mime type/value pair. + * + * @param type - The mimetype name. + * + * @param value - The value associated with the type. + * + * @returns Whether the type/value pair are valid. + */ + export function validateMimeValue( + type: string, + value: MultilineString | JSONObject + ): boolean { + // Check if "application/json" or "application/foo+json" + const jsonTest = /^application\/(.*?)+\+json$/; + const isJSONType = type === 'application/json' || jsonTest.test(type); + + let isString = (x: any) => { + return Object.prototype.toString.call(x) === '[object String]'; + }; + + // If it is an array, make sure if is not a JSON type and it is an + // array of strings. + if (Array.isArray(value)) { + if (isJSONType) { + return false; + } + let valid = true; + (value as string[]).forEach(v => { + if (!isString(v)) { + valid = false; + } + }); + return valid; + } + + // If it is a string, make sure we are not a JSON type. + if (isString(value)) { + return !isJSONType; + } + + // It is not a string, make sure it is a JSON type. + if (!isJSONType) { + return false; + } + + // It is a JSON type, make sure it is a valid JSON object. + // return JSONExt.isObject(value); + return true; + } + + /** + * Cell-level metadata. + */ + export interface IBaseCellMetadata extends JSONObject { + /** + * Whether the cell is trusted. + * + * #### Notes + * This is not strictly part of the nbformat spec, but it is added by + * the contents manager. + * + * See https://jupyter-notebook.readthedocs.io/en/latest/security.html. + */ + trusted: boolean; + + /** + * The cell's name. If present, must be a non-empty string. + */ + name: string; + + /** + * The cell's tags. Tags must be unique, and must not contain commas. + */ + tags: string[]; + } + + /** + * The base cell interface. + */ + export interface IBaseCell { + /** + * String identifying the type of cell. + */ + cell_type: string; + + /** + * Contents of the cell, represented as an array of lines. + */ + source: MultilineString; + + /** + * Cell-level metadata. + */ + metadata: Partial; + } + + /** + * Metadata for the raw cell. + */ + export interface IRawCellMetadata extends IBaseCellMetadata { + /** + * Raw cell metadata format for nbconvert. + */ + format: string; + } + + /** + * A raw cell. + */ + export interface IRawCell extends IBaseCell { + /** + * String identifying the type of cell. + */ + cell_type: 'raw'; + + /** + * Cell-level metadata. + */ + metadata: Partial; + + /** + * Cell attachments. + */ + attachments?: IAttachments; + } + + /** + * A markdown cell. + */ + export interface IMarkdownCell extends IBaseCell { + /** + * String identifying the type of cell. + */ + cell_type: 'markdown'; + + /** + * Cell attachments. + */ + attachments?: IAttachments; + } + + /** + * Metadata for a code cell. + */ + export interface ICodeCellMetadata extends IBaseCellMetadata { + /** + * Whether the cell is collapsed/expanded. + */ + collapsed: boolean; + + /** + * Whether the cell's output is scrolled, unscrolled, or autoscrolled. + */ + scrolled: boolean | 'auto'; + } + + /** + * A code cell. + */ + export interface ICodeCell extends IBaseCell { + /** + * String identifying the type of cell. + */ + cell_type: 'code'; + + /** + * Cell-level metadata. + */ + metadata: Partial; + + /** + * Execution, display, or stream outputs. + */ + outputs: IOutput[]; + + /** + * The code cell's prompt number. Will be null if the cell has not been run. + */ + execution_count: ExecutionCount; + } + + /** + * An unrecognized cell. + */ + export interface IUnrecognizedCell extends IBaseCell { } + + /** + * A cell union type. + */ + export type ICell = IRawCell | IMarkdownCell | ICodeCell | IUnrecognizedCell; + + /** + * Test whether a cell is a raw cell. + */ + export function isRaw(cell: ICell): cell is IRawCell { + return cell.cell_type === 'raw'; + } + + /** + * Test whether a cell is a markdown cell. + */ + export function isMarkdown(cell: ICell): cell is IMarkdownCell { + return cell.cell_type === 'markdown'; + } + + /** + * Test whether a cell is a code cell. + */ + export function isCode(cell: ICell): cell is ICodeCell { + return cell.cell_type === 'code'; + } + + /** + * A union metadata type. + */ + export type ICellMetadata = + | IBaseCellMetadata + | IRawCellMetadata + | ICodeCellMetadata; + + /** + * The valid output types. + */ + export type OutputType = + | 'execute_result' + | 'display_data' + | 'stream' + | 'error' + | 'update_display_data'; + + + /** + * Result of executing a code cell. + */ + export interface IExecuteResult extends nb.ICellOutput { + /** + * Type of cell output. + */ + output_type: 'execute_result'; + + /** + * A result's prompt number. + */ + execution_count: ExecutionCount; + + /** + * A mime-type keyed dictionary of data. + */ + data: IMimeBundle; + + /** + * Cell output metadata. + */ + metadata: OutputMetadata; + } + + /** + * Data displayed as a result of code cell execution. + */ + export interface IDisplayData extends nb.ICellOutput { + /** + * Type of cell output. + */ + output_type: 'display_data'; + + /** + * A mime-type keyed dictionary of data. + */ + data: IMimeBundle; + + /** + * Cell output metadata. + */ + metadata: OutputMetadata; + } + + /** + * Data displayed as an update to existing display data. + */ + export interface IDisplayUpdate extends nb.ICellOutput { + /** + * Type of cell output. + */ + output_type: 'update_display_data'; + + /** + * A mime-type keyed dictionary of data. + */ + data: IMimeBundle; + + /** + * Cell output metadata. + */ + metadata: OutputMetadata; + } + + /** + * Stream output from a code cell. + */ + export interface IStream extends nb.ICellOutput { + /** + * Type of cell output. + */ + output_type: 'stream'; + + /** + * The name of the stream. + */ + name: StreamType; + + /** + * The stream's text output. + */ + text: MultilineString; + } + + /** + * An alias for a stream type. + */ + export type StreamType = 'stdout' | 'stderr'; + + /** + * Output of an error that occurred during code cell execution. + */ + export interface IError extends nb.ICellOutput { + /** + * Type of cell output. + */ + output_type: 'error'; + + /** + * The name of the error. + */ + ename: string; + + /** + * The value, or message, of the error. + */ + evalue: string; + + /** + * The error's traceback. + */ + traceback: string[]; + } + + /** + * Unrecognized output. + */ + export interface IUnrecognizedOutput extends nb.ICellOutput { } + + /** + * Test whether an output is an execute result. + */ + export function isExecuteResult(output: IOutput): output is IExecuteResult { + return output.output_type === 'execute_result'; + } + + /** + * Test whether an output is from display data. + */ + export function isDisplayData(output: IOutput): output is IDisplayData { + return output.output_type === 'display_data'; + } + + /** + * Test whether an output is from updated display data. + */ + export function isDisplayUpdate(output: IOutput): output is IDisplayUpdate { + return output.output_type === 'update_display_data'; + } + + /** + * Test whether an output is from a stream. + */ + export function isStream(output: IOutput): output is IStream { + return output.output_type === 'stream'; + } + + /** + * Test whether an output is from a stream. + */ + export function isError(output: IOutput): output is IError { + return output.output_type === 'error'; + } + + /** + * An output union type. + */ + export type IOutput = + | IUnrecognizedOutput + | IExecuteResult + | IDisplayData + | IStream + | IError; +} + +export interface ICellOutputWithIdAndTrust extends nb.ICellOutput { + id: number; + trusted: boolean; +} diff --git a/src/sql/parts/notebook/outputs/common/outputProcessor.ts b/src/sql/parts/notebook/outputs/common/outputProcessor.ts new file mode 100644 index 0000000000..11ad262d25 --- /dev/null +++ b/src/sql/parts/notebook/outputs/common/outputProcessor.ts @@ -0,0 +1,110 @@ + +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +import { JSONObject } from './jsonext'; +import { MimeModel } from './mimemodel'; +import * as JSONExt from './jsonext'; +import { nbformat } from './nbformat'; +import { nb } from 'sqlops'; + +/** + * A multiline string. + */ +export type MultilineString = string | string[]; + +/** + * A mime-type keyed dictionary of data. + */ +export interface IMimeBundle extends JSONObject { + [key: string]: MultilineString | JSONObject; +} + +/** + * Get the data from a notebook output. + */ +export function getData(output: nb.ICellOutput): JSONObject { + let bundle: IMimeBundle = {}; + if ( + nbformat.isExecuteResult(output) || + nbformat.isDisplayData(output) || + nbformat.isDisplayUpdate(output) + ) { + bundle = (output as nbformat.IExecuteResult).data; + } else if (nbformat.isStream(output)) { + if (output.name === 'stderr') { + bundle['application/vnd.jupyter.stderr'] = output.text; + } else { + bundle['application/vnd.jupyter.stdout'] = output.text; + } + } else if (nbformat.isError(output)) { + let traceback = output.traceback.join('\n'); + bundle['application/vnd.jupyter.stderr'] = + traceback || `${output.ename}: ${output.evalue}`; + } + return convertBundle(bundle); +} + +/** + * Get the metadata from an output message. + */ +export function getMetadata(output: nbformat.IOutput): JSONObject { + let value: JSONObject = Object.create(null); + if (nbformat.isExecuteResult(output) || nbformat.isDisplayData(output)) { + for (let key in output.metadata) { + value[key] = extract(output.metadata, key); + } + } + return value; +} + +/** + * Get the bundle options given output model options. + */ +export function getBundleOptions( + options: IOutputModelOptions +): MimeModel.IOptions { + let data = getData(options.value); + let metadata = getMetadata(options.value); + let trusted = !!options.trusted; + return { data, metadata, trusted }; +} + +/** + * Extract a value from a JSONObject. + */ +export function extract(value: JSONObject, key: string): {} { + let item = value[key]; + if (JSONExt.isPrimitive(item)) { + return item; + } + return JSON.parse(JSON.stringify(item)); +} + +/** + * Convert a mime bundle to mime data. + */ +function convertBundle(bundle: nbformat.IMimeBundle): JSONObject { + let map: JSONObject = Object.create(null); + for (let mimeType in bundle) { + map[mimeType] = extract(bundle, mimeType); + } + return map; +} + +/** + * The options used to create a notebook output model. + */ +export interface IOutputModelOptions { + /** + * The raw output value. + */ + value: nbformat.IOutput; + + /** + * Whether the output is trusted. The default is false. + */ + trusted?: boolean; +} \ No newline at end of file diff --git a/src/sql/parts/notebook/outputs/common/renderMimeInterfaces.ts b/src/sql/parts/notebook/outputs/common/renderMimeInterfaces.ts new file mode 100644 index 0000000000..3585a0d112 --- /dev/null +++ b/src/sql/parts/notebook/outputs/common/renderMimeInterfaces.ts @@ -0,0 +1,360 @@ + +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ +import { ReadonlyJSONObject } from './jsonext'; + +/** + * A namespace for rendermime associated interfaces. + */ +export namespace IRenderMime { + /** + * A model for mime data. + */ + export interface IMimeModel { + /** + * Whether the data in the model is trusted. + */ + readonly trusted: boolean; + + /** + * The data associated with the model. + */ + readonly data: ReadonlyJSONObject; + + /** + * The metadata associated with the model. + */ + readonly metadata: ReadonlyJSONObject; + + /** + * Set the data associated with the model. + * + * #### Notes + * Calling this function may trigger an asynchronous operation + * that could cause the renderer to be rendered with a new model + * containing the new data. + */ + setData(options: ISetDataOptions): void; + } + + /** + * The options used to update a mime model. + */ + export interface ISetDataOptions { + /** + * The new data object. + */ + data?: ReadonlyJSONObject; + + /** + * The new metadata object. + */ + metadata?: ReadonlyJSONObject; + } + + + /** + * The options used to initialize a document widget factory. + * + * This interface is intended to be used by mime renderer extensions + * to define a document opener that uses its renderer factory. + */ + export interface IDocumentWidgetFactoryOptions { + /** + * The name of the widget to display in dialogs. + */ + readonly name: string; + + /** + * The name of the document model type. + */ + readonly modelName?: string; + + /** + * The primary file type of the widget. + */ + readonly primaryFileType: string; + + /** + * The file types the widget can view. + */ + readonly fileTypes: ReadonlyArray; + + /** + * The file types for which the factory should be the default. + */ + readonly defaultFor?: ReadonlyArray; + + /** + * The file types for which the factory should be the default for rendering, + * if that is different than the default factory (which may be for editing) + * If undefined, then it will fall back on the default file type. + */ + readonly defaultRendered?: ReadonlyArray; + } + + /** + * A file type to associate with the renderer. + */ + export interface IFileType { + /** + * The name of the file type. + */ + readonly name: string; + + /** + * The mime types associated the file type. + */ + readonly mimeTypes: ReadonlyArray; + + /** + * The extensions of the file type (e.g. `".txt"`). Can be a compound + * extension (e.g. `".table.json`). + */ + readonly extensions: ReadonlyArray; + + /** + * An optional display name for the file type. + */ + readonly displayName?: string; + + /** + * An optional pattern for a file name (e.g. `^Dockerfile$`). + */ + readonly pattern?: string; + + /** + * The icon class name for the file type. + */ + readonly iconClass?: string; + + /** + * The icon label for the file type. + */ + readonly iconLabel?: string; + + /** + * The file format for the file type ('text', 'base64', or 'json'). + */ + readonly fileFormat?: string; + } + + /** + * An interface for using a RenderMime.IRenderer for output and read-only documents. + */ + export interface IExtension { + /** + * The ID of the extension. + * + * #### Notes + * The convention for extension IDs in JupyterLab is the full NPM package + * name followed by a colon and a unique string token, e.g. + * `'@jupyterlab/apputils-extension:settings'` or `'foo-extension:bar'`. + */ + readonly id: string; + + /** + * A renderer factory to be registered to render the MIME type. + */ + readonly rendererFactory: IRendererFactory; + + /** + * The rank passed to `RenderMime.addFactory`. If not given, + * defaults to the `defaultRank` of the factory. + */ + readonly rank?: number; + + /** + * The timeout after user activity to re-render the data. + */ + readonly renderTimeout?: number; + + /** + * Preferred data type from the model. Defaults to `string`. + */ + readonly dataType?: 'string' | 'json'; + + /** + * The options used to open a document with the renderer factory. + */ + readonly documentWidgetFactoryOptions?: + | IDocumentWidgetFactoryOptions + | ReadonlyArray; + + /** + * The optional file type associated with the extension. + */ + readonly fileTypes?: ReadonlyArray; + } + + /** + * The interface for a module that exports an extension or extensions as + * the default value. + */ + export interface IExtensionModule { + /** + * The default export. + */ + readonly default: IExtension | ReadonlyArray; + } + + /** + * A widget which displays the contents of a mime model. + */ + export interface IRenderer { + /** + * Render a mime model. + * + * @param model - The mime model to render. + * + * @returns A promise which resolves when rendering is complete. + * + * #### Notes + * This method may be called multiple times during the lifetime + * of the widget to update it if and when new data is available. + */ + renderModel(model: IRenderMime.IMimeModel): Promise; + + /** + * Node to be updated by the renderer + */ + node: HTMLElement; + } + + /** + * The interface for a renderer factory. + */ + export interface IRendererFactory { + /** + * Whether the factory is a "safe" factory. + * + * #### Notes + * A "safe" factory produces renderer widgets which can render + * untrusted model data in a usable way. *All* renderers must + * handle untrusted data safely, but some may simply failover + * with a "Run cell to view output" message. A "safe" renderer + * is an indication that its sanitized output will be useful. + */ + readonly safe: boolean; + + /** + * The mime types handled by this factory. + */ + readonly mimeTypes: ReadonlyArray; + + /** + * The default rank of the factory. If not given, defaults to 100. + */ + readonly defaultRank?: number; + + /** + * Create a renderer which displays the mime data. + * + * @param options - The options used to render the data. + */ + createRenderer(options: IRendererOptions): IRenderer; + } + + /** + * The options used to create a renderer. + */ + export interface IRendererOptions { + /** + * The preferred mimeType to render. + */ + mimeType: string; + + /** + * The html sanitizer. + */ + sanitizer: ISanitizer; + + /** + * An optional url resolver. + */ + resolver: IResolver | null; + + /** + * An optional link handler. + */ + linkHandler: ILinkHandler | null; + + /** + * The LaTeX typesetter. + */ + latexTypesetter: ILatexTypesetter | null; + } + + /** + * An object that handles html sanitization. + */ + export interface ISanitizer { + /** + * Sanitize an HTML string. + */ + sanitize(dirty: string): string; + } + + /** + * An object that handles links on a node. + */ + export interface ILinkHandler { + /** + * Add the link handler to the node. + * + * @param node: the node for which to handle the link. + * + * @param path: the path to open when the link is clicked. + * + * @param id: an optional element id to scroll to when the path is opened. + */ + handleLink(node: HTMLElement, path: string, id?: string): void; + } + + /** + * An object that resolves relative URLs. + */ + export interface IResolver { + /** + * Resolve a relative url to a correct server path. + */ + resolveUrl(url: string): Promise; + + /** + * Get the download url of a given absolute server path. + */ + getDownloadUrl(path: string): Promise; + + /** + * Whether the URL should be handled by the resolver + * or not. + * + * #### Notes + * This is similar to the `isLocal` check in `URLExt`, + * but can also perform additional checks on whether the + * resolver should handle a given URL. + */ + isLocal?: (url: string) => boolean; + } + + /** + * The interface for a LaTeX typesetter. + */ + export interface ILatexTypesetter { + /** + * Typeset a DOM element. + * + * @param element - the DOM element to typeset. The typesetting may + * happen synchronously or asynchronously. + * + * #### Notes + * The application-wide rendermime object has a settable + * `latexTypesetter` property which is used wherever LaTeX + * typesetting is required. Extensions wishing to provide their + * own typesetter may replace that on the global `lab.rendermime`. + */ + typeset(element: HTMLElement): void; + } +} diff --git a/src/sql/parts/notebook/outputs/common/url.ts b/src/sql/parts/notebook/outputs/common/url.ts new file mode 100644 index 0000000000..5386ee1daa --- /dev/null +++ b/src/sql/parts/notebook/outputs/common/url.ts @@ -0,0 +1,184 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { JSONObject } from './jsonext'; +import URI from 'vs/base/common/uri'; + +/** + * The namespace for URL-related functions. + */ +export namespace URLExt { + /** + * Normalize a url. + */ + export function normalize(url: string): string { + return URI.parse(url).toString(); + } + + /** + * Join a sequence of url components and normalizes as in node `path.join`. + * + * @param parts - The url components. + * + * @returns the joined url. + */ + export function join(...parts: string[]): string { + parts = parts || []; + + // Isolate the top element. + const top = parts[0] || ''; + + // Check whether protocol shorthand is being used. + const shorthand = top.indexOf('//') === 0; + + // Parse the top element into a header collection. + const header = top.match(/(\w+)(:)(\/\/)?/); + const protocol = header && header[1]; + const colon = protocol && header[2]; + const slashes = colon && header[3]; + + // Construct the URL prefix. + const prefix = shorthand + ? '//' + : [protocol, colon, slashes].filter(str => str).join(''); + + // Construct the URL body omitting the prefix of the top value. + const body = [top.indexOf(prefix) === 0 ? top.replace(prefix, '') : top] + // Filter out top value if empty. + .filter(str => str) + // Remove leading slashes in all subsequent URL body elements. + .concat(parts.slice(1).map(str => str.replace(/^\//, ''))) + .join('/') + // Replace multiple slashes with one. + .replace(/\/+/g, '/'); + + return prefix + body; + } + + /** + * Encode the components of a multi-segment url. + * + * @param url - The url to encode. + * + * @returns the encoded url. + * + * #### Notes + * Preserves the `'/'` separators. + * Should not include the base url, since all parts are escaped. + */ + export function encodeParts(url: string): string { + return join(...url.split('/').map(encodeURIComponent)); + } + + /** + * Return a serialized object string suitable for a query. + * + * @param object - The source object. + * + * @returns an encoded url query. + * + * #### Notes + * Modified version of [stackoverflow](http://stackoverflow.com/a/30707423). + */ + export function objectToQueryString(value: JSONObject): string { + const keys = Object.keys(value); + + if (!keys.length) { + return ''; + } + + return ( + '?' + + keys + .map(key => { + const content = encodeURIComponent(String(value[key])); + + return key + (content ? '=' + content : ''); + }) + .join('&') + ); + } + + /** + * Return a parsed object that represents the values in a query string. + */ + export function queryStringToObject( + value: string + ): { [key: string]: string } { + return value + .replace(/^\?/, '') + .split('&') + .reduce( + (acc, val) => { + const [key, value] = val.split('='); + + acc[key] = decodeURIComponent(value || ''); + + return acc; + }, + {} as { [key: string]: string } + ); + } + + /** + * Test whether the url is a local url. + * + * #### Notes + * This function returns `false` for any fully qualified url, including + * `data:`, `file:`, and `//` protocol URLs. + */ + export function isLocal(url: string): boolean { + // If if doesn't have a scheme such as file: or http:// it's local + return !!URI.parse(url).scheme; + } + + /** + * The interface for a URL object + */ + export interface IUrl { + /** + * The full URL string that was parsed with both the protocol and host + * components converted to lower-case. + */ + href?: string; + + /** + * Identifies the URL's lower-cased protocol scheme. + */ + protocol?: string; + + /** + * The full lower-cased host portion of the URL, including the port if + * specified. + */ + host?: string; + + /** + * The lower-cased host name portion of the host component without the + * port included. + */ + hostname?: string; + + /** + * The numeric port portion of the host component. + */ + port?: string; + + /** + * The entire path section of the URL. + */ + pathname?: string; + + /** + * The "fragment" portion of the URL including the leading ASCII hash + * `(#)` character + */ + hash?: string; + + /** + * The search element, including leading question mark (`'?'`), if any, + * of the URL. + */ + search?: string; + } +} diff --git a/src/sql/parts/notebook/outputs/factories.ts b/src/sql/parts/notebook/outputs/factories.ts new file mode 100644 index 0000000000..a34d1cffa1 --- /dev/null +++ b/src/sql/parts/notebook/outputs/factories.ts @@ -0,0 +1,94 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +import * as widgets from './widgets'; +import { IRenderMime } from './common/renderMimeInterfaces'; + +/** + * A mime renderer factory for raw html. + */ +export const htmlRendererFactory: IRenderMime.IRendererFactory = { + safe: true, + mimeTypes: ['text/html'], + defaultRank: 50, + createRenderer: options => new widgets.RenderedHTML(options) +}; + +/** + * A mime renderer factory for images. + */ +export const imageRendererFactory: IRenderMime.IRendererFactory = { + safe: true, + mimeTypes: ['image/bmp', 'image/png', 'image/jpeg', 'image/gif'], + defaultRank: 90, + createRenderer: options => new widgets.RenderedImage(options) +}; + +// /** +// * A mime renderer factory for LaTeX. +// */ +// export const latexRendererFactory: IRenderMime.IRendererFactory = { +// safe: true, +// mimeTypes: ['text/latex'], +// defaultRank: 70, +// createRenderer: options => new widgets.RenderedLatex(options) +// }; + +// /** +// * A mime renderer factory for Markdown. +// */ +// export const markdownRendererFactory: IRenderMime.IRendererFactory = { +// safe: true, +// mimeTypes: ['text/markdown'], +// defaultRank: 60, +// createRenderer: options => new widgets.RenderedMarkdown(options) +// }; + +/** + * A mime renderer factory for svg. + */ +export const svgRendererFactory: IRenderMime.IRendererFactory = { + safe: false, + mimeTypes: ['image/svg+xml'], + defaultRank: 80, + createRenderer: options => new widgets.RenderedSVG(options) +}; + +/** + * A mime renderer factory for plain and jupyter console text data. + */ +export const textRendererFactory: IRenderMime.IRendererFactory = { + safe: true, + mimeTypes: [ + 'text/plain', + 'application/vnd.jupyter.stdout', + 'application/vnd.jupyter.stderr' + ], + defaultRank: 120, + createRenderer: options => new widgets.RenderedText(options) +}; + +/** + * A placeholder factory for deprecated rendered JavaScript. + */ +export const javaScriptRendererFactory: IRenderMime.IRendererFactory = { + safe: false, + mimeTypes: ['text/javascript', 'application/javascript'], + defaultRank: 110, + createRenderer: options => new widgets.RenderedJavaScript(options) +}; + +/** + * The standard factories provided by the rendermime package. + */ +export const standardRendererFactories: ReadonlyArray = [ + htmlRendererFactory, + // markdownRendererFactory, + // latexRendererFactory, + svgRendererFactory, + imageRendererFactory, + javaScriptRendererFactory, + textRendererFactory +]; diff --git a/src/sql/parts/notebook/outputs/registry.ts b/src/sql/parts/notebook/outputs/registry.ts new file mode 100644 index 0000000000..b35004e696 --- /dev/null +++ b/src/sql/parts/notebook/outputs/registry.ts @@ -0,0 +1,352 @@ + +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ +import { IRenderMime } from './common/renderMimeInterfaces'; +import { MimeModel } from './common/mimemodel'; +import { ReadonlyJSONObject } from './common/jsonext'; +import { defaultSanitizer } from './sanitizer'; + +/** + * An object which manages mime renderer factories. + * + * This object is used to render mime models using registered mime + * renderers, selecting the preferred mime renderer to render the + * model into a widget. + * + * #### Notes + * This class is not intended to be subclassed. + */ +export class RenderMimeRegistry { + /** + * Construct a new rendermime. + * + * @param options - The options for initializing the instance. + */ + constructor(options: RenderMimeRegistry.IOptions = {}) { + // Parse the options. + this.resolver = options.resolver || null; + this.linkHandler = options.linkHandler || null; + this.latexTypesetter = options.latexTypesetter || null; + this.sanitizer = options.sanitizer || defaultSanitizer; + + // Add the initial factories. + if (options.initialFactories) { + for (let factory of options.initialFactories) { + this.addFactory(factory); + } + } + } + + /** + * The sanitizer used by the rendermime instance. + */ + readonly sanitizer: IRenderMime.ISanitizer; + + /** + * The object used to resolve relative urls for the rendermime instance. + */ + readonly resolver: IRenderMime.IResolver | null; + + /** + * The object used to handle path opening links. + */ + readonly linkHandler: IRenderMime.ILinkHandler | null; + + /** + * The LaTeX typesetter for the rendermime. + */ + readonly latexTypesetter: IRenderMime.ILatexTypesetter | null; + + /** + * The ordered list of mimeTypes. + */ + get mimeTypes(): ReadonlyArray { + return this._types || (this._types = Private.sortedTypes(this._ranks)); + } + + /** + * Find the preferred mime type for a mime bundle. + * + * @param bundle - The bundle of mime data. + * + * @param safe - How to consider safe/unsafe factories. If 'ensure', + * it will only consider safe factories. If 'any', any factory will be + * considered. If 'prefer', unsafe factories will be considered, but + * only after the safe options have been exhausted. + * + * @returns The preferred mime type from the available factories, + * or `undefined` if the mime type cannot be rendered. + */ + preferredMimeType( + bundle: ReadonlyJSONObject, + safe: 'ensure' | 'prefer' | 'any' = 'ensure' + ): string | undefined { + // Try to find a safe factory first, if preferred. + if (safe === 'ensure' || safe === 'prefer') { + for (let mt of this.mimeTypes) { + if (mt in bundle && this._factories[mt].safe) { + return mt; + } + } + } + + if (safe !== 'ensure') { + // Otherwise, search for the best factory among all factories. + for (let mt of this.mimeTypes) { + if (mt in bundle) { + return mt; + } + } + } + + // Otherwise, no matching mime type exists. + return undefined; + } + + /** + * Create a renderer for a mime type. + * + * @param mimeType - The mime type of interest. + * + * @returns A new renderer for the given mime type. + * + * @throws An error if no factory exists for the mime type. + */ + createRenderer(mimeType: string): IRenderMime.IRenderer { + // Throw an error if no factory exists for the mime type. + if (!(mimeType in this._factories)) { + throw new Error(`No factory for mime type: '${mimeType}'`); + } + + // Invoke the best factory for the given mime type. + return this._factories[mimeType].createRenderer({ + mimeType, + resolver: this.resolver, + sanitizer: this.sanitizer, + linkHandler: this.linkHandler, + latexTypesetter: this.latexTypesetter + }); + } + + /** + * Create a new mime model. This is a convenience method. + * + * @options - The options used to create the model. + * + * @returns A new mime model. + */ + createModel(options: MimeModel.IOptions = {}): MimeModel { + return new MimeModel(options); + } + + /** + * Create a clone of this rendermime instance. + * + * @param options - The options for configuring the clone. + * + * @returns A new independent clone of the rendermime. + */ + clone(options: RenderMimeRegistry.ICloneOptions = {}): RenderMimeRegistry { + // Create the clone. + let clone = new RenderMimeRegistry({ + resolver: options.resolver || this.resolver || undefined, + sanitizer: options.sanitizer || this.sanitizer || undefined, + linkHandler: options.linkHandler || this.linkHandler || undefined, + latexTypesetter: options.latexTypesetter || this.latexTypesetter + }); + + // Clone the internal state. + clone._factories = { ...this._factories }; + clone._ranks = { ...this._ranks }; + clone._id = this._id; + + // Return the cloned object. + return clone; + } + + /** + * Get the renderer factory registered for a mime type. + * + * @param mimeType - The mime type of interest. + * + * @returns The factory for the mime type, or `undefined`. + */ + getFactory(mimeType: string): IRenderMime.IRendererFactory | undefined { + return this._factories[mimeType]; + } + + /** + * Add a renderer factory to the rendermime. + * + * @param factory - The renderer factory of interest. + * + * @param rank - The rank of the renderer. A lower rank indicates + * a higher priority for rendering. If not given, the rank will + * defer to the `defaultRank` of the factory. If no `defaultRank` + * is given, it will default to 100. + * + * #### Notes + * The renderer will replace an existing renderer for the given + * mimeType. + */ + addFactory(factory: IRenderMime.IRendererFactory, rank?: number): void { + if (rank === undefined) { + rank = factory.defaultRank; + if (rank === undefined) { + rank = 100; + } + } + for (let mt of factory.mimeTypes) { + this._factories[mt] = factory; + this._ranks[mt] = { rank, id: this._id++ }; + } + this._types = null; + } + + /** + * Remove a mime type. + * + * @param mimeType - The mime type of interest. + */ + removeMimeType(mimeType: string): void { + delete this._factories[mimeType]; + delete this._ranks[mimeType]; + this._types = null; + } + + /** + * Get the rank for a given mime type. + * + * @param mimeType - The mime type of interest. + * + * @returns The rank of the mime type or undefined. + */ + getRank(mimeType: string): number | undefined { + let rank = this._ranks[mimeType]; + return rank && rank.rank; + } + + /** + * Set the rank of a given mime type. + * + * @param mimeType - The mime type of interest. + * + * @param rank - The new rank to assign. + * + * #### Notes + * This is a no-op if the mime type is not registered. + */ + setRank(mimeType: string, rank: number): void { + if (!this._ranks[mimeType]) { + return; + } + let id = this._id++; + this._ranks[mimeType] = { rank, id }; + this._types = null; + } + + private _id = 0; + private _ranks: Private.RankMap = {}; + private _types: string[] | null = null; + private _factories: Private.FactoryMap = {}; +} + +/** + * The namespace for `RenderMimeRegistry` class statics. + */ +export namespace RenderMimeRegistry { + /** + * The options used to initialize a rendermime instance. + */ + export interface IOptions { + /** + * Initial factories to add to the rendermime instance. + */ + initialFactories?: ReadonlyArray; + + /** + * The sanitizer used to sanitize untrusted html inputs. + * + * If not given, a default sanitizer will be used. + */ + sanitizer?: IRenderMime.ISanitizer; + + /** + * The initial resolver object. + * + * The default is `null`. + */ + resolver?: IRenderMime.IResolver; + + /** + * An optional path handler. + */ + linkHandler?: IRenderMime.ILinkHandler; + + /** + * An optional LaTeX typesetter. + */ + latexTypesetter?: IRenderMime.ILatexTypesetter; + } + + /** + * The options used to clone a rendermime instance. + */ + export interface ICloneOptions { + /** + * The new sanitizer used to sanitize untrusted html inputs. + */ + sanitizer?: IRenderMime.ISanitizer; + + /** + * The new resolver object. + */ + resolver?: IRenderMime.IResolver; + + /** + * The new path handler. + */ + linkHandler?: IRenderMime.ILinkHandler; + + /** + * The new LaTeX typesetter. + */ + latexTypesetter?: IRenderMime.ILatexTypesetter; + } +} + +/** + * The namespace for the module implementation details. + */ +namespace Private { + /** + * A type alias for a mime rank and tie-breaking id. + */ + export type RankPair = { readonly id: number; readonly rank: number }; + + /** + * A type alias for a mapping of mime type -> rank pair. + */ + export type RankMap = { [key: string]: RankPair }; + + /** + * A type alias for a mapping of mime type -> ordered factories. + */ + export type FactoryMap = { [key: string]: IRenderMime.IRendererFactory }; + + /** + * Get the mime types in the map, ordered by rank. + */ + export function sortedTypes(map: RankMap): string[] { + return Object.keys(map).sort((a, b) => { + let p1 = map[a]; + let p2 = map[b]; + if (p1.rank !== p2.rank) { + return p1.rank - p2.rank; + } + return p1.id - p2.id; + }); + } +} diff --git a/src/sql/parts/notebook/outputs/renderers.ts b/src/sql/parts/notebook/outputs/renderers.ts new file mode 100644 index 0000000000..498ebe0435 --- /dev/null +++ b/src/sql/parts/notebook/outputs/renderers.ts @@ -0,0 +1,629 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +import { default as AnsiUp } from 'ansi_up'; +import { IRenderMime } from './common/renderMimeInterfaces'; +import { URLExt } from './common/url'; +import URI from 'vs/base/common/uri'; + + +/** + * Render HTML into a host node. + * + * @params options - The options for rendering. + * + * @returns A promise which resolves when rendering is complete. + */ +export function renderHTML(options: renderHTML.IOptions): Promise { + // Unpack the options. + let { + host, + source, + trusted, + sanitizer, + resolver, + linkHandler, + shouldTypeset, + latexTypesetter + } = options; + + let originalSource = source; + + // Bail early if the source is empty. + if (!source) { + host.textContent = ''; + return Promise.resolve(undefined); + } + + // Sanitize the source if it is not trusted. This removes all + // `