Path: blob/main/extensions/ipynb/src/notebookModelStoreSync.ts
3292 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Disposable, ExtensionContext, NotebookCellKind, NotebookDocument, NotebookDocumentChangeEvent, NotebookEdit, workspace, WorkspaceEdit, type NotebookCell, type NotebookDocumentWillSaveEvent } from 'vscode';6import { getCellMetadata, getVSCodeCellLanguageId, removeVSCodeCellLanguageId, setVSCodeCellLanguageId, sortObjectPropertiesRecursively, getNotebookMetadata } from './serializers';7import { CellMetadata } from './common';8import type * as nbformat from '@jupyterlab/nbformat';9import { generateUuid } from './helper';1011const noop = () => {12//13};1415/**16* Code here is used to ensure the Notebook Model is in sync the ipynb JSON file.17* E.g. assume you add a new cell, this new cell will not have any metadata at all.18* However when we save the ipynb, the metadata will be an empty object `{}`.19* Now thats completely different from the metadata os being `empty/undefined` in the model.20* As a result, when looking at things like diff view or accessing metadata, we'll see differences.21*22* This code ensures that the model is in sync with the ipynb file.23*/24export const pendingNotebookCellModelUpdates = new WeakMap<NotebookDocument, Set<Thenable<void>>>();25export function activate(context: ExtensionContext) {26workspace.onDidChangeNotebookDocument(onDidChangeNotebookCells, undefined, context.subscriptions);27workspace.onWillSaveNotebookDocument(waitForPendingModelUpdates, undefined, context.subscriptions);28}2930type NotebookDocumentChangeEventEx = Omit<NotebookDocumentChangeEvent, 'metadata'>;31let mergedEvents: NotebookDocumentChangeEventEx | undefined;32let timer: NodeJS.Timeout;3334function triggerDebouncedNotebookDocumentChangeEvent() {35if (timer) {36clearTimeout(timer);37}38if (!mergedEvents) {39return;40}41const args = mergedEvents;42mergedEvents = undefined;43onDidChangeNotebookCells(args);44}4546export function debounceOnDidChangeNotebookDocument() {47const disposable = workspace.onDidChangeNotebookDocument(e => {48if (!isSupportedNotebook(e.notebook)) {49return;50}51if (!mergedEvents) {52mergedEvents = e;53} else if (mergedEvents.notebook === e.notebook) {54// Same notebook, we can merge the updates.55mergedEvents = {56cellChanges: e.cellChanges.concat(mergedEvents.cellChanges),57contentChanges: e.contentChanges.concat(mergedEvents.contentChanges),58notebook: e.notebook59};60} else {61// Different notebooks, we cannot merge the updates.62// Hence we need to process the previous notebook and start a new timer for the new notebook.63triggerDebouncedNotebookDocumentChangeEvent();64// Start a new timer for the new notebook.65mergedEvents = e;66}67if (timer) {68clearTimeout(timer);69}70timer = setTimeout(triggerDebouncedNotebookDocumentChangeEvent, 200);71});727374return Disposable.from(disposable, new Disposable(() => {75clearTimeout(timer);76}));77}7879function isSupportedNotebook(notebook: NotebookDocument) {80return notebook.notebookType === 'jupyter-notebook';81}8283function waitForPendingModelUpdates(e: NotebookDocumentWillSaveEvent) {84if (!isSupportedNotebook(e.notebook)) {85return;86}8788triggerDebouncedNotebookDocumentChangeEvent();89const promises = pendingNotebookCellModelUpdates.get(e.notebook);90if (!promises) {91return;92}93e.waitUntil(Promise.all(promises));94}9596function cleanup(notebook: NotebookDocument, promise: PromiseLike<void>) {97const pendingUpdates = pendingNotebookCellModelUpdates.get(notebook);98if (pendingUpdates) {99pendingUpdates.delete(promise);100if (!pendingUpdates.size) {101pendingNotebookCellModelUpdates.delete(notebook);102}103}104}105function trackAndUpdateCellMetadata(notebook: NotebookDocument, updates: { cell: NotebookCell; metadata: CellMetadata & { vscode?: { languageId: string } } }[]) {106const pendingUpdates = pendingNotebookCellModelUpdates.get(notebook) ?? new Set<Thenable<void>>();107pendingNotebookCellModelUpdates.set(notebook, pendingUpdates);108const edit = new WorkspaceEdit();109updates.forEach(({ cell, metadata }) => {110const newMetadata = { ...cell.metadata, ...metadata };111if (!metadata.execution_count && newMetadata.execution_count) {112newMetadata.execution_count = null;113}114if (!metadata.attachments && newMetadata.attachments) {115delete newMetadata.attachments;116}117edit.set(cell.notebook.uri, [NotebookEdit.updateCellMetadata(cell.index, sortObjectPropertiesRecursively(newMetadata))]);118});119const promise = workspace.applyEdit(edit).then(noop, noop);120pendingUpdates.add(promise);121const clean = () => cleanup(notebook, promise);122promise.then(clean, clean);123}124125const pendingCellUpdates = new WeakSet<NotebookCell>();126function onDidChangeNotebookCells(e: NotebookDocumentChangeEventEx) {127if (!isSupportedNotebook(e.notebook)) {128return;129}130131const notebook = e.notebook;132const notebookMetadata = getNotebookMetadata(e.notebook);133134// use the preferred language from document metadata or the first cell language as the notebook preferred cell language135const preferredCellLanguage = notebookMetadata.metadata?.language_info?.name;136const updates: { cell: NotebookCell; metadata: CellMetadata & { vscode?: { languageId: string } } }[] = [];137// When we change the language of a cell,138// Ensure the metadata in the notebook cell has been updated as well,139// Else model will be out of sync with ipynb https://github.com/microsoft/vscode/issues/207968#issuecomment-2002858596140e.cellChanges.forEach(e => {141if (!preferredCellLanguage || e.cell.kind !== NotebookCellKind.Code) {142return;143}144const currentMetadata = e.metadata ? getCellMetadata({ metadata: e.metadata }) : getCellMetadata({ cell: e.cell });145const languageIdInMetadata = getVSCodeCellLanguageId(currentMetadata);146const metadata: CellMetadata = JSON.parse(JSON.stringify(currentMetadata));147metadata.metadata = metadata.metadata || {};148let metadataUpdated = false;149if (e.executionSummary?.executionOrder && typeof e.executionSummary.success === 'boolean' && currentMetadata.execution_count !== e.executionSummary?.executionOrder) {150metadata.execution_count = e.executionSummary.executionOrder;151metadataUpdated = true;152} else if (!e.executionSummary && !e.metadata && e.outputs?.length === 0 && currentMetadata.execution_count) {153// Clear all (user hit clear all).154// NOTE: At this point we're updating the `execution_count` in metadata to `null`.155// Thus this is a change in metadata, which we will need to update in the model.156metadata.execution_count = null;157metadataUpdated = true;158// Note: We will get another event for this, see below for the check.159// track the fact that we're expecting an update for this cell.160pendingCellUpdates.add(e.cell);161} else if ((!e.executionSummary || (!e.executionSummary?.executionOrder && !e.executionSummary?.success && !e.executionSummary?.timing))162&& !e.metadata && !e.outputs && currentMetadata.execution_count && pendingCellUpdates.has(e.cell)) {163// This is a result of the cell being cleared (i.e. we perfomed an update request and this is now the update event).164metadata.execution_count = null;165metadataUpdated = true;166pendingCellUpdates.delete(e.cell);167} else if (!e.executionSummary?.executionOrder && !e.executionSummary?.success && !e.executionSummary?.timing168&& !e.metadata && !e.outputs && currentMetadata.execution_count && !pendingCellUpdates.has(e.cell)) {169// This is a result of the cell without outupts but has execution count being cleared170// Create two cells, one that produces output and one that doesn't. Run both and then clear the output or all cells.171// This condition will be satisfied for first cell without outputs.172metadata.execution_count = null;173metadataUpdated = true;174}175176if (e.document?.languageId && e.document?.languageId !== preferredCellLanguage && e.document?.languageId !== languageIdInMetadata) {177setVSCodeCellLanguageId(metadata, e.document.languageId);178metadataUpdated = true;179} else if (e.document?.languageId && e.document.languageId === preferredCellLanguage && languageIdInMetadata) {180removeVSCodeCellLanguageId(metadata);181metadataUpdated = true;182} else if (e.document?.languageId && e.document.languageId === preferredCellLanguage && e.document.languageId === languageIdInMetadata) {183removeVSCodeCellLanguageId(metadata);184metadataUpdated = true;185}186187if (metadataUpdated) {188updates.push({ cell: e.cell, metadata });189}190});191192// Ensure all new cells in notebooks with nbformat >= 4.5 have an id.193// Details of the spec can be found here https://jupyter.org/enhancement-proposals/62-cell-id/cell-id.html#194e.contentChanges.forEach(change => {195change.addedCells.forEach(cell => {196// When ever a cell is added, always update the metadata197// as metadata is always an empty `{}` in ipynb JSON file198const cellMetadata = getCellMetadata({ cell });199200// Avoid updating the metadata if it's not required.201if (cellMetadata.metadata) {202if (!isCellIdRequired(notebookMetadata)) {203return;204}205if (isCellIdRequired(notebookMetadata) && cellMetadata?.id) {206return;207}208}209210// Don't edit the metadata directly, always get a clone (prevents accidental singletons and directly editing the objects).211const metadata: CellMetadata = { ...JSON.parse(JSON.stringify(cellMetadata || {})) };212metadata.metadata = metadata.metadata || {};213214if (isCellIdRequired(notebookMetadata) && !cellMetadata?.id) {215metadata.id = generateCellId(e.notebook);216}217updates.push({ cell, metadata });218});219});220221if (updates.length) {222trackAndUpdateCellMetadata(notebook, updates);223}224}225226227/**228* Cell ids are required in notebooks only in notebooks with nbformat >= 4.5229*/230function isCellIdRequired(metadata: Pick<Partial<nbformat.INotebookContent>, 'nbformat' | 'nbformat_minor'>) {231if ((metadata.nbformat || 0) >= 5) {232return true;233}234if ((metadata.nbformat || 0) === 4 && (metadata.nbformat_minor || 0) >= 5) {235return true;236}237return false;238}239240function generateCellId(notebook: NotebookDocument) {241while (true) {242// Details of the id can be found here https://jupyter.org/enhancement-proposals/62-cell-id/cell-id.html#adding-an-id-field,243// & here https://jupyter.org/enhancement-proposals/62-cell-id/cell-id.html#updating-older-formats244const id = generateUuid().replace(/-/g, '').substring(0, 8);245let duplicate = false;246for (let index = 0; index < notebook.cellCount; index++) {247const cell = notebook.cellAt(index);248const existingId = getCellMetadata({ cell })?.id;249if (!existingId) {250continue;251}252if (existingId === id) {253duplicate = true;254break;255}256}257if (!duplicate) {258return id;259}260}261}262263264265