diff --git a/extensions/ipynb/extension.webpack.config.js b/extensions/ipynb/extension.webpack.config.js index 784411bb066662..6637eac6ae4b02 100644 --- a/extensions/ipynb/extension.webpack.config.js +++ b/extensions/ipynb/extension.webpack.config.js @@ -13,8 +13,9 @@ module.exports = withDefaults({ context: __dirname, entry: { extension: './src/ipynbMain.ts', + notebookSerializerWorker: './src/notebookSerializerWorker.ts', }, output: { - filename: 'ipynbMain.js' + filename: '[name].js' } }); diff --git a/extensions/ipynb/package.json b/extensions/ipynb/package.json index d881eb8ca22163..784a352ad96000 100644 --- a/extensions/ipynb/package.json +++ b/extensions/ipynb/package.json @@ -39,6 +39,12 @@ "scope": "resource", "markdownDescription": "%ipynb.pasteImagesAsAttachments.enabled%", "default": true + }, + "ipynb.experimental.serialization": { + "type": "boolean", + "scope": "resource", + "markdownDescription": "%ipynb.experimental.serialization%", + "default": false } } } diff --git a/extensions/ipynb/package.nls.json b/extensions/ipynb/package.nls.json index 7a3d95181cfcd9..9fc163137f8866 100644 --- a/extensions/ipynb/package.nls.json +++ b/extensions/ipynb/package.nls.json @@ -2,6 +2,7 @@ "displayName": ".ipynb Support", "description": "Provides basic support for opening and reading Jupyter's .ipynb notebook files", "ipynb.pasteImagesAsAttachments.enabled": "Enable/disable pasting of images into Markdown cells in ipynb notebook files. Pasted images are inserted as attachments to the cell.", + "ipynb.experimental.serialization": "Experimental feature to serialize the Jupyter notebook in a worker thread. Not supported when the Extension host is running as a web worker.", "newUntitledIpynb.title": "New Jupyter Notebook", "newUntitledIpynb.shortTitle": "Jupyter Notebook", "openIpynbInNotebookEditor.title": "Open IPYNB File In Notebook Editor", diff --git a/extensions/ipynb/src/common.ts b/extensions/ipynb/src/common.ts index f8514216fef373..b5f7db1e0263cd 100644 --- a/extensions/ipynb/src/common.ts +++ b/extensions/ipynb/src/common.ts @@ -64,3 +64,8 @@ export interface CellMetadata { */ execution_count?: number; } + +export interface notebookSerializationWorkerData { + notebookContent: Partial; + indentAmount: string; +} diff --git a/extensions/ipynb/src/ipynbMain.ts b/extensions/ipynb/src/ipynbMain.ts index 58c7e100b3b0f6..a81d1809c5ed43 100644 --- a/extensions/ipynb/src/ipynbMain.ts +++ b/extensions/ipynb/src/ipynbMain.ts @@ -105,7 +105,7 @@ export function activate(context: vscode.ExtensionContext) { get dropCustomMetadata() { return true; }, - exportNotebook: (notebook: vscode.NotebookData): string => { + exportNotebook: (notebook: vscode.NotebookData): Promise => { return exportNotebook(notebook, serializer); }, setNotebookMetadata: async (resource: vscode.Uri, metadata: Partial): Promise => { @@ -127,7 +127,7 @@ export function activate(context: vscode.ExtensionContext) { }; } -function exportNotebook(notebook: vscode.NotebookData, serializer: NotebookSerializer): string { +function exportNotebook(notebook: vscode.NotebookData, serializer: NotebookSerializer): Promise { return serializer.serializeNotebookToString(notebook); } diff --git a/extensions/ipynb/src/notebookSerializer.ts b/extensions/ipynb/src/notebookSerializer.ts index 3304853d894ff0..73d6867efb83f2 100644 --- a/extensions/ipynb/src/notebookSerializer.ts +++ b/extensions/ipynb/src/notebookSerializer.ts @@ -10,6 +10,7 @@ import { defaultNotebookFormat } from './constants'; import { getPreferredLanguage, jupyterNotebookModelToNotebookData } from './deserializers'; import { createJupyterCellFromNotebookCell, pruneCell, sortObjectPropertiesRecursively } from './serializers'; import * as fnv from '@enonic/fnv-plus'; +import { notebookSerializationWorkerData } from './common'; export class NotebookSerializer implements vscode.NotebookSerializer { constructor(readonly context: vscode.ExtensionContext) { @@ -70,11 +71,46 @@ export class NotebookSerializer implements vscode.NotebookSerializer { return data; } - public serializeNotebook(data: vscode.NotebookData, _token: vscode.CancellationToken): Uint8Array { - return new TextEncoder().encode(this.serializeNotebookToString(data)); + public async serializeNotebook(data: vscode.NotebookData, _token: vscode.CancellationToken): Promise { + return new TextEncoder().encode(await this.serializeNotebookToString(data)); } - public serializeNotebookToString(data: vscode.NotebookData): string { + private async serializeViaWorker(workerData: notebookSerializationWorkerData): Promise { + const workerThreads = await import('node:worker_threads'); + const path = await import('node:path'); + const { Worker } = workerThreads; + + return await new Promise((resolve, reject) => { + const workerFile = path.join(__dirname, 'notebookSerializerWorker.js'); + const worker = new Worker(workerFile, { workerData }); + worker.on('message', resolve); + worker.on('error', reject); + worker.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Worker stopped with exit code ${code}`)); + } + }); + }); + } + + private serializeNotebookToJSON(notebookContent: Partial, indentAmount: string): Promise { + + const isInNodeJSContext = typeof process !== 'undefined' && process.release && process.release.name === 'node'; + const experimentalSave = vscode.workspace.getConfiguration('ipynb').get('experimental.serialization', false); + if (isInNodeJSContext && experimentalSave) { + return this.serializeViaWorker({ + notebookContent, + indentAmount + }); + } else { + // ipynb always ends with a trailing new line (we add this so that SCMs do not show unnecessary changes, resulting from a missing trailing new line). + const sorted = sortObjectPropertiesRecursively(notebookContent); + + return Promise.resolve(JSON.stringify(sorted, undefined, indentAmount) + '\n'); + } + } + + public serializeNotebookToString(data: vscode.NotebookData): Promise { const notebookContent = getNotebookMetadata(data); // use the preferred language from document metadata or the first cell language as the notebook preferred cell language const preferredCellLanguage = notebookContent.metadata?.language_info?.name ?? data.cells.find(cell => cell.kind === vscode.NotebookCellKind.Code)?.languageId; @@ -86,8 +122,8 @@ export class NotebookSerializer implements vscode.NotebookSerializer { const indentAmount = data.metadata && 'indentAmount' in data.metadata && typeof data.metadata.indentAmount === 'string' ? data.metadata.indentAmount : ' '; - // ipynb always ends with a trailing new line (we add this so that SCMs do not show unnecessary changes, resulting from a missing trailing new line). - return JSON.stringify(sortObjectPropertiesRecursively(notebookContent), undefined, indentAmount) + '\n'; + + return this.serializeNotebookToJSON(notebookContent, indentAmount); } } diff --git a/extensions/ipynb/src/notebookSerializerWorker.ts b/extensions/ipynb/src/notebookSerializerWorker.ts new file mode 100644 index 00000000000000..438d88bb145a06 --- /dev/null +++ b/extensions/ipynb/src/notebookSerializerWorker.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { notebookSerializationWorkerData } from './common'; +import { workerData, parentPort } from 'node:worker_threads'; + +function sortObjectPropertiesRecursively(obj: any): any { + if (Array.isArray(obj)) { + return obj.map(sortObjectPropertiesRecursively); + } + if (obj !== undefined && obj !== null && typeof obj === 'object' && Object.keys(obj).length > 0) { + return ( + Object.keys(obj) + .sort() + .reduce>((sortedObj, prop) => { + sortedObj[prop] = sortObjectPropertiesRecursively(obj[prop]); + return sortedObj; + }, {}) as any + ); + } + return obj; +} + +if (parentPort) { + const { notebookContent, indentAmount } = workerData; + const json = JSON.stringify(sortObjectPropertiesRecursively(notebookContent), undefined, indentAmount) + '\n'; + parentPort.postMessage(json); +} diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts index ed204686f0907b..f58ff0aa23fd15 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts @@ -105,7 +105,17 @@ export class CellEditorStatusBar extends CellContentPart { event: e }); } else { - if ((e.target as HTMLElement).classList.contains('cell-status-item-has-command')) { + const target = e.target; + let itemHasCommand = false; + if (target && DOM.isHTMLElement(target)) { + const targetElement = target; + if (targetElement.classList.contains('cell-status-item-has-command')) { + itemHasCommand = true; + } else if (targetElement.parentElement && targetElement.parentElement.classList.contains('cell-status-item-has-command')) { + itemHasCommand = true; + } + } + if (itemHasCommand) { this._onDidClick.fire({ type: ClickTargetType.ContributedCommandItem, event: e