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