diff --git a/src/sql/workbench/parts/notebook/cellViews/textCell.component.ts b/src/sql/workbench/parts/notebook/cellViews/textCell.component.ts index c34b0999bd..df67de74f1 100644 --- a/src/sql/workbench/parts/notebook/cellViews/textCell.component.ts +++ b/src/sql/workbench/parts/notebook/cellViews/textCell.component.ts @@ -24,11 +24,17 @@ import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces' import { ISanitizer, defaultSanitizer } from 'sql/workbench/parts/notebook/outputs/sanitizer'; import { NotebookModel } from 'sql/workbench/parts/notebook/models/notebookModel'; import { CellToggleMoreActions } from 'sql/workbench/parts/notebook/cellToggleMoreActions'; -import { convertVscodeResourceToFileInSubDirectories } from 'sql/workbench/parts/notebook/notebookUtils'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { toDisposable } from 'vs/base/common/lifecycle'; +import { IMarkdownRenderResult } from 'vs/editor/contrib/markdown/markdownRenderer'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { NotebookMarkdownRenderer } from 'sql/workbench/parts/notebook/outputs/notebookMarkdown'; +import { convertVscodeResourceToFileInSubDirectories, useInProcMarkdown } from 'sql/workbench/parts/notebook/notebookUtils'; export const TEXT_SELECTOR: string = 'text-cell-component'; const USER_SELECT_CLASS = 'actionselect'; + @Component({ selector: TEXT_SELECTOR, templateUrl: decodeURI(require.toUrl('./textCell.component.html')) @@ -73,17 +79,28 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges { public readonly onDidClickLink = this._onDidClickLink.event; private _cellToggleMoreActions: CellToggleMoreActions; private _hover: boolean; + private markdownRenderer: NotebookMarkdownRenderer; + private markdownResult: IMarkdownRenderResult; constructor( @Inject(forwardRef(() => CommonServiceInterface)) private _bootstrapService: CommonServiceInterface, @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, @Inject(IInstantiationService) private _instantiationService: IInstantiationService, @Inject(IWorkbenchThemeService) private themeService: IWorkbenchThemeService, - @Inject(ICommandService) private _commandService: ICommandService + @Inject(ICommandService) private _commandService: ICommandService, + @Inject(IOpenerService) private readonly openerService: IOpenerService, + @Inject(IConfigurationService) private configurationService: IConfigurationService, + ) { super(); this.isEditMode = true; this._cellToggleMoreActions = this._instantiationService.createInstance(CellToggleMoreActions); + this.markdownRenderer = this._instantiationService.createInstance(NotebookMarkdownRenderer); + this._register(toDisposable(() => { + if (this.markdownResult) { + this.markdownResult.dispose(); + } + })); } //Gets sanitizer from ISanitizer interface @@ -142,7 +159,7 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges { * 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() { + private updatePreview(): void { let trustedChanged = this.cellModel && this._lastTrustedMode !== this.cellModel.trustedMode; let contentChanged = this._content !== this.cellModel.source || this.cellModel.source.length === 0; if (trustedChanged || contentChanged) { @@ -153,13 +170,24 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges { this._content = this.cellModel.source; } - this._commandService.executeCommand('notebook.showPreview', this.cellModel.notebookModel.notebookUri, this._content).then((htmlcontent) => { - htmlcontent = convertVscodeResourceToFileInSubDirectories(htmlcontent, this.cellModel); - htmlcontent = this.sanitizeContent(htmlcontent); - let outputElement = this.output.nativeElement; - outputElement.innerHTML = htmlcontent; + if (useInProcMarkdown(this.configurationService)) { + this.markdownRenderer.setNotebookURI(this.cellModel.notebookModel.notebookUri); + this.markdownResult = this.markdownRenderer.render({ + isTrusted: this.cellModel.trustedMode, + value: this._content + }); this.setLoading(false); - }); + let outputElement = this.output.nativeElement; + outputElement.innerHTML = this.markdownResult.element.innerHTML; + } else { + this._commandService.executeCommand('notebook.showPreview', this.cellModel.notebookModel.notebookUri, this._content).then((htmlcontent) => { + htmlcontent = convertVscodeResourceToFileInSubDirectories(htmlcontent, this.cellModel); + htmlcontent = this.sanitizeContent(htmlcontent); + let outputElement = this.output.nativeElement; + outputElement.innerHTML = htmlcontent; + this.setLoading(false); + }); + } } } diff --git a/src/sql/workbench/parts/notebook/models/modelInterfaces.ts b/src/sql/workbench/parts/notebook/models/modelInterfaces.ts index 8d26cc724e..216acac174 100644 --- a/src/sql/workbench/parts/notebook/models/modelInterfaces.ts +++ b/src/sql/workbench/parts/notebook/models/modelInterfaces.ts @@ -405,6 +405,7 @@ export interface INotebookModel { serializationStateChanged(changeType: NotebookChangeType): void; + standardKernels: IStandardKernelWithProvider[]; } export interface NotebookContentChange { @@ -502,7 +503,6 @@ export interface INotebookModelOptions { contentManager: IContentManager; notebookManagers: INotebookManager[]; providerId: string; - standardKernels: IStandardKernelWithProvider[]; defaultKernel: nb.IKernelSpec; cellMagicMapper: ICellMagicMapper; diff --git a/src/sql/workbench/parts/notebook/models/notebookModel.ts b/src/sql/workbench/parts/notebook/models/notebookModel.ts index 391397be68..642e211e38 100644 --- a/src/sql/workbench/parts/notebook/models/notebookModel.ts +++ b/src/sql/workbench/parts/notebook/models/notebookModel.ts @@ -75,6 +75,7 @@ export class NotebookModel extends Disposable implements INotebookModel { private _clientSessionListeners: IDisposable[] = []; private _connectionUrisToDispose: string[] = []; private _textCellsLoading: number = 0; + private _standardKernels: notebookUtils.IStandardKernelWithProvider[]; public requestConnectionHandler: () => Promise; @@ -92,14 +93,6 @@ export class NotebookModel extends Disposable implements INotebookModel { this._trustedMode = false; this._providerId = _notebookOptions.providerId; this._onProviderIdChanged.fire(this._providerId); - this._notebookOptions.standardKernels.forEach(kernel => { - let displayName = kernel.displayName; - if (!displayName) { - displayName = kernel.name; - } - this._kernelDisplayNameToConnectionProviderIds.set(displayName, kernel.connectionProviderIds); - this._kernelDisplayNameToNotebookProviderIds.set(displayName, kernel.notebookProvider); - }); if (this._notebookOptions.layoutChanged) { this._notebookOptions.layoutChanged(() => this._layoutChanged.fire()); } @@ -274,6 +267,15 @@ export class NotebookModel extends Disposable implements INotebookModel { return this._onValidConnectionSelected.event; } + public get standardKernels(): notebookUtils.IStandardKernelWithProvider[] { + return this._standardKernels; + } + + public set standardKernels(kernels) { + this._standardKernels = kernels; + this.setKernelDisplayNameMapsWithStandardKernels(); + } + public getApplicableConnectionProviderIds(kernelDisplayName: string): string[] { let ids = []; if (kernelDisplayName) { @@ -282,7 +284,7 @@ export class NotebookModel extends Disposable implements INotebookModel { return !ids ? [] : ids; } - public async requestModelLoad(isTrusted: boolean = false): Promise { + public async loadContents(isTrusted: boolean = false): Promise { try { this._trustedMode = isTrusted; let contents = null; @@ -294,7 +296,7 @@ export class NotebookModel extends Disposable implements INotebookModel { // if cells already exist, create them with language info (if it is saved) this._cells = []; if (contents) { - this._defaultLanguageInfo = this.getDefaultLanguageInfo(contents); + this._defaultLanguageInfo = contents && contents.metadata && contents.metadata.language_info; this._savedKernelInfo = this.getSavedKernelInfo(contents); if (contents.cells && contents.cells.length > 0) { this._cells = contents.cells.map(c => { @@ -304,6 +306,13 @@ export class NotebookModel extends Disposable implements INotebookModel { }); } } + } catch (error) { + this._inErrorState = true; + throw error; + } + } + public async requestModelLoad(): Promise { + try { this.setDefaultKernelAndProviderId(); this.trySetLanguageFromLangInfo(); } catch (error) { @@ -421,8 +430,8 @@ export class NotebookModel extends Disposable implements INotebookModel { } public async startSession(manager: INotebookManager, displayName?: string, setErrorStateOnFail?: boolean): Promise { - if (displayName) { - let standardKernel = this._notebookOptions.standardKernels.find(kernel => kernel.displayName === displayName); + if (displayName && this._standardKernels) { + let standardKernel = this._standardKernels.find(kernel => kernel.displayName === displayName); this._defaultKernel = displayName ? { name: standardKernel.name, display_name: standardKernel.displayName } : this._defaultKernel; } if (this._defaultKernel) { @@ -506,30 +515,35 @@ export class NotebookModel extends Disposable implements INotebookModel { this._defaultKernel = notebookConstants.sqlKernelSpec; this._providerId = SQL_NOTEBOOK_PROVIDER; } - // update default language - this._defaultLanguageInfo = { - name: this._providerId === SQL_NOTEBOOK_PROVIDER ? 'sql' : 'python', - version: '' - }; + if (!this._defaultLanguageInfo || this._defaultLanguageInfo.name) { + // update default language + this._defaultLanguageInfo = { + name: this._providerId === SQL_NOTEBOOK_PROVIDER ? 'sql' : 'python', + version: '' + }; + } } private isValidConnection(profile: IConnectionProfile | connection.Connection) { - let standardKernels = this._notebookOptions.standardKernels.find(kernel => this._defaultKernel && kernel.displayName === this._defaultKernel.display_name); - let connectionProviderIds = standardKernels ? standardKernels.connectionProviderIds : undefined; - return profile && connectionProviderIds && connectionProviderIds.find(provider => provider === profile.providerName) !== undefined; + if (this._standardKernels) { + let standardKernels = this._standardKernels.find(kernel => this._defaultKernel && kernel.displayName === this._defaultKernel.display_name); + let connectionProviderIds = standardKernels ? standardKernels.connectionProviderIds : undefined; + return profile && connectionProviderIds && connectionProviderIds.find(provider => provider === profile.providerName) !== undefined; + } + return false; } public getStandardKernelFromName(name: string): notebookUtils.IStandardKernelWithProvider { - if (name) { - let kernel = this._notebookOptions.standardKernels.find(kernel => kernel.name.toLowerCase() === name.toLowerCase()); + if (name && this._standardKernels) { + let kernel = this._standardKernels.find(kernel => kernel.name.toLowerCase() === name.toLowerCase()); return kernel; } return undefined; } public getStandardKernelFromDisplayName(displayName: string): notebookUtils.IStandardKernelWithProvider { - if (displayName) { - let kernel = this._notebookOptions.standardKernels.find(kernel => kernel.displayName.toLowerCase() === displayName.toLowerCase()); + if (displayName && this._standardKernels) { + let kernel = this._standardKernels.find(kernel => kernel.displayName.toLowerCase() === displayName.toLowerCase()); return kernel; } return undefined; @@ -713,15 +727,6 @@ export class NotebookModel extends Disposable implements INotebookModel { } } - // Get default language if saved in notebook file - // Otherwise, default to python - private getDefaultLanguageInfo(notebook: nb.INotebookContents): nb.ILanguageInfo { - return (notebook && notebook.metadata && notebook.metadata.language_info) ? notebook.metadata.language_info : { - name: this._providerId === SQL_NOTEBOOK_PROVIDER ? 'sql' : 'python', - version: '', - mimetype: this._providerId === SQL_NOTEBOOK_PROVIDER ? 'x-sql' : 'x-python' - }; - } // Get default kernel info if saved in notebook file private getSavedKernelInfo(notebook: nb.INotebookContents): nb.IKernelInfo { @@ -746,11 +751,12 @@ export class NotebookModel extends Disposable implements INotebookModel { if (this._savedKernelInfo.display_name !== displayName) { this._savedKernelInfo.display_name = displayName; } - - let standardKernel = this._notebookOptions.standardKernels.find(kernel => kernel.displayName === displayName || displayName.startsWith(kernel.displayName)); - if (standardKernel && this._savedKernelInfo.name && this._savedKernelInfo.name !== standardKernel.name) { - this._savedKernelInfo.name = standardKernel.name; - this._savedKernelInfo.display_name = standardKernel.displayName; + if (this._standardKernels) { + let standardKernel = this._standardKernels.find(kernel => kernel.displayName === displayName || displayName.startsWith(kernel.displayName)); + if (standardKernel && this._savedKernelInfo.name && this._savedKernelInfo.name !== standardKernel.name) { + this._savedKernelInfo.name = standardKernel.name; + this._savedKernelInfo.display_name = standardKernel.displayName; + } } } } @@ -951,6 +957,23 @@ export class NotebookModel extends Disposable implements INotebookModel { })); } + /** + * Set maps with values to have a way to determine the connection + * provider and notebook provider ids from a kernel display name + */ + private setKernelDisplayNameMapsWithStandardKernels(): void { + if (this._standardKernels) { + this._standardKernels.forEach(kernel => { + let displayName = kernel.displayName; + if (!displayName) { + displayName = kernel.name; + } + this._kernelDisplayNameToConnectionProviderIds.set(displayName, kernel.connectionProviderIds); + this._kernelDisplayNameToNotebookProviderIds.set(displayName, kernel.notebookProvider); + }); + } + } + /** * Serialize the model to JSON. */ diff --git a/src/sql/workbench/parts/notebook/notebook.component.ts b/src/sql/workbench/parts/notebook/notebook.component.ts index 83add32747..0037b029f4 100644 --- a/src/sql/workbench/parts/notebook/notebook.component.ts +++ b/src/sql/workbench/parts/notebook/notebook.component.ts @@ -69,7 +69,6 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe protected isLoading: boolean; private notebookManagers: INotebookManager[] = []; private _modelReadyDeferred = new Deferred(); - private _modelRegisteredDeferred = new Deferred(); private profile: IConnectionProfile; private _trustedAction: TrustedAction; private _runAllCellsAction: RunAllCellsAction; @@ -146,10 +145,6 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe return this._model && this._model.activeCell ? this._model.activeCell.id : ''; } - public get modelRegistered(): Promise { - return this._modelRegisteredDeferred.promise; - } - public get cells(): ICellModel[] { return this._model ? this._model.cells : []; } @@ -222,6 +217,7 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe private async doLoad(): Promise { try { + await this.createModelAndLoadContents(); await this.setNotebookManager(); await this.loadModel(); this._modelReadyDeferred.resolve(this._model); @@ -268,7 +264,17 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe } private async loadModel(): Promise { + // Wait on provider information to be available before loading kernel and other information await this.awaitNonDefaultProvider(); + await this._model.requestModelLoad(); + this.detectChanges(); + await this._model.startSession(this._model.notebookManager, undefined, true); + this.setContextKeyServiceWithProviderId(this._model.providerId); + this.fillInActionsForCurrentContext(); + this.detectChanges(); + } + + private async createModelAndLoadContents(): Promise { let model = new NotebookModel({ factory: this.modelFactory, notebookUri: this._notebookParams.notebookUri, @@ -276,27 +282,22 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe notificationService: this.notificationService, notebookManagers: this.notebookManagers, contentManager: this._notebookParams.input.contentManager, - standardKernels: this._notebookParams.input.standardKernels, cellMagicMapper: new CellMagicMapper(this.notebookService.languageMagics), - providerId: 'sql', // this is tricky; really should also depend on the connection profile + providerId: 'sql', defaultKernel: this._notebookParams.input.defaultKernel, layoutChanged: this._notebookParams.input.layoutChanged, capabilitiesService: this.capabilitiesService, editorLoadedTimestamp: this._notebookParams.input.editorOpenedTimestamp }, this.profile, this.logService, this.notificationService, this.telemetryService); - model.onError((errInfo: INotification) => this.handleModelError(errInfo)); let trusted = await this.notebookService.isNotebookTrustCached(this._notebookParams.notebookUri, this.isDirty()); - await model.requestModelLoad(trusted); + model.onError((errInfo: INotification) => this.handleModelError(errInfo)); model.contentChanged((change) => this.handleContentChanged(change)); model.onProviderIdChange((provider) => this.handleProviderIdChanged(provider)); model.kernelChanged((kernelArgs) => this.handleKernelChanged(kernelArgs)); this._model = this._register(model); - this.updateToolbarComponents(this._model.trustedMode); - this._modelRegisteredDeferred.resolve(this._model); + await this._model.loadContents(trusted); this.setLoading(false); - await model.startSession(this.model.notebookManager, undefined, true); - this.setContextKeyServiceWithProviderId(model.providerId); - this.fillInActionsForCurrentContext(); + this.updateToolbarComponents(this._model.trustedMode); this.detectChanges(); } @@ -311,6 +312,7 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe private async awaitNonDefaultProvider(): Promise { // Wait on registration for now. Long-term would be good to cache and refresh await this.notebookService.registrationComplete; + this.model.standardKernels = this._notebookParams.input.standardKernels; // Refresh the provider if we had been using default let providerInfo = await this._notebookParams.providerInfo; diff --git a/src/sql/workbench/parts/notebook/notebook.contribution.ts b/src/sql/workbench/parts/notebook/notebook.contribution.ts index 2aad27327e..437ddd4942 100644 --- a/src/sql/workbench/parts/notebook/notebook.contribution.ts +++ b/src/sql/workbench/parts/notebook/notebook.contribution.ts @@ -13,6 +13,9 @@ import { NotebookEditor } from 'sql/workbench/parts/notebook/notebookEditor'; import { NewNotebookAction } from 'sql/workbench/parts/notebook/notebookActions'; import { KeyMod } from 'vs/editor/common/standalone/standaloneBase'; import { KeyCode } from 'vs/base/common/keyCodes'; +import { IConfigurationRegistry, Extensions as ConfigExtensions } from 'vs/platform/configuration/common/configurationRegistry'; +import { localize } from 'vs/nls'; +import product from 'vs/platform/product/node/product'; import { registerComponentType } from 'sql/workbench/parts/notebook/outputs/mimeRegistry'; import { MimeRendererComponent as MimeRendererComponent } from 'sql/workbench/parts/notebook/outputs/mimeRenderer.component'; import { MarkdownOutputComponent } from 'sql/workbench/parts/notebook/outputs/markdownOutput.component'; @@ -40,6 +43,19 @@ actionRegistry.registerWorkbenchAction( ), NewNotebookAction.LABEL ); +const configurationRegistry = Registry.as(ConfigExtensions.Configuration); +configurationRegistry.registerConfiguration({ + 'id': 'notebook', + 'title': 'Notebook', + 'type': 'object', + 'properties': { + 'notebook.useInProcMarkdown': { + 'type': 'boolean', + 'default': product.quality === 'stable' ? false : true, + 'description': localize('notebook.inProcMarkdown', 'Use in-process markdown viewer to render text cells more quickly (Experimental).') + } + } +}); /* *************** Output components *************** */ // Note: most existing types use the same component to render. In order to diff --git a/src/sql/workbench/parts/notebook/notebookUtils.ts b/src/sql/workbench/parts/notebook/notebookUtils.ts index c66843c4c9..a4fe2933fa 100644 --- a/src/sql/workbench/parts/notebook/notebookUtils.ts +++ b/src/sql/workbench/parts/notebook/notebookUtils.ts @@ -12,6 +12,7 @@ import { DEFAULT_NOTEBOOK_PROVIDER, DEFAULT_NOTEBOOK_FILETYPE, INotebookService import { ConnectionProfile } from 'sql/platform/connection/common/connectionProfile'; import { IOutputChannel } from 'vs/workbench/contrib/output/common/output'; import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; /** @@ -142,3 +143,7 @@ export function convertVscodeResourceToFileInSubDirectories(htmlContent: string, } return htmlContent; } + +export function useInProcMarkdown(configurationService: IConfigurationService): boolean { + return configurationService.getValue('notebook.useInProcMarkdown'); +} diff --git a/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts b/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts index 9fa41adc49..a72a67f2ad 100644 --- a/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts +++ b/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts @@ -15,7 +15,10 @@ import { IMimeComponent } from 'sql/workbench/parts/notebook/outputs/mimeRegistr import { INotebookService } from 'sql/workbench/services/notebook/common/notebookService'; import { MimeModel } from 'sql/workbench/parts/notebook/outputs/common/mimemodel'; import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces'; -import { convertVscodeResourceToFileInSubDirectories } from 'sql/workbench/parts/notebook/notebookUtils'; +import { convertVscodeResourceToFileInSubDirectories, useInProcMarkdown } from 'sql/workbench/parts/notebook/notebookUtils'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { NotebookMarkdownRenderer } from 'sql/workbench/parts/notebook/outputs/notebookMarkdown'; @Component({ selector: MarkdownOutputComponent.SELECTOR, @@ -33,15 +36,19 @@ export class MarkdownOutputComponent extends AngularDisposable implements IMimeC private _initialized: boolean = false; public loading: boolean = false; private _cellModel: ICellModel; + private _markdownRenderer: NotebookMarkdownRenderer; constructor( @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, @Inject(ICommandService) private _commandService: ICommandService, - @Inject(INotebookService) private _notebookService: INotebookService + @Inject(INotebookService) private _notebookService: INotebookService, + @Inject(IConfigurationService) private _configurationService: IConfigurationService, + @Inject(IInstantiationService) private _instantiationService: IInstantiationService ) { super(); this._sanitizer = this._notebookService.getMimeRegistry().sanitizer; + this._markdownRenderer = this._instantiationService.createInstance(NotebookMarkdownRenderer); } @Input() set bundleOptions(value: MimeModel.IOptions) { @@ -95,16 +102,26 @@ export class MarkdownOutputComponent extends AngularDisposable implements IMimeC if (trustedChanged || !this._initialized) { this._lastTrustedMode = this.isTrusted; let content = this._bundleOptions.data['text/markdown']; - if (!content) { - - } else { - this._commandService.executeCommand('notebook.showPreview', this._cellModel.notebookModel.notebookUri, content).then((htmlcontent) => { - htmlcontent = convertVscodeResourceToFileInSubDirectories(htmlcontent, this._cellModel); - htmlcontent = this.sanitizeContent(htmlcontent); - let outputElement = this.output.nativeElement; - outputElement.innerHTML = htmlcontent; - this.setLoading(false); + if (useInProcMarkdown(this._configurationService)) { + this._markdownRenderer.setNotebookURI(this.cellModel.notebookModel.notebookUri); + let markdownResult = this._markdownRenderer.render({ + isTrusted: this.cellModel.trustedMode, + value: content.toString() }); + let outputElement = this.output.nativeElement; + outputElement.innerHTML = markdownResult.element.innerHTML; + } else { + if (!content) { + + } else { + this._commandService.executeCommand('notebook.showPreview', this._cellModel.notebookModel.notebookUri, content).then((htmlcontent) => { + htmlcontent = convertVscodeResourceToFileInSubDirectories(htmlcontent, this._cellModel); + htmlcontent = this.sanitizeContent(htmlcontent); + let outputElement = this.output.nativeElement; + outputElement.innerHTML = htmlcontent; + this.setLoading(false); + }); + } } this._initialized = true; } diff --git a/src/sql/workbench/parts/notebook/outputs/notebookMarkdown.ts b/src/sql/workbench/parts/notebook/outputs/notebookMarkdown.ts new file mode 100644 index 0000000000..8edce23776 --- /dev/null +++ b/src/sql/workbench/parts/notebook/outputs/notebookMarkdown.ts @@ -0,0 +1,240 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; + +import { URI } from 'vs/base/common/uri'; + +import { dispose } from 'vs/base/common/lifecycle'; +import { RenderOptions } from 'vs/base/browser/htmlContentRenderer'; +import { IMarkdownString, removeMarkdownEscapes } from 'vs/base/common/htmlContent'; +import { IMarkdownRenderResult } from 'vs/editor/contrib/markdown/markdownRenderer'; +import marked = require('vs/base/common/marked/marked'); +import { defaultGenerator } from 'vs/base/common/idGenerator'; +import { revive } from 'vs/base/common/marshalling'; +import * as fs from 'fs'; + +// Based off of HtmlContentRenderer +export class NotebookMarkdownRenderer { + private _notebookURI: URI; + private _baseUrls: string[] = []; + + constructor() { + } + + render(markdown: IMarkdownString): IMarkdownRenderResult { + const element: HTMLElement = markdown ? this.renderMarkdown(markdown, undefined) : document.createElement('span'); + return { + element, + dispose: () => dispose() + }; + } + + createElement(options: RenderOptions): HTMLElement { + const tagName = options.inline ? 'span' : 'div'; + const element = document.createElement(tagName); + if (options.className) { + element.className = options.className; + } + return element; + } + + parse(text: string): any { + let data = JSON.parse(text); + data = revive(data, 0); + return data; + } + + /** + * Create html nodes for the given content element. + * Adapted from htmlContentRenderer. Ensures that the markdown renderer + * gets passed in the correct baseUrl for the notebook's saved location, + * respects the trusted state of a notebook, and allows command links to + * be clickable. + */ + renderMarkdown(markdown: IMarkdownString, options: RenderOptions = {}): HTMLElement { + const element = this.createElement(options); + + // signal to code-block render that the element has been created + let signalInnerHTML: () => void; + const withInnerHTML = new Promise(c => signalInnerHTML = c); + + let notebookFolder = path.dirname(this._notebookURI.path) + '/'; + if (!this._baseUrls.includes(notebookFolder)) { + this._baseUrls.push(notebookFolder); + } + const renderer = new marked.Renderer({ baseUrl: notebookFolder }); + renderer.image = (href: string, title: string, text: string) => { + href = this.cleanUrl(!markdown.isTrusted, notebookFolder, href); + let dimensions: string[] = []; + if (href) { + const splitted = href.split('|').map(s => s.trim()); + href = splitted[0]; + const parameters = splitted[1]; + if (parameters) { + const heightFromParams = /height=(\d+)/.exec(parameters); + const widthFromParams = /width=(\d+)/.exec(parameters); + const height = heightFromParams ? heightFromParams[1] : ''; + const width = widthFromParams ? widthFromParams[1] : ''; + const widthIsFinite = isFinite(parseInt(width)); + const heightIsFinite = isFinite(parseInt(height)); + if (widthIsFinite) { + dimensions.push(`width="${width}"`); + } + if (heightIsFinite) { + dimensions.push(`height="${height}"`); + } + } + } + let attributes: string[] = []; + if (href) { + attributes.push(`src="${href}"`); + } + if (text) { + attributes.push(`alt="${text}"`); + } + if (title) { + attributes.push(`title="${title}"`); + } + if (dimensions.length) { + attributes = attributes.concat(dimensions); + } + return ''; + }; + renderer.link = (href: string, title: string, text: string): string => { + href = this.cleanUrl(!markdown.isTrusted, notebookFolder, href); + if (href === null) { + return text; + } + // Remove markdown escapes. Workaround for https://github.com/chjj/marked/issues/829 + if (href === text) { // raw link case + text = removeMarkdownEscapes(text); + } + title = removeMarkdownEscapes(title); + href = removeMarkdownEscapes(href); + if ( + !href + || !markdown.isTrusted + || href.match(/^data:|javascript:/i) + || href.match(/^command:(\/\/\/)?_workbench\.downloadResource/i) + ) { + // drop the link + return text; + + } else { + // HTML Encode href + href = href.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return `${text}`; + } + }; + renderer.paragraph = (text): string => { + return `

${text}

`; + }; + + if (options.codeBlockRenderer) { + renderer.code = (code, lang) => { + const value = options.codeBlockRenderer!(lang, code); + // when code-block rendering is async we return sync + // but update the node with the real result later. + const id = defaultGenerator.nextId(); + + const promise = value.then(strValue => { + withInnerHTML.then(e => { + const span = element.querySelector(`div[data-code="${id}"]`); + if (span) { + span.innerHTML = strValue; + } + }).catch(err => { + // ignore + }); + }); + + if (options.codeBlockRenderCallback) { + promise.then(options.codeBlockRenderCallback); + } + + return `
${escape(code)}
`; + }; + } + + const markedOptions: marked.MarkedOptions = { + sanitize: !markdown.isTrusted, + renderer, + baseUrl: notebookFolder + }; + + element.innerHTML = marked.parse(markdown.value, markedOptions); + signalInnerHTML!(); + + return element; + } + + // This following methods have been adapted from marked.js + // Copyright (c) 2011-2014, Christopher Jeffrey (https://github.com/chjj/) + cleanUrl(sanitize: boolean, base: string, href: string) { + if (sanitize) { + let prot: string; + try { + prot = decodeURIComponent(unescape(href)) + .replace(/[^\w:]/g, '') + .toLowerCase(); + } catch (e) { + return null; + } + if (prot.indexOf('javascript:') === 0 || prot.indexOf('vbscript:') === 0 || prot.indexOf('data:') === 0) { + return null; + } + } + try { + if (URI.parse(href)) { + return href; + } + } catch { + // ignore + } + let originIndependentUrl = /^$|^[a-z][a-z0-9+.-]*:|^[?#]/i; + if (base && !originIndependentUrl.test(href) && !fs.existsSync(href)) { + href = this.resolveUrl(base, href); + } + try { + href = encodeURI(href).replace(/%25/g, '%'); + } catch (e) { + return null; + } + return href; + } + + resolveUrl(base: string, href: string) { + if (!this._baseUrls[' ' + base]) { + // we can ignore everything in base after the last slash of its path component, + // but we might need to add _that_ + // https://tools.ietf.org/html/rfc3986#section-3 + if (/^[^:]+:\/*[^/]*$/.test(base)) { + this._baseUrls[' ' + base] = base + '/'; + } else { + // Remove trailing 'c's. /c*$/ is vulnerable to REDOS. + this._baseUrls[' ' + base] = base.replace(/c*$/, ''); + } + } + base = this._baseUrls[' ' + base]; + + if (href.slice(0, 2) === '//') { + return base.replace(/:[\s\S]*/, ':') + href; + } else if (href.charAt(0) === '/') { + return base.replace(/(:\/*[^/]*)[\s\S]*/, '$1') + href; + } else { + return base + href; + } + } + + // end marked.js adaptation + + setNotebookURI(val: URI) { + this._notebookURI = val; + } +} diff --git a/src/sqltest/parts/notebook/common.ts b/src/sqltest/parts/notebook/common.ts index 684d1293fb..e3d5314018 100644 --- a/src/sqltest/parts/notebook/common.ts +++ b/src/sqltest/parts/notebook/common.ts @@ -17,6 +17,7 @@ export class NotebookModelStub implements INotebookModel { } public trustedMode: boolean; language: string; + standardKernels: IStandardKernelWithProvider[]; public get languageInfo(): nb.ILanguageInfo { return this._languageInfo; diff --git a/src/sqltest/parts/notebook/model/notebookModel.test.ts b/src/sqltest/parts/notebook/model/notebookModel.test.ts index ff6754b4fc..fed9b629cf 100644 --- a/src/sqltest/parts/notebook/model/notebookModel.test.ts +++ b/src/sqltest/parts/notebook/model/notebookModel.test.ts @@ -102,7 +102,6 @@ suite('notebook model', function (): void { notificationService: notificationService.object, connectionService: queryConnectionService.object, providerId: 'SQL', - standardKernels: [{ name: 'SQL', displayName: 'SQL', connectionProviderIds: [mssqlProviderName], notebookProvider: 'sql' }], cellMagicMapper: undefined, defaultKernel: undefined, layoutChanged: undefined, @@ -139,7 +138,7 @@ suite('notebook model', function (): void { notebookManagers[0].contentManager = mockContentManager.object; // When I initialize the model let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, undefined); - await model.requestModelLoad(); + await model.loadContents(); // Then I expect to have 0 code cell as the contents should(model.cells).have.length(0); @@ -154,7 +153,8 @@ suite('notebook model', function (): void { notebookManagers[0].contentManager = mockContentManager.object; // When I initialize the model let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, undefined); - await model.requestModelLoad(true); + await model.loadContents(true); + await model.requestModelLoad(); // Then Trust should be true should(model.trustedMode).be.true(); @@ -277,7 +277,7 @@ suite('notebook model', function (): void { mockContentManager.setup(c => c.getNotebookContents(TypeMoq.It.isAny())).returns(() => Promise.resolve(expectedNotebookContent)); notebookManagers[0].contentManager = mockContentManager.object; let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, undefined); - await model.requestModelLoad(false); + await model.requestModelLoad(); let actualChanged: NotebookContentChange; model.contentChanged((changed) => actualChanged = changed);