Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts
3296 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 { Emitter, Event } from '../../../../../base/common/event.js';
7
import { hash, StringSHA1 } from '../../../../../base/common/hash.js';
8
import { Disposable, DisposableStore, dispose } from '../../../../../base/common/lifecycle.js';
9
import { URI } from '../../../../../base/common/uri.js';
10
import * as UUID from '../../../../../base/common/uuid.js';
11
import { Range } from '../../../../../editor/common/core/range.js';
12
import * as model from '../../../../../editor/common/model.js';
13
import { PieceTreeTextBuffer } from '../../../../../editor/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.js';
14
import { createTextBuffer, TextModel } from '../../../../../editor/common/model/textModel.js';
15
import { PLAINTEXT_LANGUAGE_ID } from '../../../../../editor/common/languages/modesRegistry.js';
16
import { ILanguageService } from '../../../../../editor/common/languages/language.js';
17
import { NotebookCellOutputTextModel } from './notebookCellOutputTextModel.js';
18
import { CellInternalMetadataChangedEvent, CellKind, ICell, ICellDto2, ICellOutput, IOutputDto, IOutputItemDto, NotebookCellCollapseState, NotebookCellInternalMetadata, NotebookCellMetadata, NotebookCellOutputsSplice, TransientCellMetadata, TransientOptions } from '../notebookCommon.js';
19
import { ThrottledDelayer } from '../../../../../base/common/async.js';
20
import { ILanguageDetectionService } from '../../../../services/languageDetection/common/languageDetectionWorkerService.js';
21
import { toFormattedString } from '../../../../../base/common/jsonFormatter.js';
22
import { IModelContentChangedEvent } from '../../../../../editor/common/textModelEvents.js';
23
import { splitLines } from '../../../../../base/common/strings.js';
24
25
export class NotebookCellTextModel extends Disposable implements ICell {
26
private readonly _onDidChangeTextModel = this._register(new Emitter<void>());
27
readonly onDidChangeTextModel: Event<void> = this._onDidChangeTextModel.event;
28
private readonly _onDidChangeOutputs = this._register(new Emitter<NotebookCellOutputsSplice>());
29
readonly onDidChangeOutputs: Event<NotebookCellOutputsSplice> = this._onDidChangeOutputs.event;
30
31
private readonly _onDidChangeOutputItems = this._register(new Emitter<void>());
32
readonly onDidChangeOutputItems: Event<void> = this._onDidChangeOutputItems.event;
33
34
private readonly _onDidChangeContent = this._register(new Emitter<'content' | 'language' | 'mime' | { type: 'model'; event: IModelContentChangedEvent }>());
35
readonly onDidChangeContent: Event<'content' | 'language' | 'mime' | { type: 'model'; event: IModelContentChangedEvent }> = this._onDidChangeContent.event;
36
37
private readonly _onDidChangeMetadata = this._register(new Emitter<void>());
38
readonly onDidChangeMetadata: Event<void> = this._onDidChangeMetadata.event;
39
40
private readonly _onDidChangeInternalMetadata = this._register(new Emitter<CellInternalMetadataChangedEvent>());
41
readonly onDidChangeInternalMetadata: Event<CellInternalMetadataChangedEvent> = this._onDidChangeInternalMetadata.event;
42
43
private readonly _onDidChangeLanguage = this._register(new Emitter<string>());
44
readonly onDidChangeLanguage: Event<string> = this._onDidChangeLanguage.event;
45
46
private _outputs: NotebookCellOutputTextModel[];
47
48
get outputs(): ICellOutput[] {
49
return this._outputs;
50
}
51
52
private _metadata: NotebookCellMetadata;
53
54
get metadata() {
55
return this._metadata;
56
}
57
58
set metadata(newMetadata: NotebookCellMetadata) {
59
this._metadata = newMetadata;
60
this._hash = null;
61
this._onDidChangeMetadata.fire();
62
}
63
64
private _internalMetadata: NotebookCellInternalMetadata;
65
66
get internalMetadata() {
67
return this._internalMetadata;
68
}
69
70
set internalMetadata(newInternalMetadata: NotebookCellInternalMetadata) {
71
const lastRunSuccessChanged = this._internalMetadata.lastRunSuccess !== newInternalMetadata.lastRunSuccess;
72
newInternalMetadata = {
73
...newInternalMetadata,
74
...{ runStartTimeAdjustment: computeRunStartTimeAdjustment(this._internalMetadata, newInternalMetadata) }
75
};
76
this._internalMetadata = newInternalMetadata;
77
this._hash = null;
78
this._onDidChangeInternalMetadata.fire({ lastRunSuccessChanged });
79
}
80
81
get language() {
82
return this._language;
83
}
84
85
set language(newLanguage: string) {
86
if (this._textModel
87
// 1. the language update is from workspace edit, checking if it's the same as text model's mode
88
&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(newLanguage)
89
// 2. the text model's mode might be the same as the `this.language`, even if the language friendly name is not the same, we should not trigger an update
90
&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(this.language)) {
91
return;
92
}
93
94
95
this._hasLanguageSetExplicitly = true;
96
this._setLanguageInternal(newLanguage);
97
}
98
99
public get mime(): string | undefined {
100
return this._mime;
101
}
102
103
public set mime(newMime: string | undefined) {
104
if (this._mime === newMime) {
105
return;
106
}
107
this._mime = newMime;
108
this._hash = null;
109
this._onDidChangeContent.fire('mime');
110
}
111
112
private _textBuffer!: model.ITextBuffer;
113
114
get textBuffer() {
115
if (this._textBuffer) {
116
return this._textBuffer;
117
}
118
119
this._textBuffer = this._register(createTextBuffer(this._source, this._defaultEOL).textBuffer);
120
121
this._register(this._textBuffer.onDidChangeContent(() => {
122
this._hash = null;
123
if (!this._textModel) {
124
this._onDidChangeContent.fire('content');
125
}
126
this.autoDetectLanguage();
127
}));
128
129
return this._textBuffer;
130
}
131
132
private _textBufferHash: string | null = null;
133
private _hash: number | null = null;
134
135
private _versionId: number = 1;
136
private _alternativeId: number = 1;
137
get alternativeId(): number {
138
return this._alternativeId;
139
}
140
141
private readonly _textModelDisposables = this._register(new DisposableStore());
142
private _textModel: TextModel | undefined = undefined;
143
get textModel(): TextModel | undefined {
144
return this._textModel;
145
}
146
147
set textModel(m: TextModel | undefined) {
148
if (this._textModel === m) {
149
return;
150
}
151
152
this._textModelDisposables.clear();
153
this._textModel = m;
154
if (this._textModel) {
155
this.setRegisteredLanguage(this._languageService, this._textModel.getLanguageId(), this.language);
156
157
// Listen to language changes on the model
158
this._textModelDisposables.add(this._textModel.onDidChangeLanguage((e) => this.setRegisteredLanguage(this._languageService, e.newLanguage, this.language)));
159
this._textModelDisposables.add(this._textModel.onWillDispose(() => this.textModel = undefined));
160
this._textModelDisposables.add(this._textModel.onDidChangeContent((e) => {
161
if (this._textModel) {
162
this._versionId = this._textModel.getVersionId();
163
this._alternativeId = this._textModel.getAlternativeVersionId();
164
}
165
this._textBufferHash = null;
166
this._onDidChangeContent.fire('content');
167
this._onDidChangeContent.fire({ type: 'model', event: e });
168
}));
169
170
this._textModel._overwriteVersionId(this._versionId);
171
this._textModel._overwriteAlternativeVersionId(this._versionId);
172
this._onDidChangeTextModel.fire();
173
}
174
}
175
176
private setRegisteredLanguage(languageService: ILanguageService, newLanguage: string, currentLanguage: string) {
177
// The language defined in the cell might not be supported in the editor so the text model might be using the default fallback
178
// If so let's not modify the language
179
const isFallBackLanguage = (newLanguage === PLAINTEXT_LANGUAGE_ID || newLanguage === 'jupyter');
180
if (!languageService.isRegisteredLanguageId(currentLanguage) && isFallBackLanguage) {
181
// notify to display warning, but don't change the language
182
this._onDidChangeLanguage.fire(currentLanguage);
183
} else {
184
this.language = newLanguage;
185
}
186
}
187
private static readonly AUTO_DETECT_LANGUAGE_THROTTLE_DELAY = 600;
188
private readonly autoDetectLanguageThrottler = this._register(new ThrottledDelayer<void>(NotebookCellTextModel.AUTO_DETECT_LANGUAGE_THROTTLE_DELAY));
189
private _autoLanguageDetectionEnabled: boolean = false;
190
private _hasLanguageSetExplicitly: boolean = false;
191
get hasLanguageSetExplicitly(): boolean { return this._hasLanguageSetExplicitly; }
192
193
constructor(
194
readonly uri: URI,
195
public readonly handle: number,
196
private readonly _source: string,
197
private _language: string,
198
private _mime: string | undefined,
199
public readonly cellKind: CellKind,
200
outputs: IOutputDto[],
201
metadata: NotebookCellMetadata | undefined,
202
internalMetadata: NotebookCellInternalMetadata | undefined,
203
public readonly collapseState: NotebookCellCollapseState | undefined,
204
public readonly transientOptions: TransientOptions,
205
private readonly _languageService: ILanguageService,
206
private readonly _defaultEOL: model.DefaultEndOfLine,
207
private readonly _languageDetectionService: ILanguageDetectionService | undefined = undefined,
208
) {
209
super();
210
this._outputs = outputs.map(op => new NotebookCellOutputTextModel(op));
211
this._metadata = metadata ?? {};
212
this._internalMetadata = internalMetadata ?? {};
213
}
214
215
enableAutoLanguageDetection() {
216
this._autoLanguageDetectionEnabled = true;
217
this.autoDetectLanguage();
218
}
219
220
async autoDetectLanguage(): Promise<void> {
221
if (this._autoLanguageDetectionEnabled) {
222
this.autoDetectLanguageThrottler.trigger(() => this._doAutoDetectLanguage());
223
}
224
}
225
226
private async _doAutoDetectLanguage(): Promise<void> {
227
if (this.hasLanguageSetExplicitly) {
228
return;
229
}
230
231
const newLanguage = await this._languageDetectionService?.detectLanguage(this.uri);
232
if (!newLanguage) {
233
return;
234
}
235
236
if (this._textModel
237
&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(newLanguage)
238
&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(this.language)) {
239
return;
240
}
241
242
this._setLanguageInternal(newLanguage);
243
}
244
245
private _setLanguageInternal(newLanguage: string) {
246
const newLanguageId = this._languageService.getLanguageIdByLanguageName(newLanguage);
247
248
if (newLanguageId === null) {
249
return;
250
}
251
252
if (this._textModel) {
253
const languageId = this._languageService.createById(newLanguageId);
254
this._textModel.setLanguage(languageId.languageId);
255
}
256
257
if (this._language === newLanguage) {
258
return;
259
}
260
261
this._language = newLanguage;
262
this._hash = null;
263
this._onDidChangeLanguage.fire(newLanguage);
264
this._onDidChangeContent.fire('language');
265
}
266
267
resetTextBuffer(textBuffer: model.ITextBuffer) {
268
this._textBuffer = textBuffer;
269
}
270
271
getValue(): string {
272
const fullRange = this.getFullModelRange();
273
const eol = this.textBuffer.getEOL();
274
if (eol === '\n') {
275
return this.textBuffer.getValueInRange(fullRange, model.EndOfLinePreference.LF);
276
} else {
277
return this.textBuffer.getValueInRange(fullRange, model.EndOfLinePreference.CRLF);
278
}
279
}
280
281
getTextBufferHash() {
282
if (this._textBufferHash !== null) {
283
return this._textBufferHash;
284
}
285
286
const shaComputer = new StringSHA1();
287
const snapshot = this.textBuffer.createSnapshot(false);
288
let text: string | null;
289
while ((text = snapshot.read())) {
290
shaComputer.update(text);
291
}
292
this._textBufferHash = shaComputer.digest();
293
return this._textBufferHash;
294
}
295
296
getHashValue(): number {
297
if (this._hash !== null) {
298
return this._hash;
299
}
300
301
this._hash = hash([hash(this.language), this.getTextBufferHash(), this._getPersisentMetadata(), this.transientOptions.transientOutputs ? [] : this._outputs.map(op => ({
302
outputs: op.outputs.map(output => ({
303
mime: output.mime,
304
data: Array.from(output.data.buffer)
305
})),
306
metadata: op.metadata
307
}))]);
308
return this._hash;
309
}
310
311
private _getPersisentMetadata() {
312
return getFormattedMetadataJSON(this.transientOptions.transientCellMetadata, this.metadata, this.language);
313
}
314
315
getTextLength(): number {
316
return this.textBuffer.getLength();
317
}
318
319
getFullModelRange() {
320
const lineCount = this.textBuffer.getLineCount();
321
return new Range(1, 1, lineCount, this.textBuffer.getLineLength(lineCount) + 1);
322
}
323
324
spliceNotebookCellOutputs(splice: NotebookCellOutputsSplice): void {
325
if (splice.deleteCount > 0 && splice.newOutputs.length > 0) {
326
const commonLen = Math.min(splice.deleteCount, splice.newOutputs.length);
327
// update
328
for (let i = 0; i < commonLen; i++) {
329
const currentOutput = this.outputs[splice.start + i];
330
const newOutput = splice.newOutputs[i];
331
332
this.replaceOutput(currentOutput.outputId, newOutput);
333
}
334
335
const removed = this.outputs.splice(splice.start + commonLen, splice.deleteCount - commonLen, ...splice.newOutputs.slice(commonLen));
336
removed.forEach(output => output.dispose());
337
this._onDidChangeOutputs.fire({ start: splice.start + commonLen, deleteCount: splice.deleteCount - commonLen, newOutputs: splice.newOutputs.slice(commonLen) });
338
} else {
339
const removed = this.outputs.splice(splice.start, splice.deleteCount, ...splice.newOutputs);
340
removed.forEach(output => output.dispose());
341
this._onDidChangeOutputs.fire(splice);
342
}
343
}
344
345
replaceOutput(outputId: string, newOutputItem: ICellOutput) {
346
const outputIndex = this.outputs.findIndex(output => output.outputId === outputId);
347
348
if (outputIndex < 0) {
349
return false;
350
}
351
352
const output = this.outputs[outputIndex];
353
// convert to dto and dispose the cell output model
354
output.replaceData({
355
outputs: newOutputItem.outputs,
356
outputId: newOutputItem.outputId,
357
metadata: newOutputItem.metadata
358
});
359
newOutputItem.dispose();
360
this._onDidChangeOutputItems.fire();
361
return true;
362
}
363
364
changeOutputItems(outputId: string, append: boolean, items: IOutputItemDto[]): boolean {
365
const outputIndex = this.outputs.findIndex(output => output.outputId === outputId);
366
367
if (outputIndex < 0) {
368
return false;
369
}
370
371
const output = this.outputs[outputIndex];
372
if (append) {
373
output.appendData(items);
374
} else {
375
output.replaceData({ outputId: outputId, outputs: items, metadata: output.metadata });
376
}
377
this._onDidChangeOutputItems.fire();
378
return true;
379
}
380
381
private _outputNotEqualFastCheck(left: ICellOutput[], right: ICellOutput[]) {
382
if (left.length !== right.length) {
383
return false;
384
}
385
386
for (let i = 0; i < this.outputs.length; i++) {
387
const l = left[i];
388
const r = right[i];
389
390
if (l.outputs.length !== r.outputs.length) {
391
return false;
392
}
393
394
for (let k = 0; k < l.outputs.length; k++) {
395
if (l.outputs[k].mime !== r.outputs[k].mime) {
396
return false;
397
}
398
399
if (l.outputs[k].data.byteLength !== r.outputs[k].data.byteLength) {
400
return false;
401
}
402
}
403
}
404
405
return true;
406
}
407
408
equal(b: NotebookCellTextModel): boolean {
409
if (this.language !== b.language) {
410
return false;
411
}
412
413
if (this.outputs.length !== b.outputs.length) {
414
return false;
415
}
416
417
if (this.getTextLength() !== b.getTextLength()) {
418
return false;
419
}
420
421
if (!this.transientOptions.transientOutputs) {
422
// compare outputs
423
424
if (!this._outputNotEqualFastCheck(this.outputs, b.outputs)) {
425
return false;
426
}
427
}
428
429
return this.getHashValue() === b.getHashValue();
430
}
431
432
/**
433
* Only compares
434
* - language
435
* - mime
436
* - cellKind
437
* - internal metadata (conditionally)
438
* - source
439
*/
440
fastEqual(b: ICellDto2, ignoreMetadata: boolean): boolean {
441
if (this.language !== b.language) {
442
return false;
443
}
444
445
if (this.mime !== b.mime) {
446
return false;
447
}
448
449
if (this.cellKind !== b.cellKind) {
450
return false;
451
}
452
453
if (!ignoreMetadata) {
454
if (this.internalMetadata?.executionOrder !== b.internalMetadata?.executionOrder
455
|| this.internalMetadata?.lastRunSuccess !== b.internalMetadata?.lastRunSuccess
456
|| this.internalMetadata?.runStartTime !== b.internalMetadata?.runStartTime
457
|| this.internalMetadata?.runStartTimeAdjustment !== b.internalMetadata?.runStartTimeAdjustment
458
|| this.internalMetadata?.runEndTime !== b.internalMetadata?.runEndTime) {
459
return false;
460
}
461
}
462
463
// Once we attach the cell text buffer to an editor, the source of truth is the text buffer instead of the original source
464
if (this._textBuffer) {
465
if (!NotebookCellTextModel.linesAreEqual(this.textBuffer.getLinesContent(), b.source)) {
466
return false;
467
}
468
} else if (this._source !== b.source) {
469
return false;
470
}
471
472
return true;
473
}
474
475
private static linesAreEqual(aLines: string[], b: string) {
476
const bLines = splitLines(b);
477
if (aLines.length !== bLines.length) {
478
return false;
479
}
480
for (let i = 0; i < aLines.length; i++) {
481
if (aLines[i] !== bLines[i]) {
482
return false;
483
}
484
}
485
return true;
486
}
487
488
override dispose() {
489
dispose(this._outputs);
490
// Manually release reference to previous text buffer to avoid large leaks
491
// in case someone leaks a CellTextModel reference
492
const emptyDisposedTextBuffer = new PieceTreeTextBuffer([], '', '\n', false, false, true, true);
493
emptyDisposedTextBuffer.dispose();
494
this._textBuffer = emptyDisposedTextBuffer;
495
super.dispose();
496
}
497
}
498
499
export function cloneNotebookCellTextModel(cell: NotebookCellTextModel) {
500
return {
501
source: cell.getValue(),
502
language: cell.language,
503
mime: cell.mime,
504
cellKind: cell.cellKind,
505
outputs: cell.outputs.map(output => ({
506
outputs: output.outputs,
507
/* paste should generate new outputId */ outputId: UUID.generateUuid()
508
})),
509
metadata: {}
510
};
511
}
512
513
function computeRunStartTimeAdjustment(oldMetadata: NotebookCellInternalMetadata, newMetadata: NotebookCellInternalMetadata): number | undefined {
514
if (oldMetadata.runStartTime !== newMetadata.runStartTime && typeof newMetadata.runStartTime === 'number') {
515
const offset = Date.now() - newMetadata.runStartTime;
516
return offset < 0 ? Math.abs(offset) : 0;
517
} else {
518
return newMetadata.runStartTimeAdjustment;
519
}
520
}
521
522
523
export function getFormattedMetadataJSON(transientCellMetadata: TransientCellMetadata | undefined, metadata: NotebookCellMetadata, language?: string, sortKeys?: boolean): string {
524
let filteredMetadata: { [key: string]: any } = {};
525
526
if (transientCellMetadata) {
527
const keys = new Set([...Object.keys(metadata)]);
528
for (const key of keys) {
529
if (!(transientCellMetadata[key as keyof NotebookCellMetadata])
530
) {
531
filteredMetadata[key] = metadata[key as keyof NotebookCellMetadata];
532
}
533
}
534
} else {
535
filteredMetadata = metadata;
536
}
537
538
const obj = {
539
language,
540
...filteredMetadata
541
};
542
// Give preference to the language we have been given.
543
// Metadata can contain `language` due to round-tripping of cell metadata.
544
// I.e. we add it here, and then from SCM when we revert the cell, we get this same metadata back with the `language` property.
545
if (language) {
546
obj.language = language;
547
}
548
const metadataSource = toFormattedString(sortKeys ? sortObjectPropertiesRecursively(obj) : obj, {});
549
550
return metadataSource;
551
}
552
553
554
/**
555
* Sort the JSON to ensure when diffing, the JSON keys are sorted & matched correctly in diff view.
556
*/
557
export function sortObjectPropertiesRecursively(obj: any): any {
558
if (Array.isArray(obj)) {
559
return obj.map(sortObjectPropertiesRecursively);
560
}
561
if (obj !== undefined && obj !== null && typeof obj === 'object' && Object.keys(obj).length > 0) {
562
return (
563
Object.keys(obj)
564
.sort()
565
.reduce<Record<string, any>>((sortedObj, prop) => {
566
sortedObj[prop] = sortObjectPropertiesRecursively(obj[prop]);
567
return sortedObj;
568
}, {}) as any
569
);
570
}
571
return obj;
572
}
573
574