// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { SessionContext } from '@jupyterlab/apputils'; import { Cell, ICellModel } from '@jupyterlab/cells'; import { IEditorMimeTypeService } from '@jupyterlab/codeeditor'; import { Document, IAdapterOptions, IVirtualPosition, untilReady, VirtualDocument, WidgetLSPAdapter } from '@jupyterlab/lsp'; import * as nbformat from '@jupyterlab/nbformat'; import { IObservableList } from '@jupyterlab/observables'; import { Session } from '@jupyterlab/services'; import { PromiseDelegate } from '@lumino/coreutils'; import { Signal } from '@lumino/signaling'; import { NotebookPanel } from './panel'; import { Notebook } from './widget'; import { CellList } from './celllist'; type ILanguageInfoMetadata = nbformat.ILanguageInfoMetadata; export class NotebookAdapter extends WidgetLSPAdapter { constructor( public editorWidget: NotebookPanel, protected options: IAdapterOptions ) { super(editorWidget, options); this._editorToCell = new Map(); this.editor = editorWidget.content; this._cellToEditor = new WeakMap(); Promise.all([ this.widget.context.sessionContext.ready, this.connectionManager.ready ]) .then(async () => { await this.initOnceReady(); this._readyDelegate.resolve(); }) .catch(console.error); } /** * The wrapped `Notebook` widget. */ readonly editor: Notebook; /** * Get current path of the document. */ get documentPath(): string { return this.widget.context.path; } /** * Get the mime type of the document. */ get mimeType(): string { let mimeType: string | string[]; let languageMetadata = this.language_info(); if (!languageMetadata || !languageMetadata.mimetype) { // fallback to the code cell mime type if no kernel in use mimeType = this.widget.content.codeMimetype; } else { mimeType = languageMetadata.mimetype; } return Array.isArray(mimeType) ? mimeType[0] ?? IEditorMimeTypeService.defaultMimeType : mimeType; } /** * Get the file extension of the document. */ get languageFileExtension(): string | undefined { let languageMetadata = this.language_info(); if (!languageMetadata || !languageMetadata.file_extension) { return; } return languageMetadata.file_extension.replace('.', ''); } /** * Get the inner HTMLElement of the document widget. */ get wrapperElement(): HTMLElement { return this.widget.node; } /** * Get the list of CM editor with its type in the document, */ get editors(): Document.ICodeBlockOptions[] { if (this.isDisposed) { return []; } let notebook = this.widget.content; this._editorToCell.clear(); if (notebook.isDisposed) { return []; } return notebook.widgets.map(cell => { return { ceEditor: this._getCellEditor(cell), type: cell.model.type, value: cell.model.sharedModel.getSource() }; }); } /** * Get the activated CM editor. */ get activeEditor(): Document.IEditor | undefined { return this.editor.activeCell ? this._getCellEditor(this.editor.activeCell) : undefined; } /** * Promise that resolves once the adapter is initialized */ get ready(): Promise { return this._readyDelegate.promise; } /** * Get the index of editor from the cursor position in the virtual * document. * @deprecated This is error-prone and will be removed in JupyterLab 5.0, use `getEditorIndex()` with `virtualDocument.getEditorAtVirtualLine(position)` instead. * * @param position - the position of cursor in the virtual document. */ getEditorIndexAt(position: IVirtualPosition): number { let cell = this._getCellAt(position); let notebook = this.widget.content; return notebook.widgets.findIndex(otherCell => { return cell === otherCell; }); } /** * Get the index of input editor * * @param ceEditor - instance of the code editor */ getEditorIndex(ceEditor: Document.IEditor): number { let cell = this._editorToCell.get(ceEditor)!; return this.editor.widgets.findIndex(otherCell => { return cell === otherCell; }); } /** * Get the wrapper of input editor. * * @param ceEditor - instance of the code editor */ getEditorWrapper(ceEditor: Document.IEditor): HTMLElement { let cell = this._editorToCell.get(ceEditor)!; return cell.node; } /** * Callback on kernel changed event, it will disconnect the * document with the language server and then reconnect. * * @param _session - Session context of changed kernel * @param change - Changed data */ async onKernelChanged( _session: SessionContext, change: Session.ISessionConnection.IKernelChangedArgs ): Promise { if (!change.newValue) { return; } try { // note: we need to wait until ready before updating language info const oldLanguageInfo = this._languageInfo; await untilReady(this.isReady, -1); await this._updateLanguageInfo(); const newLanguageInfo = this._languageInfo; if ( oldLanguageInfo?.name != newLanguageInfo.name || oldLanguageInfo?.mimetype != newLanguageInfo?.mimetype || oldLanguageInfo?.file_extension != newLanguageInfo?.file_extension ) { console.log( `Changed to ${this._languageInfo.name} kernel, reconnecting` ); this.reloadConnection(); } else { console.log( 'Keeping old LSP connection as the new kernel uses the same langauge' ); } } catch (err) { console.warn(err); // try to reconnect anyway this.reloadConnection(); } } /** * Dispose the widget. */ dispose(): void { if (this.isDisposed) { return; } this.widget.context.sessionContext.kernelChanged.disconnect( this.onKernelChanged, this ); this.widget.content.activeCellChanged.disconnect( this._activeCellChanged, this ); super.dispose(); // editors are needed for the parent dispose() to unbind signals, so they are the last to go this._editorToCell.clear(); Signal.clearData(this); } /** * Method to check if the notebook context is ready. */ isReady(): boolean { return ( !this.widget.isDisposed && this.widget.context.isReady && this.widget.content.isVisible && this.widget.content.widgets.length > 0 && this.widget.context.sessionContext.session?.kernel != null ); } /** * Update the virtual document on cell changing event. * * @param cells - Observable list of changed cells * @param change - Changed data */ async handleCellChange( cells: CellList, change: IObservableList.IChangedArgs ): Promise { let cellsAdded: ICellModel[] = []; let cellsRemoved: ICellModel[] = []; const type = this._type; if (change.type === 'set') { // handling of conversions is important, because the editors get re-used and their handlers inherited, // so we need to clear our handlers from editors of e.g. markdown cells which previously were code cells. let convertedToMarkdownOrRaw = []; let convertedToCode = []; if (change.newValues.length === change.oldValues.length) { // during conversion the cells should not get deleted nor added for (let i = 0; i < change.newValues.length; i++) { if ( change.oldValues[i].type === type && change.newValues[i].type !== type ) { convertedToMarkdownOrRaw.push(change.newValues[i]); } else if ( change.oldValues[i].type !== type && change.newValues[i].type === type ) { convertedToCode.push(change.newValues[i]); } } cellsAdded = convertedToCode; cellsRemoved = convertedToMarkdownOrRaw; } } else if (change.type == 'add') { cellsAdded = change.newValues.filter( cellModel => cellModel.type === type ); } // note: editorRemoved is not emitted for removal of cells by change of type 'remove' (but only during cell type conversion) // because there is no easy way to get the widget associated with the removed cell(s) - because it is no // longer in the notebook widget list! It would need to be tracked on our side, but it is not necessary // as (except for a tiny memory leak) it should not impact the functionality in any way if ( cellsRemoved.length || cellsAdded.length || change.type === 'set' || change.type === 'move' || change.type === 'remove' ) { // in contrast to the file editor document which can be only changed by the modification of the editor content, // the notebook document cna also get modified by a change in the number or arrangement of editors themselves; // for this reason each change has to trigger documents update (so that LSP mirror is in sync). await this.updateDocuments(); } for (let cellModel of cellsAdded) { let cellWidget = this.widget.content.widgets.find( cell => cell.model.id === cellModel.id ); if (!cellWidget) { console.warn( `Widget for added cell with ID: ${cellModel.id} not found!` ); continue; } // Add editor to the mapping if needed this._getCellEditor(cellWidget); } } /** * Generate the virtual document associated with the document. */ createVirtualDocument(): VirtualDocument { return new VirtualDocument({ language: this.language, foreignCodeExtractors: this.options.foreignCodeExtractorsManager, path: this.documentPath, fileExtension: this.languageFileExtension, // notebooks are continuous, each cell is dependent on the previous one standalone: false, // notebooks are not supported by LSP servers hasLspSupportedFile: false }); } /** * Get the metadata of notebook. */ protected language_info(): ILanguageInfoMetadata { return this._languageInfo; } /** * Initialization function called once the editor and the LSP connection * manager is ready. This function will create the virtual document and * connect various signals. */ protected async initOnceReady(): Promise { await untilReady(this.isReady.bind(this), -1); await this._updateLanguageInfo(); this.initVirtual(); // connect the document, but do not open it as the adapter will handle this // after registering all features this.connectDocument(this.virtualDocument!, false).catch(console.warn); this.widget.context.sessionContext.kernelChanged.connect( this.onKernelChanged, this ); this.widget.content.activeCellChanged.connect( this._activeCellChanged, this ); this._connectModelSignals(this.widget); this.editor.modelChanged.connect(notebook => { // note: this should not usually happen; // there is no default action that would trigger this, // its just a failsafe in case if another extension decides // to swap the notebook model console.warn( 'Model changed, connecting cell change handler; this is not something we were expecting' ); this._connectModelSignals(notebook); }); } /** * Connect the cell changed event to its handler * * @param notebook - The notebook that emitted event. */ private _connectModelSignals(notebook: NotebookPanel | Notebook) { if (notebook.model === null) { console.warn( `Model is missing for notebook ${notebook}, cannot connect cell changed signal!` ); } else { notebook.model.cells.changed.connect(this.handleCellChange, this); } } /** * Update the stored language info with the one from the notebook. */ private async _updateLanguageInfo(): Promise { const language_info = ( await this.widget.context.sessionContext?.session?.kernel?.info )?.language_info; if (language_info) { this._languageInfo = language_info; } else { throw new Error( 'Language info update failed (no session, kernel, or info available)' ); } } /** * Handle the cell changed event * @param notebook - The notebook that emitted event * @param cell - Changed cell. */ private _activeCellChanged(notebook: Notebook, cell: Cell | null) { if (!cell || cell.model.type !== this._type) { return; } this._activeEditorChanged.emit({ editor: this._getCellEditor(cell) }); } /** * Get the cell at the cursor position of the virtual document. * @param pos - Position in the virtual document. */ private _getCellAt(pos: IVirtualPosition): Cell { let editor = this.virtualDocument!.getEditorAtVirtualLine(pos); return this._editorToCell.get(editor)!; } /** * Get the cell editor and add new ones to the mappings. * * @param cell Cell widget * @returns Cell editor accessor */ private _getCellEditor(cell: Cell): Document.IEditor { if (!this._cellToEditor.has(cell)) { const editor = Object.freeze({ getEditor: () => cell.editor, ready: async () => { await cell.ready; return cell.editor!; }, reveal: async () => { await this.editor.scrollToCell(cell); return cell.editor!; } }); this._cellToEditor.set(cell, editor); this._editorToCell.set(editor, cell); cell.disposed.connect(() => { this._cellToEditor.delete(cell); this._editorToCell.delete(editor); this._editorRemoved.emit({ editor }); }); this._editorAdded.emit({ editor }); } return this._cellToEditor.get(cell)!; } /** * A map between the editor accessor and the containing cell */ private _editorToCell: Map; /** * Mapping of cell to editor accessor to ensure accessor uniqueness. */ private _cellToEditor: WeakMap; /** * Metadata of the notebook */ private _languageInfo: ILanguageInfoMetadata; private _type: nbformat.CellType = 'code'; private _readyDelegate = new PromiseDelegate(); }