Merge pull request #123738 from microsoft/dev/mjbvz/unify-renderers-api

First cut at unifying notebook renderers apis
This commit is contained in:
Connor Peet 2021-05-19 08:52:02 -07:00 committed by GitHub
commit b02acf3908
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 412 additions and 313 deletions

View file

@ -5,35 +5,23 @@
const MarkdownIt = require('markdown-it');
export async function activate(ctx: {
dependencies: ReadonlyArray<{ entrypoint: string }>
}) {
export function activate() {
let markdownIt = new MarkdownIt({
html: true
});
// Should we load the deps before this point?
// Also could we await inside `renderMarkup`?
await Promise.all(ctx.dependencies.map(async (dep) => {
try {
const api = await import(dep.entrypoint);
if (api?.extendMarkdownIt) {
markdownIt = api.extendMarkdownIt(markdownIt);
}
} catch (e) {
console.error('Could not load markdown entryPoint', e);
}
}));
return {
renderMarkup: (context: { element: HTMLElement, content: string }) => {
const rendered = markdownIt.render(context.content);
renderCell: (_id: string, context: { element: HTMLElement, value: string }) => {
const rendered = markdownIt.render(context.value);
context.element.innerHTML = rendered;
// Insert styles into markdown preview shadow dom so that they are applied
for (const markdownStyleNode of document.getElementsByClassName('markdown-style')) {
context.element.insertAdjacentElement('beforebegin', markdownStyleNode.cloneNode(true) as Element);
}
},
extendMarkdownIt: (f: (md: typeof markdownIt) => void) => {
f(markdownIt);
}
};
}

View file

@ -6,6 +6,12 @@ import type * as markdownIt from 'markdown-it';
const emoji = require('markdown-it-emoji');
export function extendMarkdownIt(md: markdownIt.MarkdownIt) {
return md.use(emoji);
export function activate(ctx: {
getRenderer: (id: string) => any
}) {
const markdownItRenderer = ctx.getRenderer('markdownItRenderer');
markdownItRenderer.extendMarkdownIt((md: markdownIt.MarkdownIt) => {
return md.use(emoji);
});
}

View file

@ -6,23 +6,28 @@ import type * as markdownIt from 'markdown-it';
const styleHref = import.meta.url.replace(/katex.js$/, 'katex.min.css');
const link = document.createElement('link');
link.rel = 'stylesheet';
link.classList.add('markdown-style');
link.href = styleHref;
document.head.append(link);
export function activate(ctx: {
getRenderer: (id: string) => any
}) {
const markdownItRenderer = ctx.getRenderer('markdownItRenderer');
const style = document.createElement('style');
style.classList.add('markdown-style');
style.textContent = `
.katex-error {
color: var(--vscode-editorError-foreground);
}
`;
document.head.append(style);
const link = document.createElement('link');
link.rel = 'stylesheet';
link.classList.add('markdown-style');
link.href = styleHref;
document.head.append(link);
const katex = require('@iktakahiro/markdown-it-katex');
const style = document.createElement('style');
style.classList.add('markdown-style');
style.textContent = `
.katex-error {
color: var(--vscode-editorError-foreground);
}
`;
document.head.append(style);
export function extendMarkdownIt(md: markdownIt.MarkdownIt) {
return md.use(katex);
const katex = require('@iktakahiro/markdown-it-katex');
markdownItRenderer.extendMarkdownIt((md: markdownIt.MarkdownIt) => {
return md.use(katex);
});
}

View file

@ -25,24 +25,18 @@
{
"id": "markdownItRenderer-katex",
"displayName": "Markdown it katex renderer",
"entrypoint": "./notebook-out/katex.js",
"mimeTypes": [
"text/markdown"
],
"dependencies": [
"markdownItRenderer"
]
"entrypoint": {
"extends": "markdownItRenderer",
"path": "./notebook-out/katex.js"
}
},
{
"id": "markdownItRenderer-emoji",
"displayName": "Markdown it emoji renderer",
"entrypoint": "./notebook-out/emoji.js",
"mimeTypes": [
"text/markdown"
],
"dependencies": [
"markdownItRenderer"
]
"entrypoint": {
"extends": "markdownItRenderer",
"path": "./notebook-out/emoji.js"
}
}
]
},

View file

@ -6,7 +6,7 @@
import { IJSONSchema } from 'vs/base/common/jsonSchema';
import * as nls from 'vs/nls';
import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry';
import { NotebookEditorPriority } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { NotebookEditorPriority, NotebookRendererEntrypoint } from 'vs/workbench/contrib/notebook/common/notebookCommon';
namespace NotebookEditorContribution {
export const viewType = 'viewType';
@ -37,7 +37,7 @@ export interface INotebookRendererContribution {
readonly [NotebookRendererContribution.viewType]?: string;
readonly [NotebookRendererContribution.displayName]: string;
readonly [NotebookRendererContribution.mimeTypes]?: readonly string[];
readonly [NotebookRendererContribution.entrypoint]: string;
readonly [NotebookRendererContribution.entrypoint]: NotebookRendererEntrypoint;
readonly [NotebookRendererContribution.hardDependencies]: readonly string[];
readonly [NotebookRendererContribution.optionalDependencies]: readonly string[];
}
@ -130,8 +130,27 @@ const notebookRendererContribution: IJSONSchema = {
}
},
[NotebookRendererContribution.entrypoint]: {
type: 'string',
description: nls.localize('contributes.notebook.renderer.entrypoint', 'File to load in the webview to render the extension.'),
oneOf: [
{
type: 'string',
},
// todo@connor4312 + @mjbvz: uncomment this once it's ready for external adoption
// {
// type: 'object',
// required: ['extends', 'path'],
// properties: {
// extends: {
// type: 'string',
// description: nls.localize('contributes.notebook.renderer.entrypoint.extends', 'Existing renderer that this one extends.'),
// },
// path: {
// type: 'string',
// description: nls.localize('contributes.notebook.renderer.entrypoint', 'File to load in the webview to render the extension.'),
// },
// }
// }
]
},
[NotebookRendererContribution.hardDependencies]: {
type: 'array',

View file

@ -26,10 +26,10 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { asWebviewUri } from 'vs/workbench/api/common/shared/webview';
import { CellEditState, ICellOutputViewModel, ICommonCellInfo, ICommonNotebookEditor, IDisplayOutputLayoutUpdateRequest, IDisplayOutputViewModel, IGenericCellViewModel, IInsetRenderOutput, RenderOutputType } from 'vs/workbench/contrib/notebook/browser/notebookBrowser';
import { preloadsScriptStr, WebviewPreloadRenderer } from 'vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads';
import { preloadsScriptStr, RendererMetadata } from 'vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads';
import { transformWebviewThemeVars } from 'vs/workbench/contrib/notebook/browser/view/renderers/webviewThemeMapping';
import { MarkdownCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel';
import { INotebookKernel, INotebookRendererInfo, NotebookRendererMatch } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { INotebookKernel, INotebookRendererInfo } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService';
import { IWebviewService, WebviewContentPurpose, WebviewElement } from 'vs/workbench/contrib/webview/browser/webview';
import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
@ -203,7 +203,7 @@ export interface ICreationRequestMessage {
cellTop: number;
outputOffset: number;
left: number;
requiredPreloads: ReadonlyArray<IPreloadResource>;
requiredPreloads: ReadonlyArray<IControllerPreload>;
readonly initiallyHidden?: boolean;
rendererId?: string | undefined;
}
@ -263,17 +263,15 @@ export interface IAckOutputHeightMessage {
height: number;
}
export type PreloadSource = 'kernel' | { rendererId: string };
export interface IPreloadResource {
export interface IControllerPreload {
originalUri: string;
uri: string;
source: PreloadSource;
}
export interface IUpdatePreloadResourceMessage {
export interface IUpdateControllerPreloadsMessage {
type: 'preload';
resources: IPreloadResource[];
resources: IControllerPreload[];
}
export interface IUpdateDecorationsMessage {
@ -376,7 +374,7 @@ export type ToWebviewMessage =
| IClearOutputRequestMessage
| IHideOutputMessage
| IShowOutputMessage
| IUpdatePreloadResourceMessage
| IUpdateControllerPreloadsMessage
| IUpdateDecorationsMessage
| ICustomKernelMessage
| ICreateMarkdownMessage
@ -496,7 +494,7 @@ export class BackLayerWebView<T extends ICommonCellInfo> extends Disposable {
}
private generateContent(coreDependencies: string, baseUrl: string) {
const markupRenderer = this.getMarkdownRenderer();
const renderersData = this.getRendererData();
return html`
<html lang="en">
<head>
@ -755,36 +753,19 @@ export class BackLayerWebView<T extends ICommonCellInfo> extends Disposable {
</script>
${coreDependencies}
<div id='container' class="widgetarea" style="position: absolute;width:100%;top: 0px"></div>
<script type="module">${preloadsScriptStr(this.options, markupRenderer)}</script>
<script type="module">${preloadsScriptStr(this.options, renderersData)}</script>
</body>
</html>`;
}
private getMarkdownRenderer(): WebviewPreloadRenderer[] {
const markdownMimeType = 'text/markdown';
const allRenderers = this.notebookService.getRenderers()
.filter(renderer => renderer.matchesWithoutKernel(markdownMimeType) !== NotebookRendererMatch.Never);
const topLevelMarkdownRenderers = allRenderers
.filter(renderer => renderer.dependencies.length === 0);
const subRenderers = new Map<string, Array<{ entrypoint: string }>>();
for (const renderer of allRenderers) {
for (const dep of renderer.dependencies) {
if (!subRenderers.has(dep)) {
subRenderers.set(dep, []);
}
const entryPoint = this.asWebviewUri(renderer.entrypoint, renderer.extensionLocation);
subRenderers.get(dep)!.push({ entrypoint: entryPoint.toString(true) });
}
}
return topLevelMarkdownRenderers.map((renderer): WebviewPreloadRenderer => {
const src = this.asWebviewUri(renderer.entrypoint, renderer.extensionLocation);
private getRendererData(): RendererMetadata[] {
return this.notebookService.getRenderers().map((renderer): RendererMetadata => {
const entrypoint = this.asWebviewUri(renderer.entrypoint, renderer.extensionLocation).toString();
return {
entrypoint: src.toString(),
id: renderer.id,
entrypoint,
mimeTypes: renderer.mimeTypes,
dependencies: subRenderers.get(renderer.id) || [],
extends: renderer.extends,
};
});
}
@ -1211,7 +1192,6 @@ var requirejs = (function() {
if (this._currentKernel) {
this._updatePreloadsFromKernel(this._currentKernel);
}
this.updateRendererPreloads(renderers);
for (const [output, inset] of this.insetMapping.entries()) {
this._sendMessageToWebview({ ...inset.cachedCreation, initiallyHidden: this.hiddenInsetMapping.has(output) });
@ -1486,7 +1466,6 @@ var requirejs = (function() {
...messageBase,
outputId: output.outputId,
rendererId: content.renderer.id,
requiredPreloads: await this.updateRendererPreloads([content.renderer]),
content: {
type: RenderOutputType.Extension,
outputId: output.outputId,
@ -1617,13 +1596,13 @@ var requirejs = (function() {
}
private _updatePreloadsFromKernel(kernel: INotebookKernel) {
const resources: IPreloadResource[] = [];
const resources: IControllerPreload[] = [];
for (const preload of kernel.preloadUris) {
const uri = this.environmentService.isExtensionDevelopment && (preload.scheme === 'http' || preload.scheme === 'https')
? preload : this.asWebviewUri(preload, undefined);
if (!this._preloadsCache.has(uri.toString())) {
resources.push({ uri: uri.toString(), originalUri: preload.toString(), source: 'kernel' });
resources.push({ uri: uri.toString(), originalUri: preload.toString() });
this._preloadsCache.add(uri.toString());
}
}
@ -1635,43 +1614,7 @@ var requirejs = (function() {
this._updatePreloads(resources);
}
async updateRendererPreloads(renderers: Iterable<INotebookRendererInfo>) {
if (this._disposed) {
return [];
}
const requiredPreloads: IPreloadResource[] = [];
const resources: IPreloadResource[] = [];
const extensionLocations: URI[] = [];
for (const rendererInfo of renderers) {
extensionLocations.push(rendererInfo.extensionLocation);
for (const preload of [rendererInfo.entrypoint, ...rendererInfo.preloads]) {
const uri = this.asWebviewUri(preload, rendererInfo.extensionLocation);
const resource: IPreloadResource = {
uri: uri.toString(),
originalUri: preload.toString(),
source: { rendererId: rendererInfo.id },
};
requiredPreloads.push(resource);
if (!this._preloadsCache.has(uri.toString())) {
resources.push(resource);
this._preloadsCache.add(uri.toString());
}
}
}
if (!resources.length) {
return requiredPreloads;
}
this.rendererRootsCache = extensionLocations;
this._updatePreloads(resources);
return requiredPreloads;
}
private _updatePreloads(resources: IPreloadResource[]) {
private _updatePreloads(resources: IControllerPreload[]) {
if (!this.webview) {
return;
}

View file

@ -41,7 +41,7 @@ interface PreloadStyles {
declare function __import(path: string): Promise<any>;
async function webviewPreloads(style: PreloadStyles, rendererData: readonly WebviewPreloadRenderer[]) {
async function webviewPreloads(style: PreloadStyles, rendererData: readonly RendererMetadata[]) {
const acquireVsCodeApi = globalThis.acquireVsCodeApi;
const vscode = acquireVsCodeApi();
delete (globalThis as any).acquireVsCodeApi;
@ -111,32 +111,93 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
}
};
const runScript = async (url: string, originalUri: string, globals: { [name: string]: unknown } = {}): Promise<() => (PreloadResult)> => {
let text: string;
try {
const res = await fetch(url);
text = await res.text();
if (!res.ok) {
throw new Error(`Unexpected ${res.status} requesting ${originalUri}: ${text || res.statusText}`);
}
globals.scriptUrl = url;
} catch (e) {
return () => ({ state: PreloadState.Error, error: e.message });
async function loadScriptSource(url: string, originalUri = url): Promise<string> {
const res = await fetch(url);
const text = await res.text();
if (!res.ok) {
throw new Error(`Unexpected ${res.status} requesting ${originalUri}: ${text || res.statusText}`);
}
return text;
}
interface RendererContext {
getState<T>(): T | undefined;
setState<T>(newState: T): void;
getRenderer(id: string): any | undefined;
}
function createRendererContext(rendererId: string): RendererContext {
return {
setState: newState => vscode.setState({ ...vscode.getState(), [rendererId]: newState }),
getState: <T>() => {
const state = vscode.getState();
return typeof state === 'object' && state ? state[rendererId] as T : undefined;
},
getRenderer: (id: string) => renderers.getRenderer(id),
};
}
interface ScriptModule {
activate: (ctx?: RendererContext) => any;
}
const invokeSourceWithGlobals = (functionSrc: string, globals: { [name: string]: unknown }) => {
const args = Object.entries(globals);
return () => {
try {
new Function(...args.map(([k]) => k), text)(...args.map(([, v]) => v));
return { state: PreloadState.Ok };
} catch (e) {
console.error(e);
return { state: PreloadState.Error, error: e.message };
return new Function(...args.map(([k]) => k), functionSrc)(...args.map(([, v]) => v));
};
const runPreload = async (url: string, originalUri: string): Promise<ScriptModule> => {
const text = await loadScriptSource(url, originalUri);
return {
activate: () => {
return invokeSourceWithGlobals(text, kernelPreloadGlobals);
}
};
};
const runRenderScript = async (url: string, rendererId: string): Promise<ScriptModule> => {
const text = await loadScriptSource(url);
// TODO: Support both the new module based renderers and the old style global renderers
const isModule = /\bexport\b.*\bactivate\b/.test(text);
if (isModule) {
return __import(url);
} else {
return createBackCompatModule(rendererId, text);
}
};
const createBackCompatModule = (rendererId: string, scriptText: string): ScriptModule => ({
activate: (): RendererApi => {
const onDidCreateOutput = createEmitter<ICreateCellInfo>();
const onWillDestroyOutput = createEmitter<undefined | IDestroyCellInfo>();
const globals = {
acquireNotebookRendererApi: <T>(): GlobalNotebookRendererApi<T> => ({
onDidCreateOutput: onDidCreateOutput.event,
onWillDestroyOutput: onWillDestroyOutput.event,
setState: newState => vscode.setState({ ...vscode.getState(), [rendererId]: newState }),
getState: () => {
const state = vscode.getState();
return typeof state === 'object' && state ? state[rendererId] as T : undefined;
},
}),
};
invokeSourceWithGlobals(scriptText, globals);
return {
renderCell(id, context) {
onDidCreateOutput.fire({ ...context, outputId: id });
},
destroyCell(id) {
onWillDestroyOutput.fire(id ? { outputId: id } : undefined);
}
};
}
});
const dimensionUpdater = new class {
private readonly pending = new Map<string, DimensionUpdate>();
@ -352,8 +413,6 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
focusTrackers.set(outputId, new FocusTracker(element, outputId));
}
const dontEmit = Symbol('dontEmit');
function createEmitter<T>(listenerChange: (listeners: Set<Listener<T>>) => void = () => undefined): EmitterLike<T> {
const listeners = new Set<Listener<T>>();
return {
@ -385,29 +444,21 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
};
}
// Maps the events in the given emitter, invoking mapFn on each one. mapFn can return
// the dontEmit symbol to skip emission.
function mapEmitter<T, R>(emitter: EmitterLike<T>, mapFn: (data: T) => R | typeof dontEmit) {
let listener: IDisposable;
const mapped = createEmitter(listeners => {
if (listeners.size && !listener) {
listener = emitter.event(data => {
const v = mapFn(data);
if (v !== dontEmit) {
mapped.fire(v);
}
});
} else if (listener && !listeners.size) {
listener.dispose();
}
});
return mapped.event;
function showPreloadErrors(outputNode: HTMLElement, ...errors: readonly Error[]) {
outputNode.innerText = `Error loading preloads:`;
const errList = document.createElement('ul');
for (const result of errors) {
console.error(result);
const item = document.createElement('li');
item.innerText = result.message;
errList.appendChild(item);
}
outputNode.appendChild(errList);
}
interface ICreateCellInfo {
element: HTMLElement;
outputId: string;
outputId?: string;
mime: string;
value: unknown;
@ -418,26 +469,15 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
outputId: string;
}
const onWillDestroyOutput = createEmitter<'all' | { rendererId: string, info: IDestroyCellInfo }>();
const onDidCreateOutput = createEmitter<{ rendererId: string, info: ICreateCellInfo }>();
const onDidReceiveKernelMessage = createEmitter<unknown>();
const acquireNotebookRendererApi = <T>(id: string) => ({
setState(newState: T) {
vscode.setState({ ...vscode.getState(), [id]: newState });
},
getState(): T | undefined {
const state = vscode.getState();
return typeof state === 'object' && state ? state[id] as T : undefined;
},
onWillDestroyOutput: mapEmitter(onWillDestroyOutput, (evt) => {
if (evt === 'all') {
return undefined;
}
return evt.rendererId === id ? evt.info : dontEmit;
}),
onDidCreateOutput: mapEmitter(onDidCreateOutput, ({ rendererId, info }) => rendererId === id ? info : dontEmit),
});
/** @deprecated */
interface GlobalNotebookRendererApi<T> {
setState: (newState: T) => void;
getState(): T | undefined;
readonly onWillDestroyOutput: Event<undefined | IDestroyCellInfo>;
readonly onDidCreateOutput: Event<ICreateCellInfo>;
}
const kernelPreloadGlobals = {
acquireVsCodeApi,
@ -445,42 +485,6 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
postKernelMessage: (data: unknown) => postNotebookMessage('customKernelMessage', { message: data }),
};
const enum PreloadState {
Ok,
Error
}
type PreloadResult = { state: PreloadState.Ok } | { state: PreloadState.Error, error: string };
/**
* Map of preload resource URIs to promises that resolve one the resource
* loads or errors.
*/
const preloadPromises = new Map<string, Promise<PreloadResult>>();
const queuedOuputActions = new Map<string, Promise<void>>();
/**
* Enqueues an action that affects a output. This blocks behind renderer load
* requests that affect the same output. This should be called whenever you
* do something that affects output to ensure it runs in
* the correct order.
*/
const enqueueOutputAction = <T extends { outputId: string; }>(event: T, fn: (event: T) => Promise<void> | void) => {
const queued = queuedOuputActions.get(event.outputId);
const maybePromise = queued ? queued.then(() => fn(event)) : fn(event);
if (typeof maybePromise === 'undefined') {
return; // a synchonrously-called function, we're done
}
const promise = maybePromise.then(() => {
if (queuedOuputActions.get(event.outputId) === promise) {
queuedOuputActions.delete(event.outputId);
}
});
queuedOuputActions.set(event.outputId, promise);
};
const ttPolicy = window.trustedTypes?.createPolicy('notebookOutputRenderer', {
createHTML: value => value,
createScript: value => value,
@ -562,10 +566,15 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
}
}
break;
case 'html':
enqueueOutputAction(event.data, async data => {
const preloadResults = await Promise.all(data.requiredPreloads.map(p => preloadPromises.get(p.uri)));
if (!queuedOuputActions.has(data.outputId)) { // output was cleared while loading
case 'html': {
const data = event.data;
outputs.enqueue(event.data.outputId, async (state) => {
const preloadsAndErrors = await Promise.all<unknown>([
data.rendererId ? renderers.load(data.rendererId) : undefined,
...data.requiredPreloads.map(p => kernelPreloads.waitFor(p.uri)),
].map(p => p?.catch(err => err)));
if (state.cancelled) {
return;
}
@ -615,37 +624,26 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
if (content.type === RenderOutputType.Html) {
const trustedHtml = ttPolicy?.createHTML(content.htmlContent) ?? content.htmlContent;
outputNode.innerHTML = trustedHtml as string;
cellOutputContainer.appendChild(outputContainer);
outputContainer.appendChild(outputNode);
domEval(outputNode);
} else if (preloadResults.some(e => e?.state === PreloadState.Error)) {
outputNode.innerText = `Error loading preloads:`;
const errList = document.createElement('ul');
for (const result of preloadResults) {
if (result?.state === PreloadState.Error) {
const item = document.createElement('li');
item.innerText = result.error;
errList.appendChild(item);
}
}
outputNode.appendChild(errList);
cellOutputContainer.appendChild(outputContainer);
outputContainer.appendChild(outputNode);
} else if (preloadsAndErrors.some(e => e instanceof Error)) {
const errors = preloadsAndErrors.filter((e): e is Error => e instanceof Error);
showPreloadErrors(outputNode, ...errors);
} else {
onDidCreateOutput.fire({
rendererId: data.rendererId!,
info: {
const rendererApi = preloadsAndErrors[0] as RendererApi;
try {
rendererApi.renderCell(outputId, {
element: outputNode,
outputId,
mime: content.mimeType,
value: content.value,
metadata: content.metadata,
}
});
cellOutputContainer.appendChild(outputContainer);
outputContainer.appendChild(outputNode);
});
} catch (e) {
showPreloadErrors(outputNode, e);
}
}
cellOutputContainer.appendChild(outputContainer);
outputContainer.appendChild(outputNode);
resizeObserver.observe(outputNode, outputId, true);
const clientHeight = outputNode.clientHeight;
@ -670,6 +668,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
cellOutputContainer.style.visibility = data.initiallyHidden ? 'hidden' : 'visible';
});
break;
}
case 'view-scroll':
{
// const date = new Date();
@ -696,8 +695,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
break;
}
case 'clear':
queuedOuputActions.clear(); // stop all loading outputs
onWillDestroyOutput.fire('all');
renderers.clearAll();
document.getElementById('container')!.innerText = '';
focusTrackers.forEach(ft => {
@ -709,26 +707,29 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
const output = document.getElementById(event.data.outputId);
const { rendererId, outputId } = event.data;
queuedOuputActions.delete(outputId); // stop any in-progress rendering
outputs.cancelOutput(outputId);
if (output && output.parentNode) {
if (rendererId) {
onWillDestroyOutput.fire({ rendererId, info: { outputId } });
renderers.clearOutput(rendererId, outputId);
}
output.parentNode.removeChild(output);
}
break;
}
case 'hideOutput':
enqueueOutputAction(event.data, ({ outputId }) => {
case 'hideOutput': {
const { outputId } = event.data;
outputs.enqueue(event.data.outputId, () => {
const container = document.getElementById(outputId)?.parentElement?.parentElement;
if (container) {
container.style.visibility = 'hidden';
}
});
break;
case 'showOutput':
enqueueOutputAction(event.data, ({ outputId, cellTop: top, }) => {
}
case 'showOutput': {
const { outputId, cellTop: top } = event.data;
outputs.enqueue(event.data.outputId, () => {
const output = document.getElementById(outputId);
if (output) {
output.parentElement!.parentElement!.style.visibility = 'visible';
@ -740,6 +741,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
}
});
break;
}
case 'ack-dimension':
{
const { outputId, height } = event.data;
@ -752,24 +754,8 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
}
case 'preload':
const resources = event.data.resources;
let queue: Promise<PreloadResult> = Promise.resolve({ state: PreloadState.Ok });
for (const { uri, originalUri, source } of resources) {
const globals = source === 'kernel'
? kernelPreloadGlobals
: { acquireNotebookRendererApi: () => acquireNotebookRendererApi(source.rendererId) };
// create the promise so that the scripts download in parallel, but
// only invoke them in series within the queue
const promise = runScript(uri, originalUri, globals);
queue = queue.then(() => promise.then(fn => {
const result = fn();
if (result.state === PreloadState.Error) {
console.error(result.error);
}
return result;
}));
preloadPromises.set(uri, queue);
for (const { uri, originalUri } of resources) {
kernelPreloads.load(uri, originalUri);
}
break;
case 'focus-output':
@ -806,51 +792,193 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
}
});
interface MarkupRenderer {
renderMarkup: (context: { element: HTMLElement, content: string }) => void;
interface RendererApi {
renderCell: (id: string, context: ICreateCellInfo) => void;
destroyCell?: (id?: string) => void;
}
const markupRenderers = new class {
class Renderer {
constructor(
public readonly data: RendererMetadata,
private readonly loadExtension: (id: string) => Promise<void>,
) { }
private readonly mimeTypesToRenderers = new Map<string, {
load: () => Promise<MarkupRenderer>;
}>();
private _loadPromise: Promise<RendererApi> | undefined;
private _api: RendererApi | undefined;
public get api() { return this._api; }
public load(): Promise<RendererApi | undefined> {
if (!this._loadPromise) {
this._loadPromise = this._load();
}
return this._loadPromise;
}
/** Inner function cached in the _loadPromise(). */
private async _load() {
const module = await runRenderScript(this.data.entrypoint, this.data.id);
if (!module) {
return;
}
const api = module.activate(createRendererContext(this.data.id));
this._api = api;
// Squash any errors extends errors. They won't prevent the renderer
// itself from working, so just log them.
await Promise.all(rendererData
.filter(d => d.extends === this.data.id)
.map(d => this.loadExtension(d.id).catch(console.error)),
);
return api;
}
}
const kernelPreloads = new class {
private readonly preloads = new Map<string /* uri */, Promise<ScriptModule>>();
/**
* Returns a promise that resolves when the given preload is activated.
*/
public waitFor(uri: string) {
return this.preloads.get(uri) || Promise.resolve(new Error(`Preload not ready: ${uri}`));
}
/**
* Loads a preload.
* @param uri URI to load from
* @param originalUri URI to show in an error message if the preload is invalid.
*/
public load(uri: string, originalUri: string) {
const promise = Promise.all([
runPreload(uri, originalUri),
this.waitForAllCurrent(),
]).then(([module]) => module.activate());
this.preloads.set(uri, promise);
return promise;
}
/**
* Returns a promise that waits for all currently-registered preloads to
* activate before resolving.
*/
private waitForAllCurrent() {
return Promise.all([...this.preloads.values()].map(p => p.catch(err => err)));
}
};
const outputs = new class {
private outputs = new Map<string, { cancelled: boolean; queue: Promise<unknown> }>();
/**
* Pushes the action onto the list of actions for the given output ID,
* ensuring that it's run in-order.
*/
public enqueue(outputId: string, action: (record: { cancelled: boolean }) => unknown) {
const record = this.outputs.get(outputId);
if (!record) {
this.outputs.set(outputId, { cancelled: false, queue: new Promise(r => r(action({ cancelled: false }))) });
} else {
record.queue = record.queue.then(r => !record.cancelled && action(record));
}
}
/**
* Cancells the rendering of all outputs.
*/
public cancelAll() {
for (const record of this.outputs.values()) {
record.cancelled = true;
}
this.outputs.clear();
}
/**
* Cancels any ongoing rendering out an output.
*/
public cancelOutput(outputId: string) {
const output = this.outputs.get(outputId);
if (output) {
output.cancelled = true;
this.outputs.delete(outputId);
}
}
};
const renderers = new class {
private readonly _renderers = new Map</* id */ string, Renderer>();
constructor() {
for (const renderer of rendererData) {
let loadPromise: Promise<MarkupRenderer> | undefined;
const entry = {
load: () => {
if (!loadPromise) {
loadPromise = __import(renderer.entrypoint).then(module => {
return module.activate({ dependencies: renderer.dependencies });
});
}
return loadPromise;
},
renderer: undefined,
};
for (const mime of renderer.mimeTypes || []) {
if (!this.mimeTypesToRenderers.has(mime)) {
this.mimeTypesToRenderers.set(mime, entry);
this._renderers.set(renderer.id, new Renderer(renderer, async (extensionId) => {
const ext = this._renderers.get(extensionId);
if (!ext) {
throw new Error(`Could not find extending renderer: ${extensionId}`);
}
}
await ext.load();
}));
}
}
async renderMarkdown(element: HTMLElement, content: string): Promise<void> {
const entry = this.mimeTypesToRenderers.get('text/markdown');
if (!entry) {
public getRenderer(id: string): RendererApi | undefined {
return this._renderers.get(id)?.api;
}
public async load(id: string) {
const renderer = this._renderers.get(id);
if (!renderer) {
throw new Error('Could not find renderer');
}
const renderer = await entry.load();
renderer.renderMarkup({ element, content });
return renderer.load();
}
public clearAll() {
outputs.cancelAll();
for (const renderer of this._renderers.values()) {
renderer.api?.destroyCell?.();
}
}
public clearOutput(rendererId: string, outputId: string) {
outputs.cancelOutput(outputId);
this._renderers.get(rendererId)?.api?.destroyCell?.(outputId);
}
public async renderCustom(rendererId: string, outputId: string, info: ICreateCellInfo) {
const api = await this.load(rendererId);
if (!api) {
throw new Error(`renderer ${rendererId} did not return an API`);
}
api.renderCell(outputId, info);
}
public async renderMarkdown(id: string, element: HTMLElement, content: string): Promise<void> {
const markdownRenderers = Array.from(this._renderers.values())
.filter(renderer => renderer.data.mimeTypes.includes('text/markdown') && !renderer.data.extends);
if (!markdownRenderers.length) {
throw new Error('Could not find renderer');
}
await Promise.all(markdownRenderers.map(x => x.load()));
markdownRenderers[0].api?.renderCell(id, {
element,
value: content,
mime: 'text/markdown',
metadata: undefined,
outputId: undefined,
});
}
}();
vscode.postMessage({
__vscode_notebook_message: true,
type: 'initialized'
@ -978,7 +1106,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
previewNode.innerText = '';
} else {
previewContainerNode.classList.remove('emptyMarkdownCell');
await markupRenderers.renderMarkdown(previewNode, content);
await renderers.renderMarkdown(cellId, previewNode, content);
if (!hasPostedRenderedMathTelemetry) {
const hasRenderedMath = previewNode.querySelector('.katex');
@ -1077,13 +1205,14 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv
}();
}
export interface WebviewPreloadRenderer {
export interface RendererMetadata {
readonly id: string;
readonly entrypoint: string;
readonly mimeTypes: readonly string[];
readonly dependencies: ReadonlyArray<{ entrypoint: string }>;
readonly extends: string | undefined;
}
export function preloadsScriptStr(styleValues: PreloadStyles, renderers: readonly WebviewPreloadRenderer[]) {
export function preloadsScriptStr(styleValues: PreloadStyles, renderers: readonly RendererMetadata[]) {
// TS will try compiling `import()` in webviePreloads, so use an helper function instead
// of using `import(...)` directly
return `

View file

@ -53,6 +53,8 @@ export const ACCESSIBLE_NOTEBOOK_DISPLAY_ORDER = [
export const BUILTIN_RENDERER_ID = '_builtin';
export const RENDERER_NOT_AVAILABLE = '_notAvailable';
export type NotebookRendererEntrypoint = string | { extends: string; path: string };
export enum NotebookRunState {
Running = 1,
Idle = 2
@ -132,6 +134,7 @@ export const enum NotebookRendererMatch {
export interface INotebookRendererInfo {
id: string;
displayName: string;
extends?: string;
entrypoint: URI;
preloads: ReadonlyArray<URI>;
extensionLocation: URI;

View file

@ -8,7 +8,7 @@ import { Iterable } from 'vs/base/common/iterator';
import { joinPath } from 'vs/base/common/resources';
import { URI } from 'vs/base/common/uri';
import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { INotebookRendererInfo, NotebookRendererMatch } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { INotebookRendererInfo, NotebookRendererEntrypoint, NotebookRendererMatch } from 'vs/workbench/contrib/notebook/common/notebookCommon';
class DependencyList {
private readonly value: ReadonlySet<string>;
@ -34,6 +34,7 @@ class DependencyList {
export class NotebookOutputRendererInfo implements INotebookRendererInfo {
readonly id: string;
readonly extends?: string;
readonly entrypoint: URI;
readonly displayName: string;
readonly extensionLocation: URI;
@ -49,7 +50,7 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo {
constructor(descriptor: {
readonly id: string;
readonly displayName: string;
readonly entrypoint: string;
readonly entrypoint: NotebookRendererEntrypoint;
readonly mimeTypes: readonly string[];
readonly extension: IExtensionDescription;
readonly dependencies: readonly string[] | undefined;
@ -58,7 +59,14 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo {
this.id = descriptor.id;
this.extensionId = descriptor.extension.identifier;
this.extensionLocation = descriptor.extension.extensionLocation;
this.entrypoint = joinPath(this.extensionLocation, descriptor.entrypoint);
if (typeof descriptor.entrypoint === 'string') {
this.entrypoint = joinPath(this.extensionLocation, descriptor.entrypoint);
} else {
this.extends = descriptor.entrypoint.extends;
this.entrypoint = joinPath(this.extensionLocation, descriptor.entrypoint.path);
}
this.displayName = descriptor.displayName;
this.mimeTypes = descriptor.mimeTypes;
this.mimeTypeGlobs = this.mimeTypes.map(pattern => glob.parse(pattern));
@ -103,6 +111,10 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo {
}
private matchesMimeTypeOnly(mimeType: string) {
if (this.extends !== undefined) {
return false;
}
return this.mimeTypeGlobs.some(pattern => pattern(mimeType)) || this.mimeTypes.some(pattern => pattern === mimeType);
}
}