Path: blob/main/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts
5240 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, IOutputItemDto, NotebookCellCollapseState, NotebookCellDefaultCollapseConfig, 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';23import { INotebookLoggingService } from '../notebookLoggingService.js';2425export class NotebookCellTextModel extends Disposable implements ICell {26private readonly _onDidChangeTextModel = this._register(new Emitter<void>());27readonly onDidChangeTextModel: Event<void> = this._onDidChangeTextModel.event;28private readonly _onDidChangeOutputs = this._register(new Emitter<NotebookCellOutputsSplice>());29readonly onDidChangeOutputs: Event<NotebookCellOutputsSplice> = this._onDidChangeOutputs.event;3031private readonly _onDidChangeOutputItems = this._register(new Emitter<void>());32readonly onDidChangeOutputItems: Event<void> = this._onDidChangeOutputItems.event;3334private readonly _onDidChangeContent = this._register(new Emitter<'content' | 'language' | 'mime' | { type: 'model'; event: IModelContentChangedEvent }>());35readonly onDidChangeContent: Event<'content' | 'language' | 'mime' | { type: 'model'; event: IModelContentChangedEvent }> = this._onDidChangeContent.event;3637private readonly _onDidChangeMetadata = this._register(new Emitter<void>());38readonly onDidChangeMetadata: Event<void> = this._onDidChangeMetadata.event;3940private readonly _onDidChangeInternalMetadata = this._register(new Emitter<CellInternalMetadataChangedEvent>());41readonly onDidChangeInternalMetadata: Event<CellInternalMetadataChangedEvent> = this._onDidChangeInternalMetadata.event;4243private readonly _onDidChangeLanguage = this._register(new Emitter<string>());44readonly onDidChangeLanguage: Event<string> = this._onDidChangeLanguage.event;4546private _outputs: NotebookCellOutputTextModel[];4748get outputs(): ICellOutput[] {49return this._outputs;50}5152private _metadata: NotebookCellMetadata;5354get metadata() {55return this._metadata;56}5758set metadata(newMetadata: NotebookCellMetadata) {59this._metadata = newMetadata;60this._hash = null;61this._onDidChangeMetadata.fire();62}6364private _internalMetadata: NotebookCellInternalMetadata;6566get internalMetadata() {67return this._internalMetadata;68}6970set internalMetadata(newInternalMetadata: NotebookCellInternalMetadata) {71const lastRunSuccessChanged = this._internalMetadata.lastRunSuccess !== newInternalMetadata.lastRunSuccess;72newInternalMetadata = {73...newInternalMetadata,74...{ runStartTimeAdjustment: computeRunStartTimeAdjustment(this._internalMetadata, newInternalMetadata) }75};76this._internalMetadata = newInternalMetadata;77this._hash = null;78this._onDidChangeInternalMetadata.fire({ lastRunSuccessChanged });79}8081get language() {82return this._language;83}8485set language(newLanguage: string) {86if (this._textModel87// 1. the language update is from workspace edit, checking if it's the same as text model's mode88&& 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 update90&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(this.language)) {91return;92}939495this._hasLanguageSetExplicitly = true;96this._setLanguageInternal(newLanguage);97}9899public get mime(): string | undefined {100return this._mime;101}102103public set mime(newMime: string | undefined) {104if (this._mime === newMime) {105return;106}107this._mime = newMime;108this._hash = null;109this._onDidChangeContent.fire('mime');110}111112private _textBuffer!: model.ITextBuffer;113114get textBuffer() {115if (this._textBuffer) {116return this._textBuffer;117}118119this._textBuffer = this._register(createTextBuffer(this._source, this._defaultEOL).textBuffer);120121this._register(this._textBuffer.onDidChangeContent(() => {122this._hash = null;123if (!this._textModel) {124this._onDidChangeContent.fire('content');125}126this.autoDetectLanguage();127}));128129return this._textBuffer;130}131132private _textBufferHash: string | null = null;133private _hash: number | null = null;134135private _versionId: number = 1;136private _alternativeId: number = 1;137get alternativeId(): number {138return this._alternativeId;139}140141private readonly _textModelDisposables = this._register(new DisposableStore());142private _textModel: TextModel | undefined = undefined;143get textModel(): TextModel | undefined {144return this._textModel;145}146147set textModel(m: TextModel | undefined) {148if (this._textModel === m) {149return;150}151152this._textModelDisposables.clear();153this._textModel = m;154if (this._textModel) {155this.setRegisteredLanguage(this._languageService, this._textModel.getLanguageId(), this.language);156157// Listen to language changes on the model158this._textModelDisposables.add(this._textModel.onDidChangeLanguage((e) => this.setRegisteredLanguage(this._languageService, e.newLanguage, this.language)));159this._textModelDisposables.add(this._textModel.onWillDispose(() => this.textModel = undefined));160this._textModelDisposables.add(this._textModel.onDidChangeContent((e) => {161if (this._textModel) {162this._versionId = this._textModel.getVersionId();163this._alternativeId = this._textModel.getAlternativeVersionId();164}165this._textBufferHash = null;166this._onDidChangeContent.fire('content');167this._onDidChangeContent.fire({ type: 'model', event: e });168}));169170this._textModel._overwriteVersionId(this._versionId);171this._textModel._overwriteAlternativeVersionId(this._versionId);172this._onDidChangeTextModel.fire();173}174}175176private 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 fallback178// If so let's not modify the language179const isFallBackLanguage = (newLanguage === PLAINTEXT_LANGUAGE_ID || newLanguage === 'jupyter');180if (!languageService.isRegisteredLanguageId(currentLanguage) && isFallBackLanguage) {181// notify to display warning, but don't change the language182this._onDidChangeLanguage.fire(currentLanguage);183} else {184this.language = newLanguage;185}186}187private static readonly AUTO_DETECT_LANGUAGE_THROTTLE_DELAY = 600;188private readonly autoDetectLanguageThrottler = this._register(new ThrottledDelayer<void>(NotebookCellTextModel.AUTO_DETECT_LANGUAGE_THROTTLE_DELAY));189private _autoLanguageDetectionEnabled: boolean = false;190private _hasLanguageSetExplicitly: boolean = false;191get hasLanguageSetExplicitly(): boolean { return this._hasLanguageSetExplicitly; }192193private _source: string;194private _language: string;195private _mime: string | undefined;196public readonly cellKind: CellKind;197public readonly collapseState: NotebookCellCollapseState | undefined;198199constructor(200readonly uri: URI,201public readonly handle: number,202cell: ICellDto2,203public readonly transientOptions: TransientOptions,204private readonly _languageService: ILanguageService,205private readonly _defaultEOL: model.DefaultEndOfLine,206defaultCollapseConfig: NotebookCellDefaultCollapseConfig | undefined,207private readonly _languageDetectionService: ILanguageDetectionService | undefined = undefined,208private readonly _notebookLoggingService: INotebookLoggingService209) {210super();211this._source = cell.source;212this._language = cell.language;213this._mime = cell.mime;214this.cellKind = cell.cellKind;215// Compute collapse state: use cell's state if provided, otherwise use default config for this cell kind216const defaultConfig = cell.cellKind === CellKind.Code ? defaultCollapseConfig?.codeCell : defaultCollapseConfig?.markupCell;217this.collapseState = cell.collapseState ?? (defaultConfig ?? undefined);218this._outputs = cell.outputs.map(op => new NotebookCellOutputTextModel(op));219this._metadata = cell.metadata ?? {};220this._internalMetadata = cell.internalMetadata ?? {};221}222223enableAutoLanguageDetection() {224this._autoLanguageDetectionEnabled = true;225this.autoDetectLanguage();226}227228async autoDetectLanguage(): Promise<void> {229if (this._autoLanguageDetectionEnabled) {230this.autoDetectLanguageThrottler.trigger(() => this._doAutoDetectLanguage());231}232}233234private async _doAutoDetectLanguage(): Promise<void> {235if (this.hasLanguageSetExplicitly) {236return;237}238239const newLanguage = await this._languageDetectionService?.detectLanguage(this.uri);240if (!newLanguage) {241return;242}243244if (this._textModel245&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(newLanguage)246&& this._textModel.getLanguageId() === this._languageService.getLanguageIdByLanguageName(this.language)) {247return;248}249250this._setLanguageInternal(newLanguage);251}252253private _setLanguageInternal(newLanguage: string) {254const newLanguageId = this._languageService.getLanguageIdByLanguageName(newLanguage);255256if (newLanguageId === null) {257return;258}259260if (this._textModel) {261const languageId = this._languageService.createById(newLanguageId);262this._textModel.setLanguage(languageId.languageId);263}264265if (this._language === newLanguage) {266return;267}268269this._language = newLanguage;270this._hash = null;271this._onDidChangeLanguage.fire(newLanguage);272this._onDidChangeContent.fire('language');273}274275resetTextBuffer(textBuffer: model.ITextBuffer) {276this._textBuffer = textBuffer;277}278279getValue(): string {280const fullRange = this.getFullModelRange();281const eol = this.textBuffer.getEOL();282if (eol === '\n') {283return this.textBuffer.getValueInRange(fullRange, model.EndOfLinePreference.LF);284} else {285return this.textBuffer.getValueInRange(fullRange, model.EndOfLinePreference.CRLF);286}287}288289getTextBufferHash() {290if (this._textBufferHash !== null) {291return this._textBufferHash;292}293294const shaComputer = new StringSHA1();295const snapshot = this.textBuffer.createSnapshot(false);296let text: string | null;297while ((text = snapshot.read())) {298shaComputer.update(text);299}300this._textBufferHash = shaComputer.digest();301return this._textBufferHash;302}303304getHashValue(): number {305if (this._hash !== null) {306return this._hash;307}308309this._hash = hash([hash(this.language), this.getTextBufferHash(), this._getPersisentMetadata(), this.transientOptions.transientOutputs ? [] : this._outputs.map(op => ({310outputs: op.outputs.map(output => ({311mime: output.mime,312data: Array.from(output.data.buffer)313})),314metadata: op.metadata315}))]);316return this._hash;317}318319private _getPersisentMetadata() {320return getFormattedMetadataJSON(this.transientOptions.transientCellMetadata, this.metadata, this.language);321}322323getTextLength(): number {324return this.textBuffer.getLength();325}326327getFullModelRange() {328const lineCount = this.textBuffer.getLineCount();329return new Range(1, 1, lineCount, this.textBuffer.getLineLength(lineCount) + 1);330}331332spliceNotebookCellOutputs(splice: NotebookCellOutputsSplice): void {333this._notebookLoggingService.trace('textModelEdits', `splicing outputs at ${splice.start} length: ${splice.deleteCount} with ${splice.newOutputs.length} new outputs`);334if (splice.deleteCount > 0 && splice.newOutputs.length > 0) {335const commonLen = Math.min(splice.deleteCount, splice.newOutputs.length);336// update337for (let i = 0; i < commonLen; i++) {338const currentOutput = this.outputs[splice.start + i];339const newOutput = splice.newOutputs[i];340341this.replaceOutput(currentOutput.outputId, newOutput);342}343344const removed = this.outputs.splice(splice.start + commonLen, splice.deleteCount - commonLen, ...splice.newOutputs.slice(commonLen));345removed.forEach(output => output.dispose());346this._onDidChangeOutputs.fire({ start: splice.start + commonLen, deleteCount: splice.deleteCount - commonLen, newOutputs: splice.newOutputs.slice(commonLen) });347} else {348const removed = this.outputs.splice(splice.start, splice.deleteCount, ...splice.newOutputs);349removed.forEach(output => output.dispose());350this._onDidChangeOutputs.fire(splice);351}352}353354replaceOutput(outputId: string, newOutputItem: ICellOutput) {355const outputIndex = this.outputs.findIndex(output => output.outputId === outputId);356357if (outputIndex < 0) {358return false;359}360361this._notebookLoggingService.trace('textModelEdits', `replacing an output item at index ${outputIndex}`);362const output = this.outputs[outputIndex];363// convert to dto and dispose the cell output model364output.replaceData({365outputs: newOutputItem.outputs,366outputId: newOutputItem.outputId,367metadata: newOutputItem.metadata368});369newOutputItem.dispose();370this._onDidChangeOutputItems.fire();371return true;372}373374changeOutputItems(outputId: string, append: boolean, items: IOutputItemDto[]): boolean {375const outputIndex = this.outputs.findIndex(output => output.outputId === outputId);376377if (outputIndex < 0) {378return false;379}380381const output = this.outputs[outputIndex];382this._notebookLoggingService.trace('textModelEdits', `${append ? 'appending' : 'replacing'} ${items.length} output items to for output index ${outputIndex}`);383if (append) {384output.appendData(items);385} else {386output.replaceData({ outputId: outputId, outputs: items, metadata: output.metadata });387}388this._onDidChangeOutputItems.fire();389return true;390}391392private _outputNotEqualFastCheck(left: ICellOutput[], right: ICellOutput[]) {393if (left.length !== right.length) {394return false;395}396397for (let i = 0; i < this.outputs.length; i++) {398const l = left[i];399const r = right[i];400401if (l.outputs.length !== r.outputs.length) {402return false;403}404405for (let k = 0; k < l.outputs.length; k++) {406if (l.outputs[k].mime !== r.outputs[k].mime) {407return false;408}409410if (l.outputs[k].data.byteLength !== r.outputs[k].data.byteLength) {411return false;412}413}414}415416return true;417}418419equal(b: NotebookCellTextModel): boolean {420if (this.language !== b.language) {421return false;422}423424if (this.outputs.length !== b.outputs.length) {425return false;426}427428if (this.getTextLength() !== b.getTextLength()) {429return false;430}431432if (!this.transientOptions.transientOutputs) {433// compare outputs434435if (!this._outputNotEqualFastCheck(this.outputs, b.outputs)) {436return false;437}438}439440return this.getHashValue() === b.getHashValue();441}442443/**444* Only compares445* - language446* - mime447* - cellKind448* - internal metadata (conditionally)449* - source450*/451fastEqual(b: ICellDto2, ignoreMetadata: boolean): boolean {452if (this.language !== b.language) {453return false;454}455456if (this.mime !== b.mime) {457return false;458}459460if (this.cellKind !== b.cellKind) {461return false;462}463464if (!ignoreMetadata) {465if (this.internalMetadata?.executionOrder !== b.internalMetadata?.executionOrder466|| this.internalMetadata?.lastRunSuccess !== b.internalMetadata?.lastRunSuccess467|| this.internalMetadata?.runStartTime !== b.internalMetadata?.runStartTime468|| this.internalMetadata?.runStartTimeAdjustment !== b.internalMetadata?.runStartTimeAdjustment469|| this.internalMetadata?.runEndTime !== b.internalMetadata?.runEndTime) {470return false;471}472}473474// Once we attach the cell text buffer to an editor, the source of truth is the text buffer instead of the original source475if (this._textBuffer) {476if (!NotebookCellTextModel.linesAreEqual(this.textBuffer.getLinesContent(), b.source)) {477return false;478}479} else if (this._source !== b.source) {480return false;481}482483return true;484}485486private static linesAreEqual(aLines: string[], b: string) {487const bLines = splitLines(b);488if (aLines.length !== bLines.length) {489return false;490}491for (let i = 0; i < aLines.length; i++) {492if (aLines[i] !== bLines[i]) {493return false;494}495}496return true;497}498499override dispose() {500dispose(this._outputs);501// Manually release reference to previous text buffer to avoid large leaks502// in case someone leaks a CellTextModel reference503const emptyDisposedTextBuffer = new PieceTreeTextBuffer([], '', '\n', false, false, true, true);504emptyDisposedTextBuffer.dispose();505this._textBuffer = emptyDisposedTextBuffer;506super.dispose();507}508}509510export function cloneNotebookCellTextModel(cell: NotebookCellTextModel) {511return {512source: cell.getValue(),513language: cell.language,514mime: cell.mime,515cellKind: cell.cellKind,516outputs: cell.outputs.map(output => ({517outputs: output.outputs,518/* paste should generate new outputId */ outputId: UUID.generateUuid()519})),520metadata: {}521};522}523524function computeRunStartTimeAdjustment(oldMetadata: NotebookCellInternalMetadata, newMetadata: NotebookCellInternalMetadata): number | undefined {525if (oldMetadata.runStartTime !== newMetadata.runStartTime && typeof newMetadata.runStartTime === 'number') {526const offset = Date.now() - newMetadata.runStartTime;527return offset < 0 ? Math.abs(offset) : 0;528} else {529return newMetadata.runStartTimeAdjustment;530}531}532533534export function getFormattedMetadataJSON(transientCellMetadata: TransientCellMetadata | undefined, metadata: NotebookCellMetadata, language?: string, sortKeys?: boolean): string {535let filteredMetadata: { [key: string]: any } = {};536537if (transientCellMetadata) {538const keys = new Set([...Object.keys(metadata)]);539for (const key of keys) {540if (!(transientCellMetadata[key as keyof NotebookCellMetadata])541) {542filteredMetadata[key] = metadata[key as keyof NotebookCellMetadata];543}544}545} else {546filteredMetadata = metadata;547}548549const obj = {550language,551...filteredMetadata552};553// Give preference to the language we have been given.554// Metadata can contain `language` due to round-tripping of cell metadata.555// 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.556if (language) {557obj.language = language;558}559const metadataSource = toFormattedString(sortKeys ? sortObjectPropertiesRecursively(obj) : obj, {});560561return metadataSource;562}563564565/**566* Sort the JSON to ensure when diffing, the JSON keys are sorted & matched correctly in diff view.567*/568export function sortObjectPropertiesRecursively(obj: any): any {569if (Array.isArray(obj)) {570return obj.map(sortObjectPropertiesRecursively);571}572if (obj !== undefined && obj !== null && typeof obj === 'object' && Object.keys(obj).length > 0) {573return (574Object.keys(obj)575.sort()576.reduce<Record<string, any>>((sortedObj, prop) => {577sortedObj[prop] = sortObjectPropertiesRecursively(obj[prop]);578return sortedObj;579}, {})580);581}582return obj;583}584585586