// ***************************************************************************** // Copyright (C) 2023 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableCollection, Emitter, Event, URI } from '@theia/core'; import { inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify'; import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model'; import { type MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; import { CellKind, NotebookCellCollapseState, NotebookCellInternalMetadata, NotebookCellMetadata, CellOutput, CellData, CellOutputItem } from '../../common'; import { NotebookCellOutputsSplice } from '../notebook-types'; import { NotebookMonacoTextModelService } from '../service/notebook-monaco-text-model-service'; import { NotebookCellOutputModel } from './notebook-cell-output-model'; import { PreferenceService } from '@theia/core/lib/common'; import { NotebookPreferences } from '../../common/notebook-preferences'; import { LanguageService } from '@theia/core/lib/browser/language-service'; import { NotebookEditorFindMatch, NotebookEditorFindMatchOptions } from '../view/notebook-find-widget'; import { Range } from '@theia/core/shared/vscode-languageserver-protocol'; export const NotebookCellModelFactory = Symbol('NotebookModelFactory'); export type NotebookCellModelFactory = (props: NotebookCellModelProps) => NotebookCellModel; export function createNotebookCellModelContainer(parent: interfaces.Container, props: NotebookCellModelProps): interfaces.Container { const child = parent.createChild(); child.bind(NotebookCellModelProps).toConstantValue(props); child.bind(NotebookCellModel).toSelf(); return child; } export interface CellInternalMetadataChangedEvent { readonly lastRunSuccessChanged?: boolean; } export interface NotebookCell { readonly uri: URI; handle: number; language: string; cellKind: CellKind; outputs: CellOutput[]; metadata: NotebookCellMetadata; internalMetadata: NotebookCellInternalMetadata; text: string; /** * The selection of the cell. Zero-based line/character coordinates. */ selection: Range | undefined; onDidChangeOutputs?: Event; onDidChangeOutputItems?: Event; onDidChangeLanguage: Event; onDidChangeMetadata: Event; onDidChangeInternalMetadata: Event; } const NotebookCellModelProps = Symbol('NotebookModelProps'); export interface NotebookCellModelProps { readonly uri: URI, readonly handle: number, source: string, language: string, readonly cellKind: CellKind, outputs: CellOutput[], metadata?: NotebookCellMetadata | undefined, internalMetadata?: NotebookCellInternalMetadata | undefined, readonly collapseState?: NotebookCellCollapseState | undefined, } @injectable() export class NotebookCellModel implements NotebookCell, Disposable { protected readonly onDidChangeOutputsEmitter = new Emitter(); readonly onDidChangeOutputs = this.onDidChangeOutputsEmitter.event; protected readonly onDidChangeOutputItemsEmitter = new Emitter(); readonly onDidChangeOutputItems = this.onDidChangeOutputItemsEmitter.event; protected readonly onDidChangeContentEmitter = new Emitter<'content' | 'language' | 'mime'>(); readonly onDidChangeContent = this.onDidChangeContentEmitter.event; protected readonly onDidChangeMetadataEmitter = new Emitter(); readonly onDidChangeMetadata = this.onDidChangeMetadataEmitter.event; protected readonly onDidChangeInternalMetadataEmitter = new Emitter(); readonly onDidChangeInternalMetadata = this.onDidChangeInternalMetadataEmitter.event; protected readonly onDidChangeLanguageEmitter = new Emitter(); readonly onDidChangeLanguage = this.onDidChangeLanguageEmitter.event; protected readonly onDidChangeEditorOptionsEmitter = new Emitter(); readonly onDidChangeEditorOptions = this.onDidChangeEditorOptionsEmitter.event; protected readonly outputVisibilityChangeEmitter = new Emitter(); readonly onDidChangeOutputVisibility = this.outputVisibilityChangeEmitter.event; protected readonly onDidFindMatchesEmitter = new Emitter(); readonly onDidFindMatches: Event = this.onDidFindMatchesEmitter.event; protected readonly onDidSelectFindMatchEmitter = new Emitter(); readonly onDidSelectFindMatch: Event = this.onDidSelectFindMatchEmitter.event; protected onDidRequestCenterEditorEmitter = new Emitter(); readonly onDidRequestCenterEditor = this.onDidRequestCenterEditorEmitter.event; protected onDidCellHeightChangeEmitter = new Emitter(); readonly onDidCellHeightChange = this.onDidCellHeightChangeEmitter.event; @inject(NotebookCellModelProps) protected readonly props: NotebookCellModelProps; @inject(NotebookMonacoTextModelService) protected readonly textModelService: NotebookMonacoTextModelService; @inject(LanguageService) protected readonly languageService: LanguageService; @inject(PreferenceService) protected readonly preferenceService: PreferenceService; get outputs(): NotebookCellOutputModel[] { return this._outputs; } protected _outputs: NotebookCellOutputModel[]; get metadata(): NotebookCellMetadata { return this._metadata; } set metadata(newMetadata: NotebookCellMetadata) { this._metadata = newMetadata; this.onDidChangeMetadataEmitter.fire(); } protected _metadata: NotebookCellMetadata; toDispose = new DisposableCollection(); protected _internalMetadata: NotebookCellInternalMetadata; get internalMetadata(): NotebookCellInternalMetadata { return this._internalMetadata; } set internalMetadata(newInternalMetadata: NotebookCellInternalMetadata) { const lastRunSuccessChanged = this._internalMetadata.lastRunSuccess !== newInternalMetadata.lastRunSuccess; newInternalMetadata = { ...newInternalMetadata, ...{ runStartTimeAdjustment: computeRunStartTimeAdjustment(this._internalMetadata, newInternalMetadata) } }; this._internalMetadata = newInternalMetadata; this.onDidChangeInternalMetadataEmitter.fire({ lastRunSuccessChanged }); } protected textModel?: MonacoEditorModel; get text(): string { return this.textModel && !this.textModel.isDisposed() ? this.textModel.getText() : this.source; } get isTextModelWritable(): boolean { return !this.textModel || !this.textModel.readOnly; } get source(): string { return this.props.source; } set source(source: string) { this.props.source = source; this.textModel?.textEditorModel.setValue(source); } get language(): string { return this.props.language; } set language(newLanguage: string) { if (this.language === newLanguage) { return; } if (this.textModel) { this.textModel.setLanguageId(newLanguage); } this.props.language = newLanguage; this.onDidChangeLanguageEmitter.fire(newLanguage); this.onDidChangeContentEmitter.fire('language'); } get languageName(): string { return this.languageService.getLanguage(this.language)?.name ?? this.language; } get uri(): URI { return this.props.uri; } get handle(): number { return this.props.handle; } get cellKind(): CellKind { return this.props.cellKind; } protected _editorOptions: MonacoEditor.IOptions = {}; get editorOptions(): Readonly { return this._editorOptions; } set editorOptions(options: MonacoEditor.IOptions) { this._editorOptions = options; this.onDidChangeEditorOptionsEmitter.fire(options); } protected _outputVisible: boolean = true; get outputVisible(): boolean { return this._outputVisible; } set outputVisible(visible: boolean) { if (this._outputVisible !== visible) { this._outputVisible = visible; this.outputVisibilityChangeEmitter.fire(visible); } } protected _selection: Range | undefined = undefined; get selection(): Range | undefined { return this._selection; } set selection(selection: Range | undefined) { this._selection = selection; } protected _cellheight: number = 0; get cellHeight(): number { return this._cellheight; } set cellHeight(height: number) { if (height !== this._cellheight) { this.onDidCellHeightChangeEmitter.fire(height); this._cellheight = height; } } @postConstruct() protected init(): void { this._outputs = this.props.outputs.map(op => new NotebookCellOutputModel(op)); this._metadata = this.props.metadata ?? {}; this._internalMetadata = this.props.internalMetadata ?? {}; this.editorOptions = { lineNumbers: this.preferenceService.get(NotebookPreferences.NOTEBOOK_LINE_NUMBERS) }; this.toDispose.push(this.preferenceService.onPreferenceChanged(e => { if (e.preferenceName === NotebookPreferences.NOTEBOOK_LINE_NUMBERS) { this.editorOptions = { ...this.editorOptions, lineNumbers: this.preferenceService.get(NotebookPreferences.NOTEBOOK_LINE_NUMBERS) }; } })); } dispose(): void { this.onDidChangeOutputsEmitter.dispose(); this.onDidChangeOutputItemsEmitter.dispose(); this.onDidChangeContentEmitter.dispose(); this.onDidChangeMetadataEmitter.dispose(); this.onDidChangeInternalMetadataEmitter.dispose(); this.onDidChangeLanguageEmitter.dispose(); this.toDispose.dispose(); } requestCenterEditor(): void { this.onDidRequestCenterEditorEmitter.fire(); } spliceNotebookCellOutputs(splice: NotebookCellOutputsSplice): void { if (splice.deleteCount > 0 && splice.newOutputs.length > 0) { const commonLen = Math.min(splice.deleteCount, splice.newOutputs.length); // update for (let i = 0; i < commonLen; i++) { const currentOutput = this.outputs[splice.start + i]; const newOutput = splice.newOutputs[i]; this.replaceOutputData(currentOutput.outputId, newOutput); } this.outputs.splice(splice.start + commonLen, splice.deleteCount - commonLen, ...splice.newOutputs.slice(commonLen).map(op => new NotebookCellOutputModel(op))); this.onDidChangeOutputsEmitter.fire({ start: splice.start + commonLen, deleteCount: splice.deleteCount - commonLen, newOutputs: splice.newOutputs.slice(commonLen) }); } else { this.outputs.splice(splice.start, splice.deleteCount, ...splice.newOutputs.map(op => new NotebookCellOutputModel(op))); this.onDidChangeOutputsEmitter.fire(splice); } } replaceOutputData(outputId: string, newOutputData: CellOutput): boolean { const output = this.outputs.find(out => out.outputId === outputId); if (!output) { return false; } output.replaceData(newOutputData); this.onDidChangeOutputItemsEmitter.fire(output); return true; } changeOutputItems(outputId: string, append: boolean, items: CellOutputItem[]): boolean { const output = this.outputs.find(out => out.outputId === outputId); if (!output) { return false; } if (append) { output.appendData(items); } else { output.replaceData({ outputId: outputId, outputs: items, metadata: output.metadata }); } this.onDidChangeOutputItemsEmitter.fire(output); return true; } getData(): CellData { return { cellKind: this.cellKind, language: this.language, outputs: this.outputs.map(output => output.getData()), source: this.text, collapseState: this.props.collapseState, internalMetadata: this.internalMetadata, metadata: this.metadata }; } async resolveTextModel(): Promise { if (this.textModel) { return this.textModel; } const ref = await this.textModelService.getOrCreateNotebookCellModelReference(this.uri); this.textModel = ref.object; this.toDispose.push(ref); this.toDispose.push(this.textModel.onDidChangeContent(e => { this.props.source = e.model.getText(); this.onDidChangeContentEmitter.fire('content'); })); return ref.object; } restartOutputRenderer(outputId: string): void { const output = this.outputs.find(out => out.outputId === outputId); if (output) { this.onDidChangeOutputItemsEmitter.fire(output); } } onMarkdownFind: ((options: NotebookEditorFindMatchOptions) => NotebookEditorFindMatch[]) | undefined; showMatch(selected: NotebookCodeEditorFindMatch): void { this.onDidSelectFindMatchEmitter.fire(selected); } findMatches(options: NotebookEditorFindMatchOptions): NotebookEditorFindMatch[] { if (this.cellKind === CellKind.Markup && this.onMarkdownFind) { return this.onMarkdownFind(options) ?? []; } if (!this.textModel) { return []; } const matches = options.search ? this.textModel.findMatches({ searchString: options.search, isRegex: options.regex, matchCase: options.matchCase, matchWholeWord: options.wholeWord }) : []; const editorFindMatches = matches.map(match => new NotebookCodeEditorFindMatch(this, match.range, this.textModel!)); this.onDidFindMatchesEmitter.fire(editorFindMatches); return editorFindMatches; } replaceAll(matches: NotebookCodeEditorFindMatch[], value: string): void { const editOperations = matches.map(match => ({ range: { startColumn: match.range.start.character, startLineNumber: match.range.start.line, endColumn: match.range.end.character, endLineNumber: match.range.end.line }, text: value })); this.textModel?.textEditorModel.pushEditOperations( // eslint-disable-next-line no-null/no-null null, editOperations, // eslint-disable-next-line no-null/no-null () => null); } } export interface NotebookCellFindMatches { matches: NotebookEditorFindMatch[]; selected: NotebookEditorFindMatch; } export class NotebookCodeEditorFindMatch implements NotebookEditorFindMatch { selected = false; constructor(readonly cell: NotebookCellModel, readonly range: Range, readonly textModel: MonacoEditorModel) { } show(): void { this.cell.showMatch(this); } replace(value: string): void { this.textModel.textEditorModel.pushEditOperations( // eslint-disable-next-line no-null/no-null null, [{ range: { startColumn: this.range.start.character, startLineNumber: this.range.start.line, endColumn: this.range.end.character, endLineNumber: this.range.end.line }, text: value }], // eslint-disable-next-line no-null/no-null () => null); } } function computeRunStartTimeAdjustment(oldMetadata: NotebookCellInternalMetadata, newMetadata: NotebookCellInternalMetadata): number | undefined { if (oldMetadata.runStartTime !== newMetadata.runStartTime && typeof newMetadata.runStartTime === 'number') { const offset = Date.now() - newMetadata.runStartTime; return offset < 0 ? Math.abs(offset) : 0; } else { return newMetadata.runStartTimeAdjustment; } }