Path: blob/main/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts
3296 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 { Emitter, Event } from '../../../../../base/common/event.js';6import { hash, StringSHA1 } from '../../../../../base/common/hash.js';7import { Disposable, DisposableStore, dispose } from '../../../../../base/common/lifecycle.js';8import { URI } from '../../../../../base/common/uri.js';9import * as UUID from '../../../../../base/common/uuid.js';10import { Range } from '../../../../../editor/common/core/range.js';11import * as model from '../../../../../editor/common/model.js';12import { PieceTreeTextBuffer } from '../../../../../editor/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.js';13import { createTextBuffer, TextModel } from '../../../../../editor/common/model/textModel.js';14import { PLAINTEXT_LANGUAGE_ID } from '../../../../../editor/common/languages/modesRegistry.js';15import { ILanguageService } from '../../../../../editor/common/languages/language.js';16import { NotebookCellOutputTextModel } from './notebookCellOutputTextModel.js';17import { CellInternalMetadataChangedEvent, CellKind, ICell, ICellDto2, ICellOutput, IOutputDto, IOutputItemDto, NotebookCellCollapseState, NotebookCellInternalMetadata, NotebookCellMetadata, NotebookCellOutputsSplice, TransientCellMetadata, TransientOptions } from '../notebookCommon.js';18import { ThrottledDelayer } from '../../../../../base/common/async.js';19import { ILanguageDetectionService } from '../../../../services/languageDetection/common/languageDetectionWorkerService.js';20import { toFormattedString } from '../../../../../base/common/jsonFormatter.js';21import { IModelContentChangedEvent } from '../../../../../editor/common/textModelEvents.js';22import { splitLines } from '../../../../../base/common/strings.js';2324export class NotebookCellTextModel extends Disposable implements ICell {25private readonly _onDidChangeTextModel = this._register(new Emitter<void>());26readonly onDidChangeTextModel: Event<void> = this._onDidChangeTextModel.event;27private readonly _onDidChangeOutputs = this._register(new Emitter<NotebookCellOutputsSplice>());28readonly onDidChangeOutputs: Event<NotebookCellOutputsSplice> = this._onDidChangeOutputs.event;2930private readonly _onDidChangeOutputItems = this._register(new Emitter<void>());31readonly onDidChangeOutputItems: Event<void> = this._onDidChangeOutputItems.event;3233private readonly _onDidChangeContent = this._register(new Emitter<'content' | 'language' | 'mime' | { type: 'model'; event: IModelContentChangedEvent }>());34readonly onDidChangeContent: Event<'content' | 'language' | 'mime' | { type: 'model'; event: IModelContentChangedEvent }> = this._onDidChangeContent.event;3536private readonly _onDidChangeMetadata = this._register(new Emitter<void>());37readonly onDidChangeMetadata: Event<void> = this._onDidChangeMetadata.event;3839private readonly _onDidChangeInternalMetadata = this._register(new Emitter<CellInternalMetadataChangedEvent>());40readonly onDidChangeInternalMetadata: Event<CellInternalMetadataChangedEvent> = this._onDidChangeInternalMetadata.event;4142private readonly _onDidChangeLanguage = this._register(new Emitter<string>());43readonly onDidChangeLanguage: Event<string> = this._onDidChangeLanguage.event;4445private _outputs: NotebookCellOutputTextModel[];4647get outputs(): ICellOutput[] {48return this._outputs;49}5051private _metadata: NotebookCellMetadata;5253get metadata() {54return this._metadata;55}5657set metadata(newMetadata: NotebookCellMetadata) {58this._metadata = newMetadata;59this._hash = null;60this._onDidChangeMetadata.fire();61}6263private _internalMetadata: NotebookCellInternalMetadata;6465get internalMetadata() {66return this._internalMetadata;67}6869set internalMetadata(newInternalMetadata: NotebookCellInternalMetadata) {70const lastRunSuccessChanged = this._internalMetadata.lastRunSuccess !== newInternalMetadata.lastRunSuccess;71newInternalMetadata = {72...newInternalMetadata,73...{ runStartTimeAdjustment: computeRunStartTimeAdjustment(this._internalMetadata, newInternalMetadata) }74};75this._internalMetadata = newInternalMetadata;76this._hash = null;77this._onDidChangeInternalMetadata.fire({ lastRunSuccessChanged });78}7980get language() {81return this._language;82}8384set language(newLanguage: string) {85if (this._textModel86// 1. the language update is from workspace edit, checking if it's the same as text model's mode87&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(newLanguage)88// 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 update89&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(this.language)) {90return;91}929394this._hasLanguageSetExplicitly = true;95this._setLanguageInternal(newLanguage);96}9798public get mime(): string | undefined {99return this._mime;100}101102public set mime(newMime: string | undefined) {103if (this._mime === newMime) {104return;105}106this._mime = newMime;107this._hash = null;108this._onDidChangeContent.fire('mime');109}110111private _textBuffer!: model.ITextBuffer;112113get textBuffer() {114if (this._textBuffer) {115return this._textBuffer;116}117118this._textBuffer = this._register(createTextBuffer(this._source, this._defaultEOL).textBuffer);119120this._register(this._textBuffer.onDidChangeContent(() => {121this._hash = null;122if (!this._textModel) {123this._onDidChangeContent.fire('content');124}125this.autoDetectLanguage();126}));127128return this._textBuffer;129}130131private _textBufferHash: string | null = null;132private _hash: number | null = null;133134private _versionId: number = 1;135private _alternativeId: number = 1;136get alternativeId(): number {137return this._alternativeId;138}139140private readonly _textModelDisposables = this._register(new DisposableStore());141private _textModel: TextModel | undefined = undefined;142get textModel(): TextModel | undefined {143return this._textModel;144}145146set textModel(m: TextModel | undefined) {147if (this._textModel === m) {148return;149}150151this._textModelDisposables.clear();152this._textModel = m;153if (this._textModel) {154this.setRegisteredLanguage(this._languageService, this._textModel.getLanguageId(), this.language);155156// Listen to language changes on the model157this._textModelDisposables.add(this._textModel.onDidChangeLanguage((e) => this.setRegisteredLanguage(this._languageService, e.newLanguage, this.language)));158this._textModelDisposables.add(this._textModel.onWillDispose(() => this.textModel = undefined));159this._textModelDisposables.add(this._textModel.onDidChangeContent((e) => {160if (this._textModel) {161this._versionId = this._textModel.getVersionId();162this._alternativeId = this._textModel.getAlternativeVersionId();163}164this._textBufferHash = null;165this._onDidChangeContent.fire('content');166this._onDidChangeContent.fire({ type: 'model', event: e });167}));168169this._textModel._overwriteVersionId(this._versionId);170this._textModel._overwriteAlternativeVersionId(this._versionId);171this._onDidChangeTextModel.fire();172}173}174175private setRegisteredLanguage(languageService: ILanguageService, newLanguage: string, currentLanguage: string) {176// The language defined in the cell might not be supported in the editor so the text model might be using the default fallback177// If so let's not modify the language178const isFallBackLanguage = (newLanguage === PLAINTEXT_LANGUAGE_ID || newLanguage === 'jupyter');179if (!languageService.isRegisteredLanguageId(currentLanguage) && isFallBackLanguage) {180// notify to display warning, but don't change the language181this._onDidChangeLanguage.fire(currentLanguage);182} else {183this.language = newLanguage;184}185}186private static readonly AUTO_DETECT_LANGUAGE_THROTTLE_DELAY = 600;187private readonly autoDetectLanguageThrottler = this._register(new ThrottledDelayer<void>(NotebookCellTextModel.AUTO_DETECT_LANGUAGE_THROTTLE_DELAY));188private _autoLanguageDetectionEnabled: boolean = false;189private _hasLanguageSetExplicitly: boolean = false;190get hasLanguageSetExplicitly(): boolean { return this._hasLanguageSetExplicitly; }191192constructor(193readonly uri: URI,194public readonly handle: number,195private readonly _source: string,196private _language: string,197private _mime: string | undefined,198public readonly cellKind: CellKind,199outputs: IOutputDto[],200metadata: NotebookCellMetadata | undefined,201internalMetadata: NotebookCellInternalMetadata | undefined,202public readonly collapseState: NotebookCellCollapseState | undefined,203public readonly transientOptions: TransientOptions,204private readonly _languageService: ILanguageService,205private readonly _defaultEOL: model.DefaultEndOfLine,206private readonly _languageDetectionService: ILanguageDetectionService | undefined = undefined,207) {208super();209this._outputs = outputs.map(op => new NotebookCellOutputTextModel(op));210this._metadata = metadata ?? {};211this._internalMetadata = internalMetadata ?? {};212}213214enableAutoLanguageDetection() {215this._autoLanguageDetectionEnabled = true;216this.autoDetectLanguage();217}218219async autoDetectLanguage(): Promise<void> {220if (this._autoLanguageDetectionEnabled) {221this.autoDetectLanguageThrottler.trigger(() => this._doAutoDetectLanguage());222}223}224225private async _doAutoDetectLanguage(): Promise<void> {226if (this.hasLanguageSetExplicitly) {227return;228}229230const newLanguage = await this._languageDetectionService?.detectLanguage(this.uri);231if (!newLanguage) {232return;233}234235if (this._textModel236&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(newLanguage)237&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(this.language)) {238return;239}240241this._setLanguageInternal(newLanguage);242}243244private _setLanguageInternal(newLanguage: string) {245const newLanguageId = this._languageService.getLanguageIdByLanguageName(newLanguage);246247if (newLanguageId === null) {248return;249}250251if (this._textModel) {252const languageId = this._languageService.createById(newLanguageId);253this._textModel.setLanguage(languageId.languageId);254}255256if (this._language === newLanguage) {257return;258}259260this._language = newLanguage;261this._hash = null;262this._onDidChangeLanguage.fire(newLanguage);263this._onDidChangeContent.fire('language');264}265266resetTextBuffer(textBuffer: model.ITextBuffer) {267this._textBuffer = textBuffer;268}269270getValue(): string {271const fullRange = this.getFullModelRange();272const eol = this.textBuffer.getEOL();273if (eol === '\n') {274return this.textBuffer.getValueInRange(fullRange, model.EndOfLinePreference.LF);275} else {276return this.textBuffer.getValueInRange(fullRange, model.EndOfLinePreference.CRLF);277}278}279280getTextBufferHash() {281if (this._textBufferHash !== null) {282return this._textBufferHash;283}284285const shaComputer = new StringSHA1();286const snapshot = this.textBuffer.createSnapshot(false);287let text: string | null;288while ((text = snapshot.read())) {289shaComputer.update(text);290}291this._textBufferHash = shaComputer.digest();292return this._textBufferHash;293}294295getHashValue(): number {296if (this._hash !== null) {297return this._hash;298}299300this._hash = hash([hash(this.language), this.getTextBufferHash(), this._getPersisentMetadata(), this.transientOptions.transientOutputs ? [] : this._outputs.map(op => ({301outputs: op.outputs.map(output => ({302mime: output.mime,303data: Array.from(output.data.buffer)304})),305metadata: op.metadata306}))]);307return this._hash;308}309310private _getPersisentMetadata() {311return getFormattedMetadataJSON(this.transientOptions.transientCellMetadata, this.metadata, this.language);312}313314getTextLength(): number {315return this.textBuffer.getLength();316}317318getFullModelRange() {319const lineCount = this.textBuffer.getLineCount();320return new Range(1, 1, lineCount, this.textBuffer.getLineLength(lineCount) + 1);321}322323spliceNotebookCellOutputs(splice: NotebookCellOutputsSplice): void {324if (splice.deleteCount > 0 && splice.newOutputs.length > 0) {325const commonLen = Math.min(splice.deleteCount, splice.newOutputs.length);326// update327for (let i = 0; i < commonLen; i++) {328const currentOutput = this.outputs[splice.start + i];329const newOutput = splice.newOutputs[i];330331this.replaceOutput(currentOutput.outputId, newOutput);332}333334const removed = this.outputs.splice(splice.start + commonLen, splice.deleteCount - commonLen, ...splice.newOutputs.slice(commonLen));335removed.forEach(output => output.dispose());336this._onDidChangeOutputs.fire({ start: splice.start + commonLen, deleteCount: splice.deleteCount - commonLen, newOutputs: splice.newOutputs.slice(commonLen) });337} else {338const removed = this.outputs.splice(splice.start, splice.deleteCount, ...splice.newOutputs);339removed.forEach(output => output.dispose());340this._onDidChangeOutputs.fire(splice);341}342}343344replaceOutput(outputId: string, newOutputItem: ICellOutput) {345const outputIndex = this.outputs.findIndex(output => output.outputId === outputId);346347if (outputIndex < 0) {348return false;349}350351const output = this.outputs[outputIndex];352// convert to dto and dispose the cell output model353output.replaceData({354outputs: newOutputItem.outputs,355outputId: newOutputItem.outputId,356metadata: newOutputItem.metadata357});358newOutputItem.dispose();359this._onDidChangeOutputItems.fire();360return true;361}362363changeOutputItems(outputId: string, append: boolean, items: IOutputItemDto[]): boolean {364const outputIndex = this.outputs.findIndex(output => output.outputId === outputId);365366if (outputIndex < 0) {367return false;368}369370const output = this.outputs[outputIndex];371if (append) {372output.appendData(items);373} else {374output.replaceData({ outputId: outputId, outputs: items, metadata: output.metadata });375}376this._onDidChangeOutputItems.fire();377return true;378}379380private _outputNotEqualFastCheck(left: ICellOutput[], right: ICellOutput[]) {381if (left.length !== right.length) {382return false;383}384385for (let i = 0; i < this.outputs.length; i++) {386const l = left[i];387const r = right[i];388389if (l.outputs.length !== r.outputs.length) {390return false;391}392393for (let k = 0; k < l.outputs.length; k++) {394if (l.outputs[k].mime !== r.outputs[k].mime) {395return false;396}397398if (l.outputs[k].data.byteLength !== r.outputs[k].data.byteLength) {399return false;400}401}402}403404return true;405}406407equal(b: NotebookCellTextModel): boolean {408if (this.language !== b.language) {409return false;410}411412if (this.outputs.length !== b.outputs.length) {413return false;414}415416if (this.getTextLength() !== b.getTextLength()) {417return false;418}419420if (!this.transientOptions.transientOutputs) {421// compare outputs422423if (!this._outputNotEqualFastCheck(this.outputs, b.outputs)) {424return false;425}426}427428return this.getHashValue() === b.getHashValue();429}430431/**432* Only compares433* - language434* - mime435* - cellKind436* - internal metadata (conditionally)437* - source438*/439fastEqual(b: ICellDto2, ignoreMetadata: boolean): boolean {440if (this.language !== b.language) {441return false;442}443444if (this.mime !== b.mime) {445return false;446}447448if (this.cellKind !== b.cellKind) {449return false;450}451452if (!ignoreMetadata) {453if (this.internalMetadata?.executionOrder !== b.internalMetadata?.executionOrder454|| this.internalMetadata?.lastRunSuccess !== b.internalMetadata?.lastRunSuccess455|| this.internalMetadata?.runStartTime !== b.internalMetadata?.runStartTime456|| this.internalMetadata?.runStartTimeAdjustment !== b.internalMetadata?.runStartTimeAdjustment457|| this.internalMetadata?.runEndTime !== b.internalMetadata?.runEndTime) {458return false;459}460}461462// Once we attach the cell text buffer to an editor, the source of truth is the text buffer instead of the original source463if (this._textBuffer) {464if (!NotebookCellTextModel.linesAreEqual(this.textBuffer.getLinesContent(), b.source)) {465return false;466}467} else if (this._source !== b.source) {468return false;469}470471return true;472}473474private static linesAreEqual(aLines: string[], b: string) {475const bLines = splitLines(b);476if (aLines.length !== bLines.length) {477return false;478}479for (let i = 0; i < aLines.length; i++) {480if (aLines[i] !== bLines[i]) {481return false;482}483}484return true;485}486487override dispose() {488dispose(this._outputs);489// Manually release reference to previous text buffer to avoid large leaks490// in case someone leaks a CellTextModel reference491const emptyDisposedTextBuffer = new PieceTreeTextBuffer([], '', '\n', false, false, true, true);492emptyDisposedTextBuffer.dispose();493this._textBuffer = emptyDisposedTextBuffer;494super.dispose();495}496}497498export function cloneNotebookCellTextModel(cell: NotebookCellTextModel) {499return {500source: cell.getValue(),501language: cell.language,502mime: cell.mime,503cellKind: cell.cellKind,504outputs: cell.outputs.map(output => ({505outputs: output.outputs,506/* paste should generate new outputId */ outputId: UUID.generateUuid()507})),508metadata: {}509};510}511512function computeRunStartTimeAdjustment(oldMetadata: NotebookCellInternalMetadata, newMetadata: NotebookCellInternalMetadata): number | undefined {513if (oldMetadata.runStartTime !== newMetadata.runStartTime && typeof newMetadata.runStartTime === 'number') {514const offset = Date.now() - newMetadata.runStartTime;515return offset < 0 ? Math.abs(offset) : 0;516} else {517return newMetadata.runStartTimeAdjustment;518}519}520521522export function getFormattedMetadataJSON(transientCellMetadata: TransientCellMetadata | undefined, metadata: NotebookCellMetadata, language?: string, sortKeys?: boolean): string {523let filteredMetadata: { [key: string]: any } = {};524525if (transientCellMetadata) {526const keys = new Set([...Object.keys(metadata)]);527for (const key of keys) {528if (!(transientCellMetadata[key as keyof NotebookCellMetadata])529) {530filteredMetadata[key] = metadata[key as keyof NotebookCellMetadata];531}532}533} else {534filteredMetadata = metadata;535}536537const obj = {538language,539...filteredMetadata540};541// Give preference to the language we have been given.542// Metadata can contain `language` due to round-tripping of cell metadata.543// 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.544if (language) {545obj.language = language;546}547const metadataSource = toFormattedString(sortKeys ? sortObjectPropertiesRecursively(obj) : obj, {});548549return metadataSource;550}551552553/**554* Sort the JSON to ensure when diffing, the JSON keys are sorted & matched correctly in diff view.555*/556export function sortObjectPropertiesRecursively(obj: any): any {557if (Array.isArray(obj)) {558return obj.map(sortObjectPropertiesRecursively);559}560if (obj !== undefined && obj !== null && typeof obj === 'object' && Object.keys(obj).length > 0) {561return (562Object.keys(obj)563.sort()564.reduce<Record<string, any>>((sortedObj, prop) => {565sortedObj[prop] = sortObjectPropertiesRecursively(obj[prop]);566return sortedObj;567}, {}) as any568);569}570return obj;571}572573574