UNPKG

14.5 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { SessionContext } from '@jupyterlab/apputils';
5import { Cell, ICellModel } from '@jupyterlab/cells';
6import { IEditorMimeTypeService } from '@jupyterlab/codeeditor';
7import {
8 Document,
9 IAdapterOptions,
10 IVirtualPosition,
11 untilReady,
12 VirtualDocument,
13 WidgetLSPAdapter
14} from '@jupyterlab/lsp';
15import * as nbformat from '@jupyterlab/nbformat';
16import { IObservableList } from '@jupyterlab/observables';
17import { Session } from '@jupyterlab/services';
18import { PromiseDelegate } from '@lumino/coreutils';
19import { Signal } from '@lumino/signaling';
20
21import { NotebookPanel } from './panel';
22import { Notebook } from './widget';
23import { CellList } from './celllist';
24
25type ILanguageInfoMetadata = nbformat.ILanguageInfoMetadata;
26
27export class NotebookAdapter extends WidgetLSPAdapter<NotebookPanel> {
28 constructor(
29 public editorWidget: NotebookPanel,
30 protected options: IAdapterOptions
31 ) {
32 super(editorWidget, options);
33 this._editorToCell = new Map();
34 this.editor = editorWidget.content;
35 this._cellToEditor = new WeakMap();
36 Promise.all([
37 this.widget.context.sessionContext.ready,
38 this.connectionManager.ready
39 ])
40 .then(async () => {
41 await this.initOnceReady();
42 this._readyDelegate.resolve();
43 })
44 .catch(console.error);
45 }
46
47 /**
48 * The wrapped `Notebook` widget.
49 */
50 readonly editor: Notebook;
51
52 /**
53 * Get current path of the document.
54 */
55 get documentPath(): string {
56 return this.widget.context.path;
57 }
58
59 /**
60 * Get the mime type of the document.
61 */
62 get mimeType(): string {
63 let mimeType: string | string[];
64 let languageMetadata = this.language_info();
65 if (!languageMetadata || !languageMetadata.mimetype) {
66 // fallback to the code cell mime type if no kernel in use
67 mimeType = this.widget.content.codeMimetype;
68 } else {
69 mimeType = languageMetadata.mimetype;
70 }
71 return Array.isArray(mimeType)
72 ? mimeType[0] ?? IEditorMimeTypeService.defaultMimeType
73 : mimeType;
74 }
75
76 /**
77 * Get the file extension of the document.
78 */
79 get languageFileExtension(): string | undefined {
80 let languageMetadata = this.language_info();
81 if (!languageMetadata || !languageMetadata.file_extension) {
82 return;
83 }
84 return languageMetadata.file_extension.replace('.', '');
85 }
86
87 /**
88 * Get the inner HTMLElement of the document widget.
89 */
90 get wrapperElement(): HTMLElement {
91 return this.widget.node;
92 }
93
94 /**
95 * Get the list of CM editor with its type in the document,
96 */
97 get editors(): Document.ICodeBlockOptions[] {
98 if (this.isDisposed) {
99 return [];
100 }
101
102 let notebook = this.widget.content;
103
104 this._editorToCell.clear();
105
106 if (notebook.isDisposed) {
107 return [];
108 }
109
110 return notebook.widgets.map(cell => {
111 return {
112 ceEditor: this._getCellEditor(cell),
113 type: cell.model.type,
114 value: cell.model.sharedModel.getSource()
115 };
116 });
117 }
118
119 /**
120 * Get the activated CM editor.
121 */
122 get activeEditor(): Document.IEditor | undefined {
123 return this.editor.activeCell
124 ? this._getCellEditor(this.editor.activeCell)
125 : undefined;
126 }
127
128 /**
129 * Promise that resolves once the adapter is initialized
130 */
131 get ready(): Promise<void> {
132 return this._readyDelegate.promise;
133 }
134
135 /**
136 * Get the index of editor from the cursor position in the virtual
137 * document.
138 * @deprecated This is error-prone and will be removed in JupyterLab 5.0, use `getEditorIndex()` with `virtualDocument.getEditorAtVirtualLine(position)` instead.
139 *
140 * @param position - the position of cursor in the virtual document.
141 */
142 getEditorIndexAt(position: IVirtualPosition): number {
143 let cell = this._getCellAt(position);
144 let notebook = this.widget.content;
145 return notebook.widgets.findIndex(otherCell => {
146 return cell === otherCell;
147 });
148 }
149
150 /**
151 * Get the index of input editor
152 *
153 * @param ceEditor - instance of the code editor
154 */
155 getEditorIndex(ceEditor: Document.IEditor): number {
156 let cell = this._editorToCell.get(ceEditor)!;
157 return this.editor.widgets.findIndex(otherCell => {
158 return cell === otherCell;
159 });
160 }
161
162 /**
163 * Get the wrapper of input editor.
164 *
165 * @param ceEditor - instance of the code editor
166 */
167 getEditorWrapper(ceEditor: Document.IEditor): HTMLElement {
168 let cell = this._editorToCell.get(ceEditor)!;
169 return cell.node;
170 }
171
172 /**
173 * Callback on kernel changed event, it will disconnect the
174 * document with the language server and then reconnect.
175 *
176 * @param _session - Session context of changed kernel
177 * @param change - Changed data
178 */
179 async onKernelChanged(
180 _session: SessionContext,
181 change: Session.ISessionConnection.IKernelChangedArgs
182 ): Promise<void> {
183 if (!change.newValue) {
184 return;
185 }
186 try {
187 // note: we need to wait until ready before updating language info
188 const oldLanguageInfo = this._languageInfo;
189 await untilReady(this.isReady, -1);
190 await this._updateLanguageInfo();
191 const newLanguageInfo = this._languageInfo;
192 if (
193 oldLanguageInfo?.name != newLanguageInfo.name ||
194 oldLanguageInfo?.mimetype != newLanguageInfo?.mimetype ||
195 oldLanguageInfo?.file_extension != newLanguageInfo?.file_extension
196 ) {
197 console.log(
198 `Changed to ${this._languageInfo.name} kernel, reconnecting`
199 );
200 this.reloadConnection();
201 } else {
202 console.log(
203 'Keeping old LSP connection as the new kernel uses the same langauge'
204 );
205 }
206 } catch (err) {
207 console.warn(err);
208 // try to reconnect anyway
209 this.reloadConnection();
210 }
211 }
212
213 /**
214 * Dispose the widget.
215 */
216 dispose(): void {
217 if (this.isDisposed) {
218 return;
219 }
220 this.widget.context.sessionContext.kernelChanged.disconnect(
221 this.onKernelChanged,
222 this
223 );
224 this.widget.content.activeCellChanged.disconnect(
225 this._activeCellChanged,
226 this
227 );
228
229 super.dispose();
230
231 // editors are needed for the parent dispose() to unbind signals, so they are the last to go
232 this._editorToCell.clear();
233 Signal.clearData(this);
234 }
235
236 /**
237 * Method to check if the notebook context is ready.
238 */
239 isReady(): boolean {
240 return (
241 !this.widget.isDisposed &&
242 this.widget.context.isReady &&
243 this.widget.content.isVisible &&
244 this.widget.content.widgets.length > 0 &&
245 this.widget.context.sessionContext.session?.kernel != null
246 );
247 }
248
249 /**
250 * Update the virtual document on cell changing event.
251 *
252 * @param cells - Observable list of changed cells
253 * @param change - Changed data
254 */
255 async handleCellChange(
256 cells: CellList,
257 change: IObservableList.IChangedArgs<ICellModel>
258 ): Promise<void> {
259 let cellsAdded: ICellModel[] = [];
260 let cellsRemoved: ICellModel[] = [];
261 const type = this._type;
262 if (change.type === 'set') {
263 // handling of conversions is important, because the editors get re-used and their handlers inherited,
264 // so we need to clear our handlers from editors of e.g. markdown cells which previously were code cells.
265 let convertedToMarkdownOrRaw = [];
266 let convertedToCode = [];
267
268 if (change.newValues.length === change.oldValues.length) {
269 // during conversion the cells should not get deleted nor added
270 for (let i = 0; i < change.newValues.length; i++) {
271 if (
272 change.oldValues[i].type === type &&
273 change.newValues[i].type !== type
274 ) {
275 convertedToMarkdownOrRaw.push(change.newValues[i]);
276 } else if (
277 change.oldValues[i].type !== type &&
278 change.newValues[i].type === type
279 ) {
280 convertedToCode.push(change.newValues[i]);
281 }
282 }
283 cellsAdded = convertedToCode;
284 cellsRemoved = convertedToMarkdownOrRaw;
285 }
286 } else if (change.type == 'add') {
287 cellsAdded = change.newValues.filter(
288 cellModel => cellModel.type === type
289 );
290 }
291 // note: editorRemoved is not emitted for removal of cells by change of type 'remove' (but only during cell type conversion)
292 // because there is no easy way to get the widget associated with the removed cell(s) - because it is no
293 // longer in the notebook widget list! It would need to be tracked on our side, but it is not necessary
294 // as (except for a tiny memory leak) it should not impact the functionality in any way
295
296 if (
297 cellsRemoved.length ||
298 cellsAdded.length ||
299 change.type === 'set' ||
300 change.type === 'move' ||
301 change.type === 'remove'
302 ) {
303 // in contrast to the file editor document which can be only changed by the modification of the editor content,
304 // the notebook document cna also get modified by a change in the number or arrangement of editors themselves;
305 // for this reason each change has to trigger documents update (so that LSP mirror is in sync).
306 await this.updateDocuments();
307 }
308
309 for (let cellModel of cellsAdded) {
310 let cellWidget = this.widget.content.widgets.find(
311 cell => cell.model.id === cellModel.id
312 );
313 if (!cellWidget) {
314 console.warn(
315 `Widget for added cell with ID: ${cellModel.id} not found!`
316 );
317 continue;
318 }
319
320 // Add editor to the mapping if needed
321 this._getCellEditor(cellWidget);
322 }
323 }
324
325 /**
326 * Generate the virtual document associated with the document.
327 */
328 createVirtualDocument(): VirtualDocument {
329 return new VirtualDocument({
330 language: this.language,
331 foreignCodeExtractors: this.options.foreignCodeExtractorsManager,
332 path: this.documentPath,
333 fileExtension: this.languageFileExtension,
334 // notebooks are continuous, each cell is dependent on the previous one
335 standalone: false,
336 // notebooks are not supported by LSP servers
337 hasLspSupportedFile: false
338 });
339 }
340
341 /**
342 * Get the metadata of notebook.
343 */
344 protected language_info(): ILanguageInfoMetadata {
345 return this._languageInfo;
346 }
347 /**
348 * Initialization function called once the editor and the LSP connection
349 * manager is ready. This function will create the virtual document and
350 * connect various signals.
351 */
352 protected async initOnceReady(): Promise<void> {
353 await untilReady(this.isReady.bind(this), -1);
354 await this._updateLanguageInfo();
355 this.initVirtual();
356
357 // connect the document, but do not open it as the adapter will handle this
358 // after registering all features
359 this.connectDocument(this.virtualDocument!, false).catch(console.warn);
360
361 this.widget.context.sessionContext.kernelChanged.connect(
362 this.onKernelChanged,
363 this
364 );
365
366 this.widget.content.activeCellChanged.connect(
367 this._activeCellChanged,
368 this
369 );
370 this._connectModelSignals(this.widget);
371 this.editor.modelChanged.connect(notebook => {
372 // note: this should not usually happen;
373 // there is no default action that would trigger this,
374 // its just a failsafe in case if another extension decides
375 // to swap the notebook model
376 console.warn(
377 'Model changed, connecting cell change handler; this is not something we were expecting'
378 );
379 this._connectModelSignals(notebook);
380 });
381 }
382
383 /**
384 * Connect the cell changed event to its handler
385 *
386 * @param notebook - The notebook that emitted event.
387 */
388 private _connectModelSignals(notebook: NotebookPanel | Notebook) {
389 if (notebook.model === null) {
390 console.warn(
391 `Model is missing for notebook ${notebook}, cannot connect cell changed signal!`
392 );
393 } else {
394 notebook.model.cells.changed.connect(this.handleCellChange, this);
395 }
396 }
397
398 /**
399 * Update the stored language info with the one from the notebook.
400 */
401 private async _updateLanguageInfo(): Promise<void> {
402 const language_info = (
403 await this.widget.context.sessionContext?.session?.kernel?.info
404 )?.language_info;
405 if (language_info) {
406 this._languageInfo = language_info;
407 } else {
408 throw new Error(
409 'Language info update failed (no session, kernel, or info available)'
410 );
411 }
412 }
413
414 /**
415 * Handle the cell changed event
416 * @param notebook - The notebook that emitted event
417 * @param cell - Changed cell.
418 */
419 private _activeCellChanged(notebook: Notebook, cell: Cell | null) {
420 if (!cell || cell.model.type !== this._type) {
421 return;
422 }
423
424 this._activeEditorChanged.emit({
425 editor: this._getCellEditor(cell)
426 });
427 }
428
429 /**
430 * Get the cell at the cursor position of the virtual document.
431 * @param pos - Position in the virtual document.
432 */
433 private _getCellAt(pos: IVirtualPosition): Cell {
434 let editor = this.virtualDocument!.getEditorAtVirtualLine(pos);
435 return this._editorToCell.get(editor)!;
436 }
437
438 /**
439 * Get the cell editor and add new ones to the mappings.
440 *
441 * @param cell Cell widget
442 * @returns Cell editor accessor
443 */
444 private _getCellEditor(cell: Cell): Document.IEditor {
445 if (!this._cellToEditor.has(cell)) {
446 const editor = Object.freeze({
447 getEditor: () => cell.editor,
448 ready: async () => {
449 await cell.ready;
450 return cell.editor!;
451 },
452 reveal: async () => {
453 await this.editor.scrollToCell(cell);
454 return cell.editor!;
455 }
456 });
457
458 this._cellToEditor.set(cell, editor);
459 this._editorToCell.set(editor, cell);
460 cell.disposed.connect(() => {
461 this._cellToEditor.delete(cell);
462 this._editorToCell.delete(editor);
463 this._editorRemoved.emit({
464 editor
465 });
466 });
467
468 this._editorAdded.emit({
469 editor
470 });
471 }
472
473 return this._cellToEditor.get(cell)!;
474 }
475
476 /**
477 * A map between the editor accessor and the containing cell
478 */
479 private _editorToCell: Map<Document.IEditor, Cell>;
480
481 /**
482 * Mapping of cell to editor accessor to ensure accessor uniqueness.
483 */
484 private _cellToEditor: WeakMap<Cell, Document.IEditor>;
485
486 /**
487 * Metadata of the notebook
488 */
489 private _languageInfo: ILanguageInfoMetadata;
490
491 private _type: nbformat.CellType = 'code';
492
493 private _readyDelegate = new PromiseDelegate<void>();
494}