From f79a1544d9bd135399453543b8415f10eef78a6b Mon Sep 17 00:00:00 2001 From: Ssai Sanjanna Ganji Date: Sat, 26 Apr 2025 23:58:53 -0500 Subject: [PATCH 1/2] Fix for issue 1750-prevent re-execution of already running cell --- .yalc/@jupyterlab/notebook/README.md | 5 + .yalc/@jupyterlab/notebook/package.json | 79 + .yalc/@jupyterlab/notebook/src/actions.tsx | 2998 +++++++++++++++ .../@jupyterlab/notebook/src/cellexecutor.ts | 199 + .yalc/@jupyterlab/notebook/src/celllist.ts | 167 + .yalc/@jupyterlab/notebook/src/constants.ts | 14 + .../notebook/src/default-toolbar.tsx | 387 ++ .yalc/@jupyterlab/notebook/src/default.json | 160 + .../notebook/src/executionindicator.tsx | 670 ++++ .yalc/@jupyterlab/notebook/src/history.ts | 412 ++ .yalc/@jupyterlab/notebook/src/index.ts | 27 + .yalc/@jupyterlab/notebook/src/model.ts | 538 +++ .../@jupyterlab/notebook/src/modelfactory.ts | 135 + .yalc/@jupyterlab/notebook/src/modestatus.tsx | 166 + .../notebook/src/notebookfooter.ts | 81 + .../notebook/src/notebooklspadapter.ts | 495 +++ .../@jupyterlab/notebook/src/notebooktools.ts | 612 +++ .yalc/@jupyterlab/notebook/src/panel.ts | 330 ++ .../notebook/src/searchprovider.ts | 937 +++++ .yalc/@jupyterlab/notebook/src/testutils.ts | 281 ++ .yalc/@jupyterlab/notebook/src/toc.ts | 813 ++++ .yalc/@jupyterlab/notebook/src/tokens.ts | 207 + .yalc/@jupyterlab/notebook/src/tracker.ts | 104 + .../@jupyterlab/notebook/src/truststatus.tsx | 289 ++ .yalc/@jupyterlab/notebook/src/widget.ts | 3384 +++++++++++++++++ .../@jupyterlab/notebook/src/widgetfactory.ts | 173 + .yalc/@jupyterlab/notebook/src/windowing.ts | 640 ++++ .yalc/@jupyterlab/notebook/style/base.css | 555 +++ .../notebook/style/executionindicator.css | 65 + .yalc/@jupyterlab/notebook/style/index.css | 20 + .yalc/@jupyterlab/notebook/style/index.js | 21 + .../notebook/style/notebookfooter.css | 39 + .yalc/@jupyterlab/notebook/style/toc.css | 43 + .yalc/@jupyterlab/notebook/style/toolbar.css | 29 + .yalc/@jupyterlab/notebook/yalc.sig | 1 + package.json | 5 +- packages/application/test/shell.spec.d.ts | 1 + packages/application/test/shell.spec.js | 158 + packages/ui-components/test/foo.spec.d.ts | 0 packages/ui-components/test/foo.spec.js | 9 + ui-tests/test/runcell.spec.ts | 28 + yalc.lock | 9 + yarn.lock | 39 + 43 files changed, 15324 insertions(+), 1 deletion(-) create mode 100644 .yalc/@jupyterlab/notebook/README.md create mode 100644 .yalc/@jupyterlab/notebook/package.json create mode 100644 .yalc/@jupyterlab/notebook/src/actions.tsx create mode 100644 .yalc/@jupyterlab/notebook/src/cellexecutor.ts create mode 100644 .yalc/@jupyterlab/notebook/src/celllist.ts create mode 100644 .yalc/@jupyterlab/notebook/src/constants.ts create mode 100644 .yalc/@jupyterlab/notebook/src/default-toolbar.tsx create mode 100644 .yalc/@jupyterlab/notebook/src/default.json create mode 100644 .yalc/@jupyterlab/notebook/src/executionindicator.tsx create mode 100644 .yalc/@jupyterlab/notebook/src/history.ts create mode 100644 .yalc/@jupyterlab/notebook/src/index.ts create mode 100644 .yalc/@jupyterlab/notebook/src/model.ts create mode 100644 .yalc/@jupyterlab/notebook/src/modelfactory.ts create mode 100644 .yalc/@jupyterlab/notebook/src/modestatus.tsx create mode 100644 .yalc/@jupyterlab/notebook/src/notebookfooter.ts create mode 100644 .yalc/@jupyterlab/notebook/src/notebooklspadapter.ts create mode 100644 .yalc/@jupyterlab/notebook/src/notebooktools.ts create mode 100644 .yalc/@jupyterlab/notebook/src/panel.ts create mode 100644 .yalc/@jupyterlab/notebook/src/searchprovider.ts create mode 100644 .yalc/@jupyterlab/notebook/src/testutils.ts create mode 100644 .yalc/@jupyterlab/notebook/src/toc.ts create mode 100644 .yalc/@jupyterlab/notebook/src/tokens.ts create mode 100644 .yalc/@jupyterlab/notebook/src/tracker.ts create mode 100644 .yalc/@jupyterlab/notebook/src/truststatus.tsx create mode 100644 .yalc/@jupyterlab/notebook/src/widget.ts create mode 100644 .yalc/@jupyterlab/notebook/src/widgetfactory.ts create mode 100644 .yalc/@jupyterlab/notebook/src/windowing.ts create mode 100644 .yalc/@jupyterlab/notebook/style/base.css create mode 100644 .yalc/@jupyterlab/notebook/style/executionindicator.css create mode 100644 .yalc/@jupyterlab/notebook/style/index.css create mode 100644 .yalc/@jupyterlab/notebook/style/index.js create mode 100644 .yalc/@jupyterlab/notebook/style/notebookfooter.css create mode 100644 .yalc/@jupyterlab/notebook/style/toc.css create mode 100644 .yalc/@jupyterlab/notebook/style/toolbar.css create mode 100644 .yalc/@jupyterlab/notebook/yalc.sig create mode 100644 packages/application/test/shell.spec.d.ts create mode 100644 packages/application/test/shell.spec.js create mode 100644 packages/ui-components/test/foo.spec.d.ts create mode 100644 packages/ui-components/test/foo.spec.js create mode 100644 ui-tests/test/runcell.spec.ts create mode 100644 yalc.lock diff --git a/.yalc/@jupyterlab/notebook/README.md b/.yalc/@jupyterlab/notebook/README.md new file mode 100644 index 0000000000..07de0b3723 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/README.md @@ -0,0 +1,5 @@ +# @jupyterlab/notebook + +A JupyterLab package which implements the primary interface to the Jupyter notebook. + +Notebook cells are implemented in [@jupyterlab/cells](../cells). diff --git a/.yalc/@jupyterlab/notebook/package.json b/.yalc/@jupyterlab/notebook/package.json new file mode 100644 index 0000000000..dd35643624 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/package.json @@ -0,0 +1,79 @@ +{ + "name": "@jupyterlab/notebook", + "version": "4.4.1", + "description": "JupyterLab - Notebook", + "homepage": "https://github.com/jupyterlab/jupyterlab", + "bugs": { + "url": "https://github.com/jupyterlab/jupyterlab/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/jupyterlab/jupyterlab.git" + }, + "license": "BSD-3-Clause", + "author": "Project Jupyter", + "sideEffects": [ + "style/**/*" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "style": "style/index.css", + "directories": { + "lib": "lib/" + }, + "files": [ + "lib/*.{d.ts,js.map,js,json}", + "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}", + "style/index.js", + "src/**/*.{ts,tsx}", + "src/default.json" + ], + "scripts": { + "build": "tsc -b", + "build:test": "tsc --build tsconfig.test.json", + "clean": "rimraf lib && rimraf tsconfig.tsbuildinfo", + "test": "jest -i", + "test:cov": "jest -i --collect-coverage", + "test:debug": "node --inspect-brk ../../node_modules/.bin/jest --runInBand", + "test:debug:watch": "node --inspect-brk ../../node_modules/.bin/jest --runInBand --watch", + "test:watch": "jest --runInBand --watch", + "watch": "tsc -b --watch" + }, + "dependencies": { + "@jupyter/ydoc": "^3.0.4", + "@jupyterlab/apputils": "^4.5.1", + "@jupyterlab/cells": "^4.4.1", + "@jupyterlab/codeeditor": "^4.4.1", + "@jupyterlab/codemirror": "^4.4.1", + "@jupyterlab/coreutils": "^6.4.1", + "@jupyterlab/docregistry": "^4.4.1", + "@jupyterlab/documentsearch": "^4.4.1", + "@jupyterlab/lsp": "^4.4.1", + "@jupyterlab/nbformat": "^4.4.1", + "@jupyterlab/observables": "^5.4.1", + "@jupyterlab/rendermime": "^4.4.1", + "@jupyterlab/services": "^7.4.1", + "@jupyterlab/settingregistry": "^4.4.1", + "@jupyterlab/statusbar": "^4.4.1", + "@jupyterlab/toc": "^6.4.1", + "@jupyterlab/translation": "^4.4.1", + "@jupyterlab/ui-components": "^4.4.1", + "@lumino/algorithm": "^2.0.3", + "@lumino/coreutils": "^2.2.1", + "@lumino/disposable": "^2.1.4", + "@lumino/domutils": "^2.0.3", + "@lumino/dragdrop": "^2.1.6", + "@lumino/messaging": "^2.0.3", + "@lumino/polling": "^2.1.4", + "@lumino/properties": "^2.0.3", + "@lumino/signaling": "^2.1.4", + "@lumino/virtualdom": "^2.0.3", + "@lumino/widgets": "^2.7.0", + "react": "^18.2.0" + }, + "publishConfig": { + "access": "public" + }, + "styleModule": "style/index.js", + "yalcSig": "9fcf69f7c3db5e34220fca94eb3c4503" +} diff --git a/.yalc/@jupyterlab/notebook/src/actions.tsx b/.yalc/@jupyterlab/notebook/src/actions.tsx new file mode 100644 index 0000000000..e8119ad380 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/actions.tsx @@ -0,0 +1,2998 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + Clipboard, + Dialog, + ISessionContext, + ISessionContextDialogs, + showDialog +} from '@jupyterlab/apputils'; +import { + Cell, + CodeCell, + ICellModel, + ICodeCellModel, + isMarkdownCellModel, + isRawCellModel, + MarkdownCell +} from '@jupyterlab/cells'; +import { Notification } from '@jupyterlab/apputils'; +import { signalToPromise } from '@jupyterlab/coreutils'; +import * as nbformat from '@jupyterlab/nbformat'; +import { KernelMessage } from '@jupyterlab/services'; +import { ISharedAttachmentsCell } from '@jupyter/ydoc'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { every, findIndex } from '@lumino/algorithm'; +import { JSONExt, JSONObject } from '@lumino/coreutils'; +import { ISignal, Signal } from '@lumino/signaling'; +import * as React from 'react'; +import { runCell as defaultRunCell } from './cellexecutor'; +import { Notebook, StaticNotebook } from './widget'; +import { NotebookWindowedLayout } from './windowing'; +import { INotebookCellExecutor } from './tokens'; + +/** + * The mimetype used for Jupyter cell data. + */ +const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells'; + +export class KernelError extends Error { + /** + * Exception name + */ + readonly errorName: string; + /** + * Exception value + */ + readonly errorValue: string; + /** + * Traceback + */ + readonly traceback: string[]; + + /** + * Construct the kernel error. + */ + constructor(content: KernelMessage.IExecuteReplyMsg['content']) { + const errorContent = content as KernelMessage.IReplyErrorContent; + const errorName = errorContent.ename; + const errorValue = errorContent.evalue; + super(`KernelReplyNotOK: ${errorName} ${errorValue}`); + + this.errorName = errorName; + this.errorValue = errorValue; + this.traceback = errorContent.traceback; + Object.setPrototypeOf(this, KernelError.prototype); + } +} + +/** + * A collection of actions that run against notebooks. + * + * #### Notes + * All of the actions are a no-op if there is no model on the notebook. + * The actions set the widget `mode` to `'command'` unless otherwise specified. + * The actions will preserve the selection on the notebook widget unless + * otherwise specified. + */ +export class NotebookActions { + /** + * A signal that emits whenever a cell completes execution. + */ + static get executed(): ISignal< + any, + { + notebook: Notebook; + cell: Cell; + success: boolean; + error?: KernelError | null; + } + > { + return Private.executed; + } + + /** + * A signal that emits whenever a cell execution is scheduled. + */ + static get executionScheduled(): ISignal< + any, + { notebook: Notebook; cell: Cell } + > { + return Private.executionScheduled; + } + + /** + * A signal that emits when one notebook's cells are all executed. + */ + static get selectionExecuted(): ISignal< + any, + { notebook: Notebook; lastCell: Cell } + > { + return Private.selectionExecuted; + } + + /** + * A signal that emits when a cell's output is cleared. + */ + static get outputCleared(): ISignal { + return Private.outputCleared; + } + + /** + * A private constructor for the `NotebookActions` class. + * + * #### Notes + * This class can never be instantiated. Its static member `executed` will be + * merged with the `NotebookActions` namespace. The reason it exists as a + * standalone class is because at run time, the `Private.executed` variable + * does not yet exist, so it needs to be referenced via a getter. + */ + private constructor() { + // Intentionally empty. + } +} + +/** + * A namespace for `NotebookActions` static methods. + */ +export namespace NotebookActions { + /** + * Split the active cell into two or more cells. + * + * @param notebook The target notebook widget. + * + * #### Notes + * It will preserve the existing mode. + * The last cell will be activated if no selection is found. + * If text was selected, the cell containing the selection will + * be activated. + * The existing selection will be cleared. + * The activated cell will have focus and the cursor will + * remain in the initial position. + * The leading whitespace in the second cell will be removed. + * If there is no content, two empty cells will be created. + * Both cells will have the same type as the original cell. + * This action can be undone. + */ + export function splitCell(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + // We force the notebook back in edit mode as splitting a cell + // requires using the cursor position within a cell (aka it was recently in edit mode) + // However the focus may be stolen if the action is triggered + // from the menu entry; switching the notebook in command mode. + notebook.mode = 'edit'; + + notebook.deselectAll(); + + const nbModel = notebook.model; + const index = notebook.activeCellIndex; + const child = notebook.widgets[index]; + const editor = child.editor; + if (!editor) { + // TODO + return; + } + const selections = editor.getSelections(); + const orig = child.model.sharedModel.getSource(); + + const offsets = [0]; + + let start: number = -1; + let end: number = -1; + for (let i = 0; i < selections.length; i++) { + // append start and end to handle selections + // cursors will have same start and end + start = editor.getOffsetAt(selections[i].start); + end = editor.getOffsetAt(selections[i].end); + if (start < end) { + offsets.push(start); + offsets.push(end); + } else if (end < start) { + offsets.push(end); + offsets.push(start); + } else { + offsets.push(start); + } + } + + offsets.push(orig.length); + + const cellCountAfterSplit = offsets.length - 1; + const clones = offsets.slice(0, -1).map((offset, offsetIdx) => { + const { cell_type, metadata, outputs } = child.model.sharedModel.toJSON(); + + return { + cell_type, + metadata, + source: orig + .slice(offset, offsets[offsetIdx + 1]) + .replace(/^\n+/, '') + .replace(/\n+$/, ''), + outputs: + offsetIdx === cellCountAfterSplit - 1 && cell_type === 'code' + ? outputs + : undefined + }; + }); + + nbModel.sharedModel.transact(() => { + nbModel.sharedModel.deleteCell(index); + nbModel.sharedModel.insertCells(index, clones); + }); + + // If there is a selection the selected cell will be activated + const activeCellDelta = start !== end ? 2 : 1; + notebook.activeCellIndex = index + clones.length - activeCellDelta; + notebook + .scrollToItem(notebook.activeCellIndex) + .then(() => { + notebook.activeCell?.editor!.focus(); + }) + .catch(reason => { + // no-op + }); + + void Private.handleState(notebook, state); + } + + /** + * Merge the selected cells. + * + * @param notebook - The target notebook widget. + * + * @param mergeAbove - If only one cell is selected, indicates whether to merge it + * with the cell above (true) or below (false, default). + * + * #### Notes + * The widget mode will be preserved. + * If only one cell is selected and `mergeAbove` is true, the above cell will be selected. + * If only one cell is selected and `mergeAbove` is false, the below cell will be selected. + * If the active cell is a code cell, its outputs will be cleared. + * This action can be undone. + * The final cell will have the same type as the active cell. + * If the active cell is a markdown cell, it will be unrendered. + */ + export function mergeCells( + notebook: Notebook, + mergeAbove: boolean = false + ): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + const toMerge: string[] = []; + const toDelete: number[] = []; + const model = notebook.model; + const cells = model.cells; + const primary = notebook.activeCell; + const active = notebook.activeCellIndex; + const attachments: nbformat.IAttachments = {}; + + // Get the cells to merge. + notebook.widgets.forEach((child, index) => { + if (notebook.isSelectedOrActive(child)) { + toMerge.push(child.model.sharedModel.getSource()); + if (index !== active) { + toDelete.push(index); + } + // Collect attachments if the cell is a markdown cell or a raw cell + const model = child.model; + if (isRawCellModel(model) || isMarkdownCellModel(model)) { + for (const key of model.attachments.keys) { + attachments[key] = model.attachments.get(key)!.toJSON(); + } + } + } + }); + + // Check for only a single cell selected. + if (toMerge.length === 1) { + // Merge with the cell above when mergeAbove is true + if (mergeAbove === true) { + // Bail if it is the first cell. + if (active === 0) { + return; + } + // Otherwise merge with the previous cell. + const cellModel = cells.get(active - 1); + + toMerge.unshift(cellModel.sharedModel.getSource()); + toDelete.push(active - 1); + } else if (mergeAbove === false) { + // Bail if it is the last cell. + if (active === cells.length - 1) { + return; + } + // Otherwise merge with the next cell. + const cellModel = cells.get(active + 1); + + toMerge.push(cellModel.sharedModel.getSource()); + toDelete.push(active + 1); + } + } + + notebook.deselectAll(); + + const primaryModel = primary.model.sharedModel; + const { cell_type, metadata } = primaryModel.toJSON(); + if (primaryModel.cell_type === 'code') { + // We can trust this cell because the outputs will be removed. + metadata.trusted = true; + } + const newModel = { + cell_type, + metadata, + source: toMerge.join('\n\n'), + attachments: + primaryModel.cell_type === 'markdown' || + primaryModel.cell_type === 'raw' + ? attachments + : undefined + }; + + // Make the changes while preserving history. + model.sharedModel.transact(() => { + model.sharedModel.deleteCell(active); + model.sharedModel.insertCell(active, newModel); + toDelete + .sort((a, b) => b - a) + .forEach(index => { + model.sharedModel.deleteCell(index); + }); + }); + // If the original cell is a markdown cell, make sure + // the new cell is unrendered. + if (primary instanceof MarkdownCell) { + (notebook.activeCell as MarkdownCell).rendered = false; + } + + void Private.handleState(notebook, state); + } + + /** + * Delete the selected cells. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The cell after the last selected cell will be activated. + * It will add a code cell if all cells are deleted. + * This action can be undone. + */ + export function deleteCells(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + Private.deleteCells(notebook); + void Private.handleState(notebook, state, true); + } + + /** + * Insert a new code cell above the active cell or in index 0 if the notebook is empty. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The widget mode will be preserved. + * This action can be undone. + * The existing selection will be cleared. + * The new cell will the active cell. + */ + export function insertAbove(notebook: Notebook): void { + if (!notebook.model) { + return; + } + + const state = Private.getState(notebook); + const model = notebook.model; + + const newIndex = notebook.activeCell ? notebook.activeCellIndex : 0; + model.sharedModel.insertCell(newIndex, { + cell_type: notebook.notebookConfig.defaultCell, + metadata: + notebook.notebookConfig.defaultCell === 'code' + ? { + // This is an empty cell created by user, thus is trusted + trusted: true + } + : {} + }); + // Make the newly inserted cell active. + notebook.activeCellIndex = newIndex; + + notebook.deselectAll(); + void Private.handleState(notebook, state, true); + } + + /** + * Insert a new code cell below the active cell or in index 0 if the notebook is empty. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The widget mode will be preserved. + * This action can be undone. + * The existing selection will be cleared. + * The new cell will be the active cell. + */ + export function insertBelow(notebook: Notebook): void { + if (!notebook.model) { + return; + } + + const state = Private.getState(notebook); + const model = notebook.model; + + const newIndex = notebook.activeCell ? notebook.activeCellIndex + 1 : 0; + model.sharedModel.insertCell(newIndex, { + cell_type: notebook.notebookConfig.defaultCell, + metadata: + notebook.notebookConfig.defaultCell === 'code' + ? { + // This is an empty cell created by user, thus is trusted + trusted: true + } + : {} + }); + // Make the newly inserted cell active. + notebook.activeCellIndex = newIndex; + + notebook.deselectAll(); + void Private.handleState(notebook, state, true); + } + + function move(notebook: Notebook, shift: number): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + const firstIndex = notebook.widgets.findIndex(w => + notebook.isSelectedOrActive(w) + ); + let lastIndex = notebook.widgets + .slice(firstIndex + 1) + .findIndex(w => !notebook.isSelectedOrActive(w)); + + if (lastIndex >= 0) { + lastIndex += firstIndex + 1; + } else { + lastIndex = notebook.model.cells.length; + } + + if (shift > 0) { + notebook.moveCell(firstIndex, lastIndex, lastIndex - firstIndex); + } else { + notebook.moveCell(firstIndex, firstIndex + shift, lastIndex - firstIndex); + } + + void Private.handleState(notebook, state, true); + } + + /** + * Move the selected cell(s) down. + * + * @param notebook = The target notebook widget. + */ + export function moveDown(notebook: Notebook): void { + move(notebook, 1); + } + + /** + * Move the selected cell(s) up. + * + * @param notebook - The target notebook widget. + */ + export function moveUp(notebook: Notebook): void { + move(notebook, -1); + } + + /** + * Change the selected cell type(s). + * + * @param notebook - The target notebook widget. + * @param value - The target cell type. + * @param translator - The application translator. + * + * #### Notes + * It should preserve the widget mode. + * This action can be undone. + * The existing selection will be cleared. + * Any cells converted to markdown will be unrendered. + */ + export function changeCellType( + notebook: Notebook, + value: nbformat.CellType, + translator?: ITranslator + ): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + Private.changeCellType(notebook, value, translator); + void Private.handleState(notebook, state); + } + + /** + * Run the selected cell(s). + * + * @param notebook - The target notebook widget. + * @param sessionContext - The client session object. + * @param sessionDialogs - The session dialogs. + * @param translator - The application translator. + * + * #### Notes + * The last selected cell will be activated, but not scrolled into view. + * The existing selection will be cleared. + * An execution error will prevent the remaining code cells from executing. + * All markdown cells will be rendered. + */ + export function run( + notebook: Notebook, + sessionContext?: ISessionContext, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): Promise { + if (!notebook.model || !notebook.activeCell) { + return Promise.resolve(false); + } + + const state = Private.getState(notebook); + const promise = Private.runSelected( + notebook, + sessionContext, + sessionDialogs, + translator + ); + + void Private.handleRunState(notebook, state); + return promise; + } + + /** + * Run specified cells. + * + * @param notebook - The target notebook widget. + * @param cells - The cells to run. + * @param sessionContext - The client session object. + * @param sessionDialogs - The session dialogs. + * @param translator - The application translator. + * + * #### Notes + * The existing selection will be preserved. + * The mode will be changed to command. + * An execution error will prevent the remaining code cells from executing. + * All markdown cells will be rendered. + */ + export function runCells( + notebook: Notebook, + cells: readonly Cell[], + sessionContext?: ISessionContext, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): Promise { + if (!notebook.model) { + return Promise.resolve(false); + } + + const state = Private.getState(notebook); + const promise = Private.runCells( + notebook, + cells, + sessionContext, + sessionDialogs, + translator + ); + + void Private.handleRunState(notebook, state); + return promise; + } + + /** + * Run the selected cell(s) and advance to the next cell. + * + * @param notebook - The target notebook widget. + * @param sessionContext - The client session object. + * @param sessionDialogs - The session dialogs. + * @param translator - The application translator. + * + * #### Notes + * The existing selection will be cleared. + * The cell after the last selected cell will be activated and scrolled into view. + * An execution error will prevent the remaining code cells from executing. + * All markdown cells will be rendered. + * If the last selected cell is the last cell, a new code cell + * will be created in `'edit'` mode. The new cell creation can be undone. + */ + export async function runAndAdvance( + notebook: Notebook, + sessionContext?: ISessionContext, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): Promise { + if (!notebook.model || !notebook.activeCell) { + return Promise.resolve(false); + } + + const state = Private.getState(notebook); + const promise = Private.runSelected( + notebook, + sessionContext, + sessionDialogs, + translator + ); + const model = notebook.model; + + if (notebook.activeCellIndex === notebook.widgets.length - 1) { + // Do not use push here, as we want an widget insertion + // to make sure no placeholder widget is rendered. + model.sharedModel.insertCell(notebook.widgets.length, { + cell_type: notebook.notebookConfig.defaultCell, + metadata: + notebook.notebookConfig.defaultCell === 'code' + ? { + // This is an empty cell created by user, thus is trusted + trusted: true + } + : {} + }); + notebook.activeCellIndex++; + if (notebook.activeCell?.inViewport === false) { + await signalToPromise(notebook.activeCell.inViewportChanged, 200).catch( + () => { + // no-op + } + ); + } + notebook.mode = 'edit'; + } else { + notebook.activeCellIndex++; + } + + // If a cell is outside of viewport and scrolling is needed, the `smart` + // logic in `handleRunState` will choose appropriate alignment, except + // for the case of a small cell less than one viewport away for which it + // would use the `auto` heuristic, for which we set the preferred alignment + // to `center` as in most cases there will be space below and above a cell + // that is smaller than (or approximately equal to) the viewport size. + void Private.handleRunState(notebook, state, 'center'); + return promise; + } + + /** + * Run the selected cell(s) and insert a new code cell. + * + * @param notebook - The target notebook widget. + * @param sessionContext - The client session object. + * @param sessionDialogs - The session dialogs. + * @param translator - The application translator. + * + * #### Notes + * An execution error will prevent the remaining code cells from executing. + * All markdown cells will be rendered. + * The widget mode will be set to `'edit'` after running. + * The existing selection will be cleared. + * The cell insert can be undone. + * The new cell will be scrolled into view. + */ + export async function runAndInsert( + notebook: Notebook, + sessionContext?: ISessionContext, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): Promise { + if (!notebook.model || !notebook.activeCell) { + return Promise.resolve(false); + } + + const state = Private.getState(notebook); + const promise = Private.runSelected( + notebook, + sessionContext, + sessionDialogs, + translator + ); + const model = notebook.model; + model.sharedModel.insertCell(notebook.activeCellIndex + 1, { + cell_type: notebook.notebookConfig.defaultCell, + metadata: + notebook.notebookConfig.defaultCell === 'code' + ? { + // This is an empty cell created by user, thus is trusted + trusted: true + } + : {} + }); + notebook.activeCellIndex++; + if (notebook.activeCell?.inViewport === false) { + await signalToPromise(notebook.activeCell.inViewportChanged, 200).catch( + () => { + // no-op + } + ); + } + notebook.mode = 'edit'; + void Private.handleRunState(notebook, state, 'center'); + return promise; + } + + /** + * Run all of the cells in the notebook. + * + * @param notebook - The target notebook widget. + * @param sessionContext - The client session object. + * @param sessionDialogs - The session dialogs. + * @param translator - The application translator. + * + * #### Notes + * The existing selection will be cleared. + * An execution error will prevent the remaining code cells from executing. + * All markdown cells will be rendered. + * The last cell in the notebook will be activated and scrolled into view. + */ + export function runAll( + notebook: Notebook, + sessionContext?: ISessionContext, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): Promise { + if (!notebook.model || !notebook.activeCell) { + return Promise.resolve(false); + } + + const state = Private.getState(notebook); + const lastIndex = notebook.widgets.length; + + const promise = Private.runCells( + notebook, + notebook.widgets, + sessionContext, + sessionDialogs, + translator + ); + + notebook.activeCellIndex = lastIndex; + notebook.deselectAll(); + + void Private.handleRunState(notebook, state); + return promise; + } + + export function renderAllMarkdown(notebook: Notebook): Promise { + if (!notebook.model || !notebook.activeCell) { + return Promise.resolve(false); + } + const previousIndex = notebook.activeCellIndex; + const state = Private.getState(notebook); + notebook.widgets.forEach((child, index) => { + if (child.model.type === 'markdown') { + notebook.select(child); + // This is to make sure that the activeCell + // does not get executed + notebook.activeCellIndex = index; + } + }); + if (notebook.activeCell.model.type !== 'markdown') { + return Promise.resolve(true); + } + const promise = Private.runSelected(notebook); + notebook.activeCellIndex = previousIndex; + void Private.handleRunState(notebook, state); + return promise; + } + + /** + * Run all of the cells before the currently active cell (exclusive). + * + * @param notebook - The target notebook widget. + * @param sessionContext - The client session object. + * @param sessionDialogs - The session dialogs. + * @param translator - The application translator. + * + * #### Notes + * The existing selection will be cleared. + * An execution error will prevent the remaining code cells from executing. + * All markdown cells will be rendered. + * The currently active cell will remain selected. + */ + export function runAllAbove( + notebook: Notebook, + sessionContext?: ISessionContext, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): Promise { + const { activeCell, activeCellIndex, model } = notebook; + + if (!model || !activeCell || activeCellIndex < 1) { + return Promise.resolve(false); + } + + const state = Private.getState(notebook); + + const promise = Private.runCells( + notebook, + notebook.widgets.slice(0, notebook.activeCellIndex), + sessionContext, + sessionDialogs, + translator + ); + + notebook.deselectAll(); + + void Private.handleRunState(notebook, state); + return promise; + } + + /** + * Run all of the cells after the currently active cell (inclusive). + * + * @param notebook - The target notebook widget. + * @param sessionContext - The client session object. + * @param sessionDialogs - The session dialogs. + * @param translator - The application translator. + * + * #### Notes + * The existing selection will be cleared. + * An execution error will prevent the remaining code cells from executing. + * All markdown cells will be rendered. + * The last cell in the notebook will be activated and scrolled into view. + */ + export function runAllBelow( + notebook: Notebook, + sessionContext?: ISessionContext, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): Promise { + if (!notebook.model || !notebook.activeCell) { + return Promise.resolve(false); + } + + const state = Private.getState(notebook); + const lastIndex = notebook.widgets.length; + + const promise = Private.runCells( + notebook, + notebook.widgets.slice(notebook.activeCellIndex), + sessionContext, + sessionDialogs, + translator + ); + + notebook.activeCellIndex = lastIndex; + notebook.deselectAll(); + + void Private.handleRunState(notebook, state); + return promise; + } + + /** + * Replaces the selection in the active cell of the notebook. + * + * @param notebook - The target notebook widget. + * @param text - The text to replace the selection. + */ + export function replaceSelection(notebook: Notebook, text: string): void { + if (!notebook.model || !notebook.activeCell?.editor) { + return; + } + notebook.activeCell.editor.replaceSelection?.(text); + } + + /** + * Select the above the active cell. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The widget mode will be preserved. + * This is a no-op if the first cell is the active cell. + * This will skip any collapsed cells. + * The existing selection will be cleared. + */ + export function selectAbove(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + const footer = (notebook.layout as NotebookWindowedLayout).footer; + if (footer && document.activeElement === footer.node) { + footer.node.blur(); + notebook.mode = 'command'; + return; + } + + if (notebook.activeCellIndex === 0) { + return; + } + + let possibleNextCellIndex = notebook.activeCellIndex - 1; + + // find first non hidden cell above current cell + while (possibleNextCellIndex >= 0) { + const possibleNextCell = notebook.widgets[possibleNextCellIndex]; + if (!possibleNextCell.inputHidden && !possibleNextCell.isHidden) { + break; + } + possibleNextCellIndex -= 1; + } + + const state = Private.getState(notebook); + notebook.activeCellIndex = possibleNextCellIndex; + notebook.deselectAll(); + void Private.handleState(notebook, state, true); + } + + /** + * Select the cell below the active cell. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The widget mode will be preserved. + * This is a no-op if the last cell is the active cell. + * This will skip any collapsed cells. + * The existing selection will be cleared. + */ + export function selectBelow(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + let maxCellIndex = notebook.widgets.length - 1; + + // Find last non-hidden cell + while ( + notebook.widgets[maxCellIndex].isHidden || + notebook.widgets[maxCellIndex].inputHidden + ) { + maxCellIndex -= 1; + } + + if (notebook.activeCellIndex === maxCellIndex) { + const footer = (notebook.layout as NotebookWindowedLayout).footer; + footer?.node.focus(); + return; + } + + let possibleNextCellIndex = notebook.activeCellIndex + 1; + + // find first non hidden cell below current cell + while (possibleNextCellIndex < maxCellIndex) { + let possibleNextCell = notebook.widgets[possibleNextCellIndex]; + if (!possibleNextCell.inputHidden && !possibleNextCell.isHidden) { + break; + } + possibleNextCellIndex += 1; + } + + const state = Private.getState(notebook); + notebook.activeCellIndex = possibleNextCellIndex; + notebook.deselectAll(); + void Private.handleState(notebook, state, true); + } + + /** Insert new heading of same level above active cell. + * + * @param notebook - The target notebook widget + */ + export async function insertSameLevelHeadingAbove( + notebook: Notebook + ): Promise { + if (!notebook.model || !notebook.activeCell) { + return; + } + let headingLevel = Private.Headings.determineHeadingLevel( + notebook.activeCell, + notebook + ); + if (headingLevel == -1) { + await Private.Headings.insertHeadingAboveCellIndex(0, 1, notebook); + } else { + await Private.Headings.insertHeadingAboveCellIndex( + notebook.activeCellIndex!, + headingLevel, + notebook + ); + } + } + + /** Insert new heading of same level at end of current section. + * + * @param notebook - The target notebook widget + */ + export async function insertSameLevelHeadingBelow( + notebook: Notebook + ): Promise { + if (!notebook.model || !notebook.activeCell) { + return; + } + let headingLevel = Private.Headings.determineHeadingLevel( + notebook.activeCell, + notebook + ); + headingLevel = headingLevel > -1 ? headingLevel : 1; + let cellIdxOfHeadingBelow = + Private.Headings.findLowerEqualLevelHeadingBelow( + notebook.activeCell, + notebook, + true + ) as number; + await Private.Headings.insertHeadingAboveCellIndex( + cellIdxOfHeadingBelow == -1 + ? notebook.model.cells.length + : cellIdxOfHeadingBelow, + headingLevel, + notebook + ); + } + + /** + * Select the heading above the active cell or, if already at heading, collapse it. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The widget mode will be preserved. + * This is a no-op if the active cell is the topmost heading in collapsed state + * The existing selection will be cleared. + */ + export function selectHeadingAboveOrCollapseHeading( + notebook: Notebook + ): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + const state = Private.getState(notebook); + let hInfoActiveCell = getHeadingInfo(notebook.activeCell); + // either collapse or find the right heading to jump to + if (hInfoActiveCell.isHeading && !hInfoActiveCell.collapsed) { + setHeadingCollapse(notebook.activeCell, true, notebook); + } else { + let targetHeadingCellIdx = + Private.Headings.findLowerEqualLevelParentHeadingAbove( + notebook.activeCell, + notebook, + true + ) as number; + if (targetHeadingCellIdx > -1) { + notebook.activeCellIndex = targetHeadingCellIdx; + } + } + // clear selection and handle state + notebook.deselectAll(); + void Private.handleState(notebook, state, true); + } + + /** + * Select the heading below the active cell or, if already at heading, expand it. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The widget mode will be preserved. + * This is a no-op if the active cell is the last heading in expanded state + * The existing selection will be cleared. + */ + export function selectHeadingBelowOrExpandHeading(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + const state = Private.getState(notebook); + let hInfo = getHeadingInfo(notebook.activeCell); + if (hInfo.isHeading && hInfo.collapsed) { + setHeadingCollapse(notebook.activeCell, false, notebook); + } else { + let targetHeadingCellIdx = Private.Headings.findHeadingBelow( + notebook.activeCell, + notebook, + true // return index of heading cell + ) as number; + if (targetHeadingCellIdx > -1) { + notebook.activeCellIndex = targetHeadingCellIdx; + } + } + notebook.deselectAll(); + void Private.handleState(notebook, state, true); + } + + /** + * Extend the selection to the cell above. + * + * @param notebook - The target notebook widget. + * @param toTop - If true, denotes selection to extend to the top. + * + * #### Notes + * This is a no-op if the first cell is the active cell. + * The new cell will be activated. + */ + export function extendSelectionAbove( + notebook: Notebook, + toTop: boolean = false + ): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + // Do not wrap around. + if (notebook.activeCellIndex === 0) { + return; + } + + const state = Private.getState(notebook); + + notebook.mode = 'command'; + // Check if toTop is true, if yes, selection is made to the top. + if (toTop) { + notebook.extendContiguousSelectionTo(0); + } else { + notebook.extendContiguousSelectionTo(notebook.activeCellIndex - 1); + } + void Private.handleState(notebook, state, true); + } + + /** + * Extend the selection to the cell below. + * + * @param notebook - The target notebook widget. + * @param toBottom - If true, denotes selection to extend to the bottom. + * + * #### Notes + * This is a no-op if the last cell is the active cell. + * The new cell will be activated. + */ + export function extendSelectionBelow( + notebook: Notebook, + toBottom: boolean = false + ): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + // Do not wrap around. + if (notebook.activeCellIndex === notebook.widgets.length - 1) { + return; + } + + const state = Private.getState(notebook); + + notebook.mode = 'command'; + // Check if toBottom is true, if yes selection is made to the bottom. + if (toBottom) { + notebook.extendContiguousSelectionTo(notebook.widgets.length - 1); + } else { + notebook.extendContiguousSelectionTo(notebook.activeCellIndex + 1); + } + void Private.handleState(notebook, state, true); + } + + /** + * Select all of the cells of the notebook. + * + * @param notebook - the target notebook widget. + */ + export function selectAll(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + notebook.widgets.forEach(child => { + notebook.select(child); + }); + } + + /** + * Deselect all of the cells of the notebook. + * + * @param notebook - the target notebook widget. + */ + export function deselectAll(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + notebook.deselectAll(); + } + + /** + * Copy the selected cell(s) data to a clipboard. + * + * @param notebook - The target notebook widget. + */ + export function copy(notebook: Notebook): void { + Private.copyOrCut(notebook, false); + } + + /** + * Cut the selected cell data to a clipboard. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * This action can be undone. + * A new code cell is added if all cells are cut. + */ + export function cut(notebook: Notebook): void { + Private.copyOrCut(notebook, true); + } + + /** + * Paste cells from the application clipboard. + * + * @param notebook - The target notebook widget. + * + * @param mode - the mode of adding cells: + * 'below' (default) adds cells below the active cell, + * 'belowSelected' adds cells below all selected cells, + * 'above' adds cells above the active cell, and + * 'replace' removes the currently selected cells and adds cells in their place. + * + * #### Notes + * The last pasted cell becomes the active cell. + * This is a no-op if there is no cell data on the clipboard. + * This action can be undone. + */ + export function paste( + notebook: Notebook, + mode: 'below' | 'belowSelected' | 'above' | 'replace' = 'below' + ): void { + const clipboard = Clipboard.getInstance(); + + if (!clipboard.hasData(JUPYTER_CELL_MIME)) { + return; + } + + const values = clipboard.getData(JUPYTER_CELL_MIME) as nbformat.IBaseCell[]; + + addCells(notebook, mode, values, true); + void focusActiveCell(notebook); + } + + /** + * Duplicate selected cells in the notebook without using the application clipboard. + * + * @param notebook - The target notebook widget. + * + * @param mode - the mode of adding cells: + * 'below' (default) adds cells below the active cell, + * 'belowSelected' adds cells below all selected cells, + * 'above' adds cells above the active cell, and + * 'replace' removes the currently selected cells and adds cells in their place. + * + * #### Notes + * The last pasted cell becomes the active cell. + * This is a no-op if there is no cell data on the clipboard. + * This action can be undone. + */ + export function duplicate( + notebook: Notebook, + mode: 'below' | 'belowSelected' | 'above' | 'replace' = 'below' + ): void { + const values = Private.selectedCells(notebook); + + if (!values || values.length === 0) { + return; + } + + addCells(notebook, mode, values, false); // Cells not from the clipboard + } + + /** + * Adds cells to the notebook. + * + * @param notebook - The target notebook widget. + * + * @param mode - the mode of adding cells: + * 'below' (default) adds cells below the active cell, + * 'belowSelected' adds cells below all selected cells, + * 'above' adds cells above the active cell, and + * 'replace' removes the currently selected cells and adds cells in their place. + * + * @param values — The cells to add to the notebook. + * + * @param cellsFromClipboard — True if the cells were sourced from the clipboard. + * + * #### Notes + * The last added cell becomes the active cell. + * This is a no-op if values is an empty array. + * This action can be undone. + */ + + function addCells( + notebook: Notebook, + mode: 'below' | 'belowSelected' | 'above' | 'replace' = 'below', + values: nbformat.IBaseCell[], + cellsFromClipboard: boolean = false + ): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + const model = notebook.model; + + notebook.mode = 'command'; + + let index = 0; + const prevActiveCellIndex = notebook.activeCellIndex; + + model.sharedModel.transact(() => { + // Set the starting index of the paste operation depending upon the mode. + switch (mode) { + case 'below': + index = notebook.activeCellIndex + 1; + break; + case 'belowSelected': + notebook.widgets.forEach((child, childIndex) => { + if (notebook.isSelectedOrActive(child)) { + index = childIndex + 1; + } + }); + + break; + case 'above': + index = notebook.activeCellIndex; + break; + case 'replace': { + // Find the cells to delete. + const toDelete: number[] = []; + + notebook.widgets.forEach((child, index) => { + const deletable = + (child.model.sharedModel.getMetadata( + 'deletable' + ) as unknown as boolean) !== false; + + if (notebook.isSelectedOrActive(child) && deletable) { + toDelete.push(index); + } + }); + + // If cells are not deletable, we may not have anything to delete. + if (toDelete.length > 0) { + // Delete the cells as one undo event. + toDelete.reverse().forEach(i => { + model.sharedModel.deleteCell(i); + }); + } + index = toDelete[0]; + break; + } + default: + break; + } + + model.sharedModel.insertCells( + index, + values.map(cell => { + cell.id = + cell.cell_type === 'code' && + notebook.lastClipboardInteraction === 'cut' && + typeof cell.id === 'string' + ? cell.id + : undefined; + return cell; + }) + ); + }); + + notebook.activeCellIndex = prevActiveCellIndex + values.length; + notebook.deselectAll(); + if (cellsFromClipboard) { + notebook.lastClipboardInteraction = 'paste'; + } + void Private.handleState(notebook, state, true); + } + + /** + * Undo a cell action. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * This is a no-op if there are no cell actions to undo. + */ + export function undo(notebook: Notebook): void { + if (!notebook.model) { + return; + } + + const state = Private.getState(notebook); + + notebook.mode = 'command'; + notebook.model.sharedModel.undo(); + notebook.deselectAll(); + void Private.handleState(notebook, state); + } + + /** + * Redo a cell action. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * This is a no-op if there are no cell actions to redo. + */ + export function redo(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.mode = 'command'; + notebook.model.sharedModel.redo(); + notebook.deselectAll(); + void Private.handleState(notebook, state); + } + + /** + * Toggle the line number of all cells. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The original state is based on the state of the active cell. + * The `mode` of the widget will be preserved. + */ + export function toggleAllLineNumbers(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + const config = notebook.editorConfig; + const lineNumbers = !( + config.code.lineNumbers && + config.markdown.lineNumbers && + config.raw.lineNumbers + ); + const newConfig = { + code: { ...config.code, lineNumbers }, + markdown: { ...config.markdown, lineNumbers }, + raw: { ...config.raw, lineNumbers } + }; + + notebook.editorConfig = newConfig; + void Private.handleState(notebook, state); + } + + /** + * Clear the code outputs of the selected cells. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The widget `mode` will be preserved. + */ + export function clearOutputs(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + let index = -1; + for (const cell of notebook.model.cells) { + const child = notebook.widgets[++index]; + + if (notebook.isSelectedOrActive(child) && cell.type === 'code') { + cell.sharedModel.transact(() => { + (cell as ICodeCellModel).clearExecution(); + (child as CodeCell).outputHidden = false; + }, false); + Private.outputCleared.emit({ notebook, cell: child }); + } + } + void Private.handleState(notebook, state, true); + } + + /** + * Clear all the code outputs on the widget. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The widget `mode` will be preserved. + */ + export function clearAllOutputs(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + let index = -1; + for (const cell of notebook.model.cells) { + const child = notebook.widgets[++index]; + + if (cell.type === 'code') { + cell.sharedModel.transact(() => { + (cell as ICodeCellModel).clearExecution(); + (child as CodeCell).outputHidden = false; + }, false); + Private.outputCleared.emit({ notebook, cell: child }); + } + } + void Private.handleState(notebook, state, true); + } + + /** + * Hide the code on selected code cells. + * + * @param notebook - The target notebook widget. + */ + export function hideCode(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.widgets.forEach(cell => { + if (notebook.isSelectedOrActive(cell) && cell.model.type === 'code') { + cell.inputHidden = true; + } + }); + void Private.handleState(notebook, state); + } + + /** + * Show the code on selected code cells. + * + * @param notebook - The target notebook widget. + */ + export function showCode(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.widgets.forEach(cell => { + if (notebook.isSelectedOrActive(cell) && cell.model.type === 'code') { + cell.inputHidden = false; + } + }); + void Private.handleState(notebook, state); + } + + /** + * Hide the code on all code cells. + * + * @param notebook - The target notebook widget. + */ + export function hideAllCode(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.widgets.forEach(cell => { + if (cell.model.type === 'code') { + cell.inputHidden = true; + } + }); + void Private.handleState(notebook, state); + } + + /** + * Show the code on all code cells. + * + * @param notebook The target notebook widget. + */ + export function showAllCode(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.widgets.forEach(cell => { + if (cell.model.type === 'code') { + cell.inputHidden = false; + } + }); + void Private.handleState(notebook, state); + } + + /** + * Hide the output on selected code cells. + * + * @param notebook - The target notebook widget. + */ + export function hideOutput(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.widgets.forEach(cell => { + if (notebook.isSelectedOrActive(cell) && cell.model.type === 'code') { + (cell as CodeCell).outputHidden = true; + } + }); + void Private.handleState(notebook, state, true); + } + + /** + * Show the output on selected code cells. + * + * @param notebook - The target notebook widget. + */ + export function showOutput(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.widgets.forEach(cell => { + if (notebook.isSelectedOrActive(cell) && cell.model.type === 'code') { + (cell as CodeCell).outputHidden = false; + } + }); + void Private.handleState(notebook, state); + } + + /** + * Toggle output visibility on selected code cells. + * If at least one output is visible, all outputs are hidden. + * If no outputs are visible, all outputs are made visible. + * + * @param notebook - The target notebook widget. + */ + export function toggleOutput(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + for (const cell of notebook.widgets) { + if (notebook.isSelectedOrActive(cell) && cell.model.type === 'code') { + if ((cell as CodeCell).outputHidden === false) { + // We found at least one visible output; hide outputs for this cell + return hideOutput(notebook); + } + } + } + + // We found no selected cells or no selected cells with visible output; + // show outputs for selected cells + return showOutput(notebook); + } + + /** + * Hide the output on all code cells. + * + * @param notebook - The target notebook widget. + */ + export function hideAllOutputs(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.widgets.forEach(cell => { + if (cell.model.type === 'code') { + (cell as CodeCell).outputHidden = true; + } + }); + void Private.handleState(notebook, state, true); + } + + /** + * Render side-by-side. + * + * @param notebook - The target notebook widget. + */ + export function renderSideBySide(notebook: Notebook): void { + notebook.renderingLayout = 'side-by-side'; + } + + /** + * Render not side-by-side. + * + * @param notebook - The target notebook widget. + */ + export function renderDefault(notebook: Notebook): void { + notebook.renderingLayout = 'default'; + } + + /** + * Show the output on all code cells. + * + * @param notebook - The target notebook widget. + */ + export function showAllOutputs(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.widgets.forEach(cell => { + if (cell.model.type === 'code') { + (cell as CodeCell).outputHidden = false; + } + }); + void Private.handleState(notebook, state); + } + + /** + * Enable output scrolling for all selected cells. + * + * @param notebook - The target notebook widget. + */ + export function enableOutputScrolling(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.widgets.forEach(cell => { + if (notebook.isSelectedOrActive(cell) && cell.model.type === 'code') { + (cell as CodeCell).outputsScrolled = true; + } + }); + void Private.handleState(notebook, state, true); + } + + /** + * Disable output scrolling for all selected cells. + * + * @param notebook - The target notebook widget. + */ + export function disableOutputScrolling(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + + notebook.widgets.forEach(cell => { + if (notebook.isSelectedOrActive(cell) && cell.model.type === 'code') { + (cell as CodeCell).outputsScrolled = false; + } + }); + void Private.handleState(notebook, state); + } + + /** + * Go to the last cell that is run or current if it is running. + * + * Note: This requires execution timing to be toggled on or this will have + * no effect. + * + * @param notebook - The target notebook widget. + */ + export function selectLastRunCell(notebook: Notebook): void { + let latestTime: Date | null = null; + let latestCellIdx: number | null = null; + notebook.widgets.forEach((cell, cellIndx) => { + if (cell.model.type === 'code') { + const execution = cell.model.getMetadata('execution'); + if ( + execution && + JSONExt.isObject(execution) && + execution['iopub.status.busy'] !== undefined + ) { + // The busy status is used as soon as a request is received: + // https://jupyter-client.readthedocs.io/en/stable/messaging.html + const timestamp = execution['iopub.status.busy']!.toString(); + if (timestamp) { + const startTime = new Date(timestamp); + if (!latestTime || startTime >= latestTime) { + latestTime = startTime; + latestCellIdx = cellIndx; + } + } + } + } + }); + if (latestCellIdx !== null) { + notebook.activeCellIndex = latestCellIdx; + } + } + + /** + * Set the markdown header level. + * + * @param notebook - The target notebook widget. + * @param level - The header level. + * @param translator - The application translator. + * + * #### Notes + * All selected cells will be switched to markdown. + * The level will be clamped between 1 and 6. + * If there is an existing header, it will be replaced. + * There will always be one blank space after the header. + * The cells will be unrendered. + */ + export function setMarkdownHeader( + notebook: Notebook, + level: number, + translator?: ITranslator + ): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = Private.getState(notebook); + const cells = notebook.model.cells; + + level = Math.min(Math.max(level, 1), 6); + notebook.widgets.forEach((child, index) => { + if (notebook.isSelectedOrActive(child)) { + Private.setMarkdownHeader(cells.get(index), level); + } + }); + Private.changeCellType(notebook, 'markdown', translator); + void Private.handleState(notebook, state); + } + + /** + * Collapse all cells in given notebook. + * + * @param notebook - The target notebook widget. + */ + export function collapseAllHeadings(notebook: Notebook): any { + const state = Private.getState(notebook); + for (const cell of notebook.widgets) { + if (NotebookActions.getHeadingInfo(cell).isHeading) { + NotebookActions.setHeadingCollapse(cell, true, notebook); + NotebookActions.setCellCollapse(cell, true); + } + } + notebook.activeCellIndex = 0; + void Private.handleState(notebook, state, true); + } + + /** + * Un-collapse all cells in given notebook. + * + * @param notebook - The target notebook widget. + */ + export function expandAllHeadings(notebook: Notebook): any { + for (const cell of notebook.widgets) { + if (NotebookActions.getHeadingInfo(cell).isHeading) { + NotebookActions.setHeadingCollapse(cell, false, notebook); + // similar to collapseAll. + NotebookActions.setCellCollapse(cell, false); + } + } + } + + function findNearestParentHeader( + cell: Cell, + notebook: Notebook + ): Cell | undefined { + const index = findIndex( + notebook.widgets, + (possibleCell: Cell, index: number) => { + return cell.model.id === possibleCell.model.id; + } + ); + if (index === -1) { + return; + } + // Finds the nearest header above the given cell. If the cell is a header itself, it does not return itself; + // this can be checked directly by calling functions. + if (index >= notebook.widgets.length) { + return; + } + let childHeaderInfo = getHeadingInfo(notebook.widgets[index]); + for (let cellN = index - 1; cellN >= 0; cellN--) { + if (cellN < notebook.widgets.length) { + let hInfo = getHeadingInfo(notebook.widgets[cellN]); + if ( + hInfo.isHeading && + hInfo.headingLevel < childHeaderInfo.headingLevel + ) { + return notebook.widgets[cellN]; + } + } + } + // else no parent header found. + return; + } + + /** + * Finds the "parent" heading of the given cell and expands. + * Used for the case that a cell becomes active that is within a collapsed heading. + * @param cell - "Child" cell that has become the active cell + * @param notebook - The target notebook widget. + */ + export function expandParent(cell: Cell, notebook: Notebook): void { + let nearestParentCell = findNearestParentHeader(cell, notebook); + if (!nearestParentCell) { + return; + } + if ( + !getHeadingInfo(nearestParentCell).collapsed && + !nearestParentCell.isHidden + ) { + return; + } + if (nearestParentCell.isHidden) { + expandParent(nearestParentCell, notebook); + } + if (getHeadingInfo(nearestParentCell).collapsed) { + setHeadingCollapse(nearestParentCell, false, notebook); + } + } + + /** + * Finds the next heading that isn't a child of the given markdown heading. + * @param cell - "Child" cell that has become the active cell + * @param notebook - The target notebook widget. + */ + export function findNextParentHeading( + cell: Cell, + notebook: Notebook + ): number { + let index = findIndex( + notebook.widgets, + (possibleCell: Cell, index: number) => { + return cell.model.id === possibleCell.model.id; + } + ); + if (index === -1) { + return -1; + } + let childHeaderInfo = getHeadingInfo(cell); + for (index = index + 1; index < notebook.widgets.length; index++) { + let hInfo = getHeadingInfo(notebook.widgets[index]); + if ( + hInfo.isHeading && + hInfo.headingLevel <= childHeaderInfo.headingLevel + ) { + return index; + } + } + // else no parent header found. return the index of the last cell + return notebook.widgets.length; + } + + /** + * Set the given cell and ** all "child" cells ** + * to the given collapse / expand if cell is + * a markdown header. + * + * @param cell - The cell + * @param collapsing - Whether to collapse or expand the cell + * @param notebook - The target notebook widget. + */ + export function setHeadingCollapse( + cell: Cell, + collapsing: boolean, + notebook: StaticNotebook + ): number { + const which = findIndex( + notebook.widgets, + (possibleCell: Cell, index: number) => { + return cell.model.id === possibleCell.model.id; + } + ); + if (which === -1) { + return -1; + } + if (!notebook.widgets.length) { + return which + 1; + } + let selectedHeadingInfo = NotebookActions.getHeadingInfo(cell); + if ( + cell.isHidden || + !(cell instanceof MarkdownCell) || + !selectedHeadingInfo.isHeading + ) { + // otherwise collapsing and uncollapsing already hidden stuff can + // cause some funny looking bugs. + return which + 1; + } + let localCollapsed = false; + let localCollapsedLevel = 0; + // iterate through all cells after the active cell. + let cellNum; + for (cellNum = which + 1; cellNum < notebook.widgets.length; cellNum++) { + let subCell = notebook.widgets[cellNum]; + let subCellHeadingInfo = NotebookActions.getHeadingInfo(subCell); + if ( + subCellHeadingInfo.isHeading && + subCellHeadingInfo.headingLevel <= selectedHeadingInfo.headingLevel + ) { + // then reached an equivalent or higher heading level than the + // original the end of the collapse. + cellNum -= 1; + break; + } + if ( + localCollapsed && + subCellHeadingInfo.isHeading && + subCellHeadingInfo.headingLevel <= localCollapsedLevel + ) { + // then reached the end of the local collapsed, so unset NotebookActions. + localCollapsed = false; + } + + if (collapsing || localCollapsed) { + // then no extra handling is needed for further locally collapsed + // headings. + subCell.setHidden(true); + continue; + } + + if (subCellHeadingInfo.collapsed && subCellHeadingInfo.isHeading) { + localCollapsed = true; + localCollapsedLevel = subCellHeadingInfo.headingLevel; + // but don't collapse the locally collapsed heading, so continue to + // expand the heading. This will get noticed in the next round. + } + subCell.setHidden(false); + } + if (cellNum === notebook.widgets.length) { + cell.numberChildNodes = cellNum - which - 1; + } else { + cell.numberChildNodes = cellNum - which; + } + NotebookActions.setCellCollapse(cell, collapsing); + return cellNum + 1; + } + + /** + * Toggles the collapse state of the active cell of the given notebook + * and ** all of its "child" cells ** if the cell is a heading. + * + * @param notebook - The target notebook widget. + */ + export function toggleCurrentHeadingCollapse(notebook: Notebook): any { + if (!notebook.activeCell || notebook.activeCellIndex === undefined) { + return; + } + let headingInfo = NotebookActions.getHeadingInfo(notebook.activeCell); + if (headingInfo.isHeading) { + // Then toggle! + NotebookActions.setHeadingCollapse( + notebook.activeCell, + !headingInfo.collapsed, + notebook + ); + } + notebook.scrollToItem(notebook.activeCellIndex).catch(reason => { + // no-op + }); + } + + /** + * If cell is a markdown heading, sets the headingCollapsed field, + * and otherwise hides the cell. + * + * @param cell - The cell to collapse / expand + * @param collapsing - Whether to collapse or expand the given cell + */ + export function setCellCollapse(cell: Cell, collapsing: boolean): any { + if (cell instanceof MarkdownCell) { + cell.headingCollapsed = collapsing; + } else { + cell.setHidden(collapsing); + } + } + + /** + * If given cell is a markdown heading, returns the heading level. + * If given cell is not markdown, returns 7 (there are only 6 levels of markdown headings) + * + * @param cell - The target cell widget. + */ + export function getHeadingInfo(cell: Cell): { + isHeading: boolean; + headingLevel: number; + collapsed?: boolean; + } { + if (!(cell instanceof MarkdownCell)) { + return { isHeading: false, headingLevel: 7 }; + } + let level = cell.headingInfo.level; + let collapsed = cell.headingCollapsed; + return { isHeading: level > 0, headingLevel: level, collapsed: collapsed }; + } + + /** + * Trust the notebook after prompting the user. + * + * @param notebook - The target notebook widget. + * @param translator - The application translator. + * + * @returns a promise that resolves when the transaction is finished. + * + * #### Notes + * No dialog will be presented if the notebook is already trusted. + */ + export function trust( + notebook: Notebook, + translator?: ITranslator + ): Promise { + translator = translator || nullTranslator; + const trans = translator.load('jupyterlab'); + + if (!notebook.model) { + return Promise.resolve(); + } + // Do nothing if already trusted. + + const trusted = every(notebook.model.cells, cell => cell.trusted); + // FIXME + const trustMessage = ( +

+ {trans.__( + 'A trusted Jupyter notebook may execute hidden malicious code when you open it.' + )} +
+ {trans.__( + 'Selecting "Trust" will re-render this notebook in a trusted state.' + )} +
+ {trans.__('For more information, see')}{' '} + + {trans.__('the Jupyter security documentation')} + + . +

+ ); + + if (trusted) { + return showDialog({ + body: trans.__('Notebook is already trusted'), + buttons: [Dialog.okButton()] + }).then(() => undefined); + } + + return showDialog({ + body: trustMessage, + title: trans.__('Trust this notebook?'), + buttons: [ + Dialog.cancelButton(), + Dialog.warnButton({ + label: trans.__('Trust'), + ariaLabel: trans.__('Confirm Trusting this notebook') + }) + ] // FIXME? + }).then(result => { + if (result.button.accept) { + if (notebook.model) { + for (const cell of notebook.model.cells) { + cell.trusted = true; + } + } + } + }); + } + + /** + * If the notebook has an active cell, focus it. + * + * @param notebook The target notebook widget + * @param options Optional options to change the behavior of this function + * @param options.waitUntilReady If true, do not call focus until activeCell.ready is resolved + * @param options.preventScroll If true, do not scroll the active cell into view + * + * @returns a promise that resolves when focus has been called on the active + * cell's node. + * + * #### Notes + * By default, waits until after the active cell has been attached unless + * called with { waitUntilReady: false } + */ + export async function focusActiveCell( + notebook: Notebook, + options: { + waitUntilReady?: boolean; + preventScroll?: boolean; + } = { waitUntilReady: true, preventScroll: false } + ): Promise { + const { activeCell } = notebook; + const { waitUntilReady, preventScroll } = options; + if (!activeCell) { + return; + } + if (waitUntilReady) { + await activeCell.ready; + } + if (notebook.isDisposed || activeCell.isDisposed) { + return; + } + activeCell.node.focus({ + preventScroll + }); + } + + /* + * Access last notebook history. + * + * @param notebook - The target notebook widget. + */ + export async function accessPreviousHistory( + notebook: Notebook + ): Promise { + if (!notebook.notebookConfig.accessKernelHistory) { + return; + } + const activeCell = notebook.activeCell; + if (activeCell) { + if (notebook.kernelHistory) { + const previousHistory = await notebook.kernelHistory.back(activeCell); + notebook.kernelHistory.updateEditor(activeCell, previousHistory); + } + } + } + + /** + * Access next notebook history. + * + * @param notebook - The target notebook widget. + */ + export async function accessNextHistory(notebook: Notebook): Promise { + if (!notebook.notebookConfig.accessKernelHistory) { + return; + } + const activeCell = notebook.activeCell; + if (activeCell) { + if (notebook.kernelHistory) { + const nextHistory = await notebook.kernelHistory.forward(activeCell); + notebook.kernelHistory.updateEditor(activeCell, nextHistory); + } + } + } +} + +/** + * Set the notebook cell executor and the related signals. + */ +export function setCellExecutor(executor: INotebookCellExecutor): void { + if (Private.executor) { + throw new Error('Cell executor can only be set once.'); + } + Private.executor = executor; +} + +/** + * A namespace for private data. + */ +namespace Private { + /** + * Notebook cell executor + */ + export let executor: INotebookCellExecutor; + + /** + * A signal that emits whenever a cell completes execution. + */ + export const executed = new Signal< + any, + { + notebook: Notebook; + cell: Cell; + success: boolean; + error?: KernelError | null; + } + >({}); + + /** + * A signal that emits whenever a cell execution is scheduled. + */ + export const executionScheduled = new Signal< + any, + { notebook: Notebook; cell: Cell } + >({}); + + /** + * A signal that emits when one notebook's cells are all executed. + */ + export const selectionExecuted = new Signal< + any, + { notebook: Notebook; lastCell: Cell } + >({}); + + /** + * A signal that emits when one notebook's cells are all executed. + */ + export const outputCleared = new Signal< + any, + { notebook: Notebook; cell: Cell } + >({}); + + /** + * The interface for a widget state. + */ + export interface IState { + /** + * Whether the widget had focus. + */ + wasFocused: boolean; + + /** + * The active cell id before the action. + * + * We cannot rely on the Cell widget or model as it may be + * discarded by action such as move. + */ + activeCellId: string | null; + } + + /** + * Get the state of a widget before running an action. + */ + export function getState(notebook: Notebook): IState { + return { + wasFocused: notebook.node.contains(document.activeElement), + activeCellId: notebook.activeCell?.model.id ?? null + }; + } + + /** + * Handle the state of a widget after running an action. + */ + export async function handleState( + notebook: Notebook, + state: IState, + scrollIfNeeded = false + ): Promise { + const { activeCell, activeCellIndex } = notebook; + if (scrollIfNeeded && activeCell) { + await notebook.scrollToItem(activeCellIndex, 'auto', 0).catch(reason => { + // no-op + }); + } + if (state.wasFocused || notebook.mode === 'edit') { + notebook.activate(); + } + } + + /** + * Handle the state of a widget after running a run action. + */ + export async function handleRunState( + notebook: Notebook, + state: IState, + alignPreference?: 'start' | 'end' | 'center' | 'top-center' + ): Promise { + const { activeCell, activeCellIndex } = notebook; + + if (activeCell) { + await notebook + .scrollToItem(activeCellIndex, 'smart', 0, alignPreference) + .catch(reason => { + // no-op + }); + } + if (state.wasFocused || notebook.mode === 'edit') { + notebook.activate(); + } + } + + /** + * Run the selected cells. + * + * @param notebook Notebook + * @param cells Cells to run + * @param sessionContext Notebook session context + * @param sessionDialogs Session dialogs + * @param translator Application translator + */ + export function runCells( + notebook: Notebook, + cells: readonly Cell[], + sessionContext?: ISessionContext, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): Promise { + const lastCell = cells[cells.length - 1]; + notebook.mode = 'command'; + + let initializingDialogShown = false; + return Promise.all( + cells.map(cell => { + if ( + cell.model.type === 'code' && + notebook.notebookConfig.enableKernelInitNotification && + sessionContext && + sessionContext.kernelDisplayStatus === 'initializing' && + !initializingDialogShown + ) { + initializingDialogShown = true; + translator = translator || nullTranslator; + const trans = translator.load('jupyterlab'); + Notification.emit( + trans.__( + `Kernel '${sessionContext.kernelDisplayName}' for '${sessionContext.path}' is still initializing. You can run code cells when the kernel has initialized.` + ), + 'warning', + { + autoClose: false + } + ); + return Promise.resolve(false); + } + if ( + cell.model.type === 'code' && + notebook.notebookConfig.enableKernelInitNotification && + initializingDialogShown + ) { + return Promise.resolve(false); + } + return runCell( + notebook, + cell, + sessionContext, + sessionDialogs, + translator + ); + }) + ) + .then(results => { + if (notebook.isDisposed) { + return false; + } + selectionExecuted.emit({ + notebook, + lastCell + }); + // Post an update request. + notebook.update(); + + return results.every(result => result); + }) + .catch(reason => { + if (reason.message.startsWith('KernelReplyNotOK')) { + cells.map(cell => { + // Remove '*' prompt from cells that didn't execute + if ( + cell.model.type === 'code' && + (cell as CodeCell).model.executionCount == null + ) { + (cell.model as ICodeCellModel).executionState = 'idle'; + } + }); + } else { + throw reason; + } + + selectionExecuted.emit({ + notebook, + lastCell + }); + + notebook.update(); + + return false; + }); + } + + /** + * Run the selected cells. + * + * @param notebook Notebook + * @param sessionContext Notebook session context + * @param sessionDialogs Session dialogs + * @param translator Application translator + */ + export function runSelected( + notebook: Notebook, + sessionContext?: ISessionContext, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): Promise { + notebook.mode = 'command'; + + let lastIndex = notebook.activeCellIndex; + const selected = notebook.widgets.filter((child, index) => { + const active = notebook.isSelectedOrActive(child); + + if (active) { + lastIndex = index; + } + + return active; + }); + + notebook.activeCellIndex = lastIndex; + notebook.deselectAll(); + + return runCells( + notebook, + selected, + sessionContext, + sessionDialogs, + translator + ); + } + + /** + * Run a cell. + */ + async function runCell( + notebook: Notebook, + cell: Cell, + sessionContext?: ISessionContext, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): Promise { + // Ensure cell and cell.model are valid + if (!cell || !cell.model) { + console.warn('Cannot execute cell: cell or cell.model is null or undefined'); + return Promise.resolve(false); + } + + // Check if the cell is a code cell and already running + if (cell.model.type === 'code') { + const metadata = cell.model.metadata as Record; + if (metadata) { + const isRunning = metadata['running'] as boolean | undefined; + if (isRunning) { + console.log(`Cell ${cell.model.id ?? 'unknown'} is already running, ignoring execution request`); + return Promise.resolve(false); + } + // Mark the cell as running + metadata['running'] = true; + } else { + console.warn(`Cell ${cell.model.id ?? 'unknown'} has no metadata, cannot set running state`); + // Proceed with execution + } + } + + try { + if (!executor) { + console.warn( + 'Requesting cell execution without any cell executor defined. Falling back to default execution.' + ); + } + const options = { + cell, + notebook: notebook.model!, + notebookConfig: notebook.notebookConfig, + onCellExecuted: args => { + executed.emit({ notebook, ...args }); + }, + onCellExecutionScheduled: args => { + executionScheduled.emit({ notebook, ...args }); + }, + sessionContext, + sessionDialogs, + translator + } satisfies INotebookCellExecutor.IRunCellOptions; + return await (executor ? executor.runCell(options) : defaultRunCell(options)); + } finally { + // Clear the running state for code cells if metadata exists + if (cell.model?.type === 'code' && cell.model.metadata) { + delete (cell.model.metadata as Record)['running']; + } + } + } + + /** + * Get the selected cell(s) without affecting the clipboard. + * + * @param notebook - The target notebook widget. + * + * @returns A list of 0 or more selected cells + */ + export function selectedCells(notebook: Notebook): nbformat.ICell[] { + return notebook.widgets + .filter(cell => notebook.isSelectedOrActive(cell)) + .map(cell => cell.model.toJSON()) + .map(cellJSON => { + if ((cellJSON.metadata as JSONObject).deletable !== undefined) { + delete (cellJSON.metadata as JSONObject).deletable; + } + return cellJSON; + }); + } + + /** + * Copy or cut the selected cell data to the application clipboard. + * + * @param notebook - The target notebook widget. + * + * @param cut - True if the cells should be cut, false if they should be copied. + */ + export function copyOrCut(notebook: Notebook, cut: boolean): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = getState(notebook); + const clipboard = Clipboard.getInstance(); + + notebook.mode = 'command'; + clipboard.clear(); + + const data = Private.selectedCells(notebook); + + clipboard.setData(JUPYTER_CELL_MIME, data); + if (cut) { + deleteCells(notebook); + } else { + notebook.deselectAll(); + } + if (cut) { + notebook.lastClipboardInteraction = 'cut'; + } else { + notebook.lastClipboardInteraction = 'copy'; + } + void handleState(notebook, state); + } + + /** + * Change the selected cell type(s). + * + * @param notebook - The target notebook widget. + * + * @param value - The target cell type. + * + * #### Notes + * It should preserve the widget mode. + * This action can be undone. + * The existing selection will be cleared. + * Any cells converted to markdown will be unrendered. + */ + export function changeCellType( + notebook: Notebook, + value: nbformat.CellType, + translator?: ITranslator + ): void { + const notebookSharedModel = notebook.model!.sharedModel; + notebook.widgets.forEach((child, index) => { + if (!notebook.isSelectedOrActive(child)) { + return; + } + if ( + child.model.type === 'code' && + (child as CodeCell).outputArea.pendingInput + ) { + translator = translator || nullTranslator; + const trans = translator.load('jupyterlab'); + // Do not permit changing cell type when input is pending + void showDialog({ + title: trans.__('Cell type not changed due to pending input'), + body: trans.__( + 'The cell type has not been changed to avoid kernel deadlock as this cell has pending input! Submit your pending input and try again.' + ), + buttons: [Dialog.okButton()] + }); + return; + } + if (child.model.getMetadata('editable') == false) { + translator = translator || nullTranslator; + const trans = translator.load('jupyterlab'); + // Do not permit changing cell type when the cell is readonly + void showDialog({ + title: trans.__('Cell is read-only'), + body: trans.__('The cell is read-only, its type cannot be changed!'), + buttons: [Dialog.okButton()] + }); + return; + } + if (child.model.type !== value) { + const raw = child.model.toJSON(); + notebookSharedModel.transact(() => { + notebookSharedModel.deleteCell(index); + if (value === 'code') { + // After change of type outputs are deleted so cell can be trusted. + raw.metadata.trusted = true; + } else { + // Otherwise clear the metadata as trusted is only "valid" on code + // cells (since other cell types cannot have outputs). + raw.metadata.trusted = undefined; + } + const newCell = notebookSharedModel.insertCell(index, { + cell_type: value, + source: raw.source, + metadata: raw.metadata + }); + if (raw.attachments && ['markdown', 'raw'].includes(value)) { + (newCell as ISharedAttachmentsCell).attachments = + raw.attachments as nbformat.IAttachments; + } + }); + } + if (value === 'markdown') { + // Fetch the new widget and unrender it. + child = notebook.widgets[index]; + (child as MarkdownCell).rendered = false; + } + }); + notebook.deselectAll(); + } + + /** + * Delete the selected cells. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The cell after the last selected cell will be activated. + * If the last cell is deleted, then the previous one will be activated. + * It will add a code cell if all cells are deleted. + * This action can be undone. + */ + export function deleteCells(notebook: Notebook): void { + const model = notebook.model!; + const sharedModel = model.sharedModel; + const toDelete: number[] = []; + + notebook.mode = 'command'; + + // Find the cells to delete. + notebook.widgets.forEach((child, index) => { + const deletable = child.model.getMetadata('deletable') !== false; + + if (notebook.isSelectedOrActive(child) && deletable) { + toDelete.push(index); + notebook.model?.deletedCells.push(child.model.id); + } + }); + + // If cells are not deletable, we may not have anything to delete. + if (toDelete.length > 0) { + // Delete the cells as one undo event. + sharedModel.transact(() => { + // Delete cells in reverse order to maintain the correct indices. + toDelete.reverse().forEach(index => { + sharedModel.deleteCell(index); + }); + + // Add a new cell if the notebook is empty. This is done + // within the compound operation to make the deletion of + // a notebook's last cell undoable. + if (sharedModel.cells.length == toDelete.length) { + sharedModel.insertCell(0, { + cell_type: notebook.notebookConfig.defaultCell, + metadata: + notebook.notebookConfig.defaultCell === 'code' + ? { + // This is an empty cell created in empty notebook, thus is trusted + trusted: true + } + : {} + }); + } + }); + // Select the *first* interior cell not deleted or the cell + // *after* the last selected cell. + // Note: The activeCellIndex is clamped to the available cells, + // so if the last cell is deleted the previous cell will be activated. + // The *first* index is the index of the last cell in the initial + // toDelete list due to the `reverse` operation above. + notebook.activeCellIndex = toDelete[0] - toDelete.length + 1; + } + + // Deselect any remaining, undeletable cells. Do this even if we don't + // delete anything so that users are aware *something* happened. + notebook.deselectAll(); + } + + /** + * Set the markdown header level of a cell. + */ + export function setMarkdownHeader(cell: ICellModel, level: number): void { + // Remove existing header or leading white space. + let source = cell.sharedModel.getSource(); + const regex = /^(#+\s*)|^(\s*)/; + const newHeader = Array(level + 1).join('#') + ' '; + const matches = regex.exec(source); + + if (matches) { + source = source.slice(matches[0].length); + } + cell.sharedModel.setSource(newHeader + source); + } + + /** Functionality related to collapsible headings */ + export namespace Headings { + /** Find the heading that is parent to cell. + * + * @param childCell - The cell that is child to the sought heading + * @param notebook - The target notebook widget + * @param includeChildCell [default=false] - if set to true and childCell is a heading itself, the childCell will be returned + * @param returnIndex [default=false] - if set to true, the cell index is returned rather than the cell object. + * + * @returns the (index | Cell object) of the parent heading or (-1 | null) if there is no parent heading. + */ + export function findParentHeading( + childCell: Cell, + notebook: Notebook, + includeChildCell = false, + returnIndex = false + ): number | Cell | null { + let cellIdx = + notebook.widgets.indexOf(childCell) - (includeChildCell ? 1 : 0); + while (cellIdx >= 0) { + let headingInfo = NotebookActions.getHeadingInfo( + notebook.widgets[cellIdx] + ); + if (headingInfo.isHeading) { + return returnIndex ? cellIdx : notebook.widgets[cellIdx]; + } + cellIdx--; + } + return returnIndex ? -1 : null; + } + + /** Find heading above with leq level than baseCell heading level. + * + * @param baseCell - cell relative to which so search + * @param notebook - target notebook widget + * @param returnIndex [default=false] - if set to true, the cell index is returned rather than the cell object. + * + * @returns the (index | Cell object) of the found heading or (-1 | null) if no heading found. + */ + export function findLowerEqualLevelParentHeadingAbove( + baseCell: Cell, + notebook: Notebook, + returnIndex = false + ): number | Cell | null { + let baseHeadingLevel = Private.Headings.determineHeadingLevel( + baseCell, + notebook + ); + if (baseHeadingLevel == -1) { + baseHeadingLevel = 1; // if no heading level can be determined, assume we're on level 1 + } + + // find the heading above with heading level <= baseHeadingLevel and return its index + let cellIdx = notebook.widgets.indexOf(baseCell) - 1; + while (cellIdx >= 0) { + let cell = notebook.widgets[cellIdx]; + let headingInfo = NotebookActions.getHeadingInfo(cell); + if ( + headingInfo.isHeading && + headingInfo.headingLevel <= baseHeadingLevel + ) { + return returnIndex ? cellIdx : cell; + } + cellIdx--; + } + return returnIndex ? -1 : null; // no heading found + } + + /** Find next heading with equal or lower level. + * + * @param baseCell - cell relative to which so search + * @param notebook - target notebook widget + * @param returnIndex [default=false] - if set to true, the cell index is returned rather than the cell object. + * + * @returns the (index | Cell object) of the found heading or (-1 | null) if no heading found. + */ + export function findLowerEqualLevelHeadingBelow( + baseCell: Cell, + notebook: Notebook, + returnIndex = false + ): number | Cell | null { + let baseHeadingLevel = Private.Headings.determineHeadingLevel( + baseCell, + notebook + ); + if (baseHeadingLevel == -1) { + baseHeadingLevel = 1; // if no heading level can be determined, assume we're on level 1 + } + let cellIdx = notebook.widgets.indexOf(baseCell) + 1; + while (cellIdx < notebook.widgets.length) { + let cell = notebook.widgets[cellIdx]; + let headingInfo = NotebookActions.getHeadingInfo(cell); + if ( + headingInfo.isHeading && + headingInfo.headingLevel <= baseHeadingLevel + ) { + return returnIndex ? cellIdx : cell; + } + cellIdx++; + } + return returnIndex ? -1 : null; + } + + /** Find next heading. + * + * @param baseCell - cell relative to which so search + * @param notebook - target notebook widget + * @param returnIndex [default=false] - if set to true, the cell index is returned rather than the cell object. + * + * @returns the (index | Cell object) of the found heading or (-1 | null) if no heading found. + */ + export function findHeadingBelow( + baseCell: Cell, + notebook: Notebook, + returnIndex = false + ): number | Cell | null { + let cellIdx = notebook.widgets.indexOf(baseCell) + 1; + while (cellIdx < notebook.widgets.length) { + let cell = notebook.widgets[cellIdx]; + let headingInfo = NotebookActions.getHeadingInfo(cell); + if (headingInfo.isHeading) { + return returnIndex ? cellIdx : cell; + } + cellIdx++; + } + return returnIndex ? -1 : null; + } + + /** Determine the heading level of a cell. + * + * @param baseCell - The cell of which the heading level shall be determined + * @param notebook - The target notebook widget + * + * @returns the heading level or -1 if there is no parent heading + * + * #### Notes + * If the baseCell is a heading itself, the heading level of baseCell is returned. + * If the baseCell is not a heading itself, the level of the parent heading is returned. + * If there is no parent heading, -1 is returned. + */ + export function determineHeadingLevel( + baseCell: Cell, + notebook: Notebook + ): number { + let headingInfoBaseCell = NotebookActions.getHeadingInfo(baseCell); + // fill baseHeadingLevel or return null if there is no heading at or above baseCell + if (headingInfoBaseCell.isHeading) { + return headingInfoBaseCell.headingLevel; + } else { + let parentHeading = findParentHeading( + baseCell, + notebook, + true + ) as Cell | null; + if (parentHeading == null) { + return -1; + } + return NotebookActions.getHeadingInfo(parentHeading).headingLevel; + } + } + + /** Insert a new heading cell at given position. + * + * @param cellIndex - where to insert + * @param headingLevel - level of the new heading + * @param notebook - target notebook + * + * #### Notes + * Enters edit mode after insert. + */ + export async function insertHeadingAboveCellIndex( + cellIndex: number, + headingLevel: number, + notebook: Notebook + ): Promise { + headingLevel = Math.min(Math.max(headingLevel, 1), 6); + const state = Private.getState(notebook); + const model = notebook.model!; + const sharedModel = model!.sharedModel; + sharedModel.insertCell(cellIndex, { + cell_type: 'markdown', + source: '#'.repeat(headingLevel) + ' ' + }); + notebook.activeCellIndex = cellIndex; + if (notebook.activeCell?.inViewport === false) { + await signalToPromise(notebook.activeCell.inViewportChanged, 200).catch( + () => { + // no-op + } + ); + } + notebook.deselectAll(); + + void Private.handleState(notebook, state, true); + notebook.mode = 'edit'; + notebook.widgets[cellIndex].setHidden(false); + } + } +} diff --git a/.yalc/@jupyterlab/notebook/src/cellexecutor.ts b/.yalc/@jupyterlab/notebook/src/cellexecutor.ts new file mode 100644 index 0000000000..c97aa3d4f4 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/cellexecutor.ts @@ -0,0 +1,199 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Dialog, showDialog } from '@jupyterlab/apputils'; +import { + type Cell, + CodeCell, + type ICodeCellModel, + type MarkdownCell +} from '@jupyterlab/cells'; +import type { KernelMessage } from '@jupyterlab/services'; +import { nullTranslator } from '@jupyterlab/translation'; +import { findIndex } from '@lumino/algorithm'; +import { KernelError } from './actions'; +import type { INotebookModel } from './model'; +import type { INotebookCellExecutor } from './tokens'; + +/** + * Run a single notebook cell. + * + * @param options Cell execution options + * @returns Execution status + */ +export async function runCell({ + cell, + notebook, + notebookConfig, + onCellExecuted, + onCellExecutionScheduled, + sessionContext, + sessionDialogs, + translator +}: INotebookCellExecutor.IRunCellOptions): Promise { + translator = translator ?? nullTranslator; + const trans = translator.load('jupyterlab'); + switch (cell.model.type) { + case 'markdown': + (cell as MarkdownCell).rendered = true; + cell.inputHidden = false; + onCellExecuted({ cell, success: true }); + break; + case 'code': + if (sessionContext) { + if (sessionContext.isTerminating) { + await showDialog({ + title: trans.__('Kernel Terminating'), + body: trans.__( + 'The kernel for %1 appears to be terminating. You can not run any cell for now.', + sessionContext.session?.path + ), + buttons: [Dialog.okButton()] + }); + break; + } + if (sessionContext.pendingInput) { + await showDialog({ + title: trans.__('Cell not executed due to pending input'), + body: trans.__( + 'The cell has not been executed to avoid kernel deadlock as there is another pending input! Type your input in the input box, press Enter and try again.' + ), + buttons: [Dialog.okButton()] + }); + return false; + } + if (sessionContext.hasNoKernel) { + const shouldSelect = await sessionContext.startKernel(); + if (shouldSelect && sessionDialogs) { + await sessionDialogs.selectKernel(sessionContext); + } + } + + if (sessionContext.hasNoKernel) { + cell.model.sharedModel.transact(() => { + (cell.model as ICodeCellModel).clearExecution(); + }); + return true; + } + + const deletedCells = notebook.deletedCells; + onCellExecutionScheduled({ cell }); + + let ran = false; + try { + const reply = await CodeCell.execute( + cell as CodeCell, + sessionContext, + { + deletedCells, + recordTiming: notebookConfig.recordTiming + } + ); + deletedCells.splice(0, deletedCells.length); + + ran = (() => { + if (cell.isDisposed) { + return false; + } + + if (!reply) { + return true; + } + if (reply.content.status === 'ok') { + const content = reply.content; + + if (content.payload && content.payload.length) { + handlePayload(content, notebook, cell); + } + + return true; + } else { + throw new KernelError(reply.content); + } + })(); + } catch (reason) { + if (cell.isDisposed || reason.message.startsWith('Canceled')) { + ran = false; + } else { + onCellExecuted({ + cell, + success: false, + error: reason + }); + throw reason; + } + } + + if (ran) { + onCellExecuted({ cell, success: true }); + } + + return ran; + } + cell.model.sharedModel.transact(() => { + (cell.model as ICodeCellModel).clearExecution(); + }, false); + break; + default: + break; + } + + return Promise.resolve(true); +} + +/** + * Handle payloads from an execute reply. + * + * #### Notes + * Payloads are deprecated and there are no official interfaces for them in + * the kernel type definitions. + * See [Payloads (DEPRECATED)](https://jupyter-client.readthedocs.io/en/latest/messaging.html#payloads-deprecated). + */ +function handlePayload( + content: KernelMessage.IExecuteReply, + notebook: INotebookModel, + cell: Cell +) { + const setNextInput = content.payload?.filter(i => { + return (i as any).source === 'set_next_input'; + })[0]; + + if (!setNextInput) { + return; + } + + const text = setNextInput.text as string; + const replace = setNextInput.replace; + + if (replace) { + cell.model.sharedModel.setSource(text); + return; + } + + // Create a new code cell and add as the next cell. + const notebookModel = notebook.sharedModel; + const cells = notebook.cells; + const index = findIndex(cells, model => model === cell.model); + + // While this cell has no outputs and could be trusted following the letter + // of Jupyter trust model, its content comes from kernel and hence is not + // necessarily controlled by the user; if we set it as trusted, a user + // executing cells in succession could end up with unwanted trusted output. + if (index === -1) { + notebookModel.insertCell(notebookModel.cells.length, { + cell_type: 'code', + source: text, + metadata: { + trusted: false + } + }); + } else { + notebookModel.insertCell(index + 1, { + cell_type: 'code', + source: text, + metadata: { + trusted: false + } + }); + } +} diff --git a/.yalc/@jupyterlab/notebook/src/celllist.ts b/.yalc/@jupyterlab/notebook/src/celllist.ts new file mode 100644 index 0000000000..79af34aa39 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/celllist.ts @@ -0,0 +1,167 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + CellModel, + CodeCellModel, + ICellModel, + MarkdownCellModel, + RawCellModel +} from '@jupyterlab/cells'; +import { IObservableList } from '@jupyterlab/observables'; +import { + ISharedCell, + ISharedCodeCell, + ISharedMarkdownCell, + ISharedNotebook, + ISharedRawCell, + NotebookChange +} from '@jupyter/ydoc'; +import { ISignal, Signal } from '@lumino/signaling'; + +/** + * A cell list object that supports undo/redo. + */ +export class CellList { + /** + * Construct the cell list. + */ + constructor(protected model: ISharedNotebook) { + this._insertCells(0, this.model.cells); + + this.model.changed.connect(this._onSharedModelChanged, this); + } + + /** + * A signal emitted when the cell list has changed. + */ + get changed(): ISignal> { + return this._changed; + } + + /** + * Test whether the cell list has been disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Get the length of the cell list. + * + * @returns The number of cells in the cell list. + */ + get length(): number { + return this.model.cells.length; + } + + /** + * Create an iterator over the cells in the cell list. + * + * @returns A new iterator starting at the front of the cell list. + */ + *[Symbol.iterator](): IterableIterator { + for (const cell of this.model.cells) { + yield this._cellMap.get(cell)!; + } + } + + /** + * Dispose of the resources held by the cell list. + */ + dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + + // Clean up the cell map and cell order objects. + for (const cell of this.model.cells) { + this._cellMap.get(cell)?.dispose(); + } + Signal.clearData(this); + } + + /** + * Get the cell at the specified index. + * + * @param index - The positive integer index of interest. + * + * @returns The cell at the specified index. + */ + get(index: number): ICellModel { + return this._cellMap.get(this.model.cells[index])!; + } + + private _insertCells(index: number, cells: Array) { + cells.forEach(sharedModel => { + let cellModel: CellModel; + switch (sharedModel.cell_type) { + case 'code': { + cellModel = new CodeCellModel({ + sharedModel: sharedModel as ISharedCodeCell + }); + break; + } + case 'markdown': { + cellModel = new MarkdownCellModel({ + sharedModel: sharedModel as ISharedMarkdownCell + }); + break; + } + default: { + cellModel = new RawCellModel({ + sharedModel: sharedModel as ISharedRawCell + }); + } + } + + this._cellMap.set(sharedModel, cellModel); + sharedModel.disposed.connect(() => { + cellModel.dispose(); + this._cellMap.delete(sharedModel); + }); + }); + + return this.length; + } + + private _onSharedModelChanged(self: ISharedNotebook, change: NotebookChange) { + let currpos = 0; + // We differ emitting the list changes to ensure cell model for all current shared cell have been created. + const events = new Array>(); + change.cellsChange?.forEach(delta => { + if (delta.insert != null) { + this._insertCells(currpos, delta.insert); + events.push({ + type: 'add', + newIndex: currpos, + newValues: delta.insert.map(c => this._cellMap.get(c)!), + oldIndex: -2, + oldValues: [] + }); + + currpos += delta.insert.length; + } else if (delta.delete != null) { + events.push({ + type: 'remove', + newIndex: -1, + newValues: [], + oldIndex: currpos, + // Cells have been disposed, so we don't know which one are gone. + oldValues: new Array(delta.delete).fill(undefined) + }); + } else if (delta.retain != null) { + currpos += delta.retain; + } + }); + + events.forEach(msg => this._changed.emit(msg)); + } + + private _cellMap = new WeakMap(); + private _changed = new Signal>( + this + ); + private _isDisposed = false; +} diff --git a/.yalc/@jupyterlab/notebook/src/constants.ts b/.yalc/@jupyterlab/notebook/src/constants.ts new file mode 100644 index 0000000000..0ffb7d7860 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +/** + * The class name added to a drop target. + */ +export const DROP_TARGET_CLASS = 'jp-mod-dropTarget'; + +/** + * The class name added to a drop source. + */ +export const DROP_SOURCE_CLASS = 'jp-mod-dropSource'; diff --git a/.yalc/@jupyterlab/notebook/src/default-toolbar.tsx b/.yalc/@jupyterlab/notebook/src/default-toolbar.tsx new file mode 100644 index 0000000000..6214edb5ca --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/default-toolbar.tsx @@ -0,0 +1,387 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + Toolbar as AppToolbar, + Dialog, + ISessionContext, + ISessionContextDialogs, + SessionContextDialogs, + showDialog +} from '@jupyterlab/apputils'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import * as nbformat from '@jupyterlab/nbformat'; +import { + ITranslator, + nullTranslator, + TranslationBundle +} from '@jupyterlab/translation'; +import { + addIcon, + addToolbarButtonClass, + copyIcon, + cutIcon, + fastForwardIcon, + HTMLSelect, + pasteIcon, + ReactWidget, + runIcon, + saveIcon, + Toolbar, + ToolbarButton, + ToolbarButtonComponent, + UseSignal +} from '@jupyterlab/ui-components'; +import * as React from 'react'; +import { NotebookActions } from './actions'; +import { NotebookPanel } from './panel'; +import { Notebook } from './widget'; + +/** + * The class name added to toolbar cell type dropdown wrapper. + */ +const TOOLBAR_CELLTYPE_CLASS = 'jp-Notebook-toolbarCellType'; + +/** + * The class name added to toolbar cell type dropdown. + */ +const TOOLBAR_CELLTYPE_DROPDOWN_CLASS = 'jp-Notebook-toolbarCellTypeDropdown'; + +/** + * A namespace for the default toolbar items. + */ +export namespace ToolbarItems { + /** + * Create save button toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. + */ + export function createSaveButton( + panel: NotebookPanel, + translator?: ITranslator + ): ReactWidget { + const trans = (translator || nullTranslator).load('jupyterlab'); + function onClick() { + if (panel.context.model.readOnly) { + return showDialog({ + title: trans.__('Cannot Save'), + body: trans.__('Document is read-only'), + buttons: [Dialog.okButton()] + }); + } + void panel.context.save().then(() => { + if (!panel.isDisposed) { + return panel.context.createCheckpoint(); + } + }); + } + + return addToolbarButtonClass( + ReactWidget.create( + + {() => ( + + )} + + ) + ); + } + + /** + * Create an insert toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. + */ + export function createInsertButton( + panel: NotebookPanel, + translator?: ITranslator + ): ReactWidget { + const trans = (translator || nullTranslator).load('jupyterlab'); + return new ToolbarButton({ + icon: addIcon, + onClick: () => { + NotebookActions.insertBelow(panel.content); + }, + tooltip: trans.__('Insert a cell below') + }); + } + + /** + * Create a cut toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. + */ + export function createCutButton( + panel: NotebookPanel, + translator?: ITranslator + ): ReactWidget { + const trans = (translator || nullTranslator).load('jupyterlab'); + return new ToolbarButton({ + icon: cutIcon, + onClick: () => { + NotebookActions.cut(panel.content); + }, + tooltip: trans.__('Cut the selected cells') + }); + } + + /** + * Create a copy toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. + */ + export function createCopyButton( + panel: NotebookPanel, + translator?: ITranslator + ): ReactWidget { + const trans = (translator || nullTranslator).load('jupyterlab'); + return new ToolbarButton({ + icon: copyIcon, + onClick: () => { + NotebookActions.copy(panel.content); + }, + tooltip: trans.__('Copy the selected cells') + }); + } + + /** + * Create a paste toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. + */ + export function createPasteButton( + panel: NotebookPanel, + translator?: ITranslator + ): ReactWidget { + const trans = (translator || nullTranslator).load('jupyterlab'); + return new ToolbarButton({ + icon: pasteIcon, + onClick: () => { + NotebookActions.paste(panel.content); + }, + tooltip: trans.__('Paste cells from the clipboard') + }); + } + + /** + * Create a run toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. + */ + export function createRunButton( + panel: NotebookPanel, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): ReactWidget { + const trans = (translator ?? nullTranslator).load('jupyterlab'); + return new ToolbarButton({ + icon: runIcon, + onClick: () => { + void NotebookActions.runAndAdvance( + panel.content, + panel.sessionContext, + sessionDialogs, + translator + ); + }, + tooltip: trans.__('Run the selected cells and advance') + }); + } + /** + * Create a restart run all toolbar item + * + * @deprecated since v3.2 + * This is dead code now. + */ + export function createRestartRunAllButton( + panel: NotebookPanel, + dialogs?: ISessionContext.IDialogs, + translator?: ITranslator + ): ReactWidget { + const trans = (translator ?? nullTranslator).load('jupyterlab'); + return new ToolbarButton({ + icon: fastForwardIcon, + onClick: () => { + const dialogs_ = dialogs ?? new SessionContextDialogs({ translator }); + void dialogs_.restart(panel.sessionContext).then(restarted => { + if (restarted) { + void NotebookActions.runAll( + panel.content, + panel.sessionContext, + dialogs_, + translator + ); + } + return restarted; + }); + }, + tooltip: trans.__('Restart the kernel, then re-run the whole notebook') + }); + } + + /** + * Create a cell type switcher item. + * + * #### Notes + * It will display the type of the current active cell. + * If more than one cell is selected but are of different types, + * it will display `'-'`. + * When the user changes the cell type, it will change the + * cell types of the selected cells. + * It can handle a change to the context. + */ + export function createCellTypeItem( + panel: NotebookPanel, + translator?: ITranslator + ): ReactWidget { + return new CellTypeSwitcher(panel.content, translator); + } + + /** + * Get the default toolbar items for panel + * + * @deprecated since v4 + */ + export function getDefaultItems( + panel: NotebookPanel, + sessionDialogs?: ISessionContextDialogs, + translator?: ITranslator + ): DocumentRegistry.IToolbarItem[] { + return [ + { name: 'save', widget: createSaveButton(panel, translator) }, + { name: 'insert', widget: createInsertButton(panel, translator) }, + { name: 'cut', widget: createCutButton(panel, translator) }, + { name: 'copy', widget: createCopyButton(panel, translator) }, + { name: 'paste', widget: createPasteButton(panel, translator) }, + { + name: 'run', + widget: createRunButton(panel, sessionDialogs, translator) + }, + { + name: 'interrupt', + widget: AppToolbar.createInterruptButton( + panel.sessionContext, + translator + ) + }, + { + name: 'restart', + widget: AppToolbar.createRestartButton( + panel.sessionContext, + sessionDialogs, + translator + ) + }, + { + name: 'restart-and-run', + widget: createRestartRunAllButton(panel, sessionDialogs, translator) + }, + { name: 'cellType', widget: createCellTypeItem(panel, translator) }, + { name: 'spacer', widget: Toolbar.createSpacerItem() }, + { + name: 'kernelName', + widget: AppToolbar.createKernelNameItem( + panel.sessionContext, + sessionDialogs, + translator + ) + } + ]; + } +} + +/** + * A toolbar widget that switches cell types. + */ +export class CellTypeSwitcher extends ReactWidget { + /** + * Construct a new cell type switcher. + */ + constructor(widget: Notebook, translator?: ITranslator) { + super(); + this._trans = (translator || nullTranslator).load('jupyterlab'); + this.addClass(TOOLBAR_CELLTYPE_CLASS); + this._notebook = widget; + if (widget.model) { + this.update(); + } + widget.activeCellChanged.connect(this.update, this); + // Follow a change in the selection. + widget.selectionChanged.connect(this.update, this); + } + + /** + * Handle `change` events for the HTMLSelect component. + */ + handleChange = (event: React.ChangeEvent): void => { + if (event.target.value !== '-') { + NotebookActions.changeCellType( + this._notebook, + event.target.value as nbformat.CellType + ); + this._notebook.activate(); + } + }; + + /** + * Handle `keydown` events for the HTMLSelect component. + */ + handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.keyCode === 13) { + this._notebook.activate(); + } + }; + + render(): JSX.Element { + let value = '-'; + if (this._notebook.activeCell) { + value = this._notebook.activeCell.model.type; + } + for (const widget of this._notebook.widgets) { + if (this._notebook.isSelectedOrActive(widget)) { + if (widget.model.type !== value) { + value = '-'; + break; + } + } + } + return ( + + + + + + + ); + } + + private _trans: TranslationBundle; + private _notebook: Notebook; +} diff --git a/.yalc/@jupyterlab/notebook/src/default.json b/.yalc/@jupyterlab/notebook/src/default.json new file mode 100644 index 0000000000..0e693bb53d --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/default.json @@ -0,0 +1,160 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": ["hello world\n", "0\n", "1\n", "2\n"] + }, + { + "name": "stderr", + "output_type": "stream", + "text": ["output to stderr\n"] + }, + { + "name": "stdout", + "output_type": "stream", + "text": ["some more stdout text\n"] + } + ], + "source": [ + "import sys\n", + "sys.stdout.write('hello world\\n')\n", + "sys.stdout.flush()\n", + "for i in range(3):\n", + " sys.stdout.write('%s\\n' % i)\n", + " sys.stdout.flush()\n", + "sys.stderr.write('output to stderr\\n')\n", + "sys.stderr.flush()\n", + "sys.stdout.write('some more stdout text\\n')\n", + "sys.stdout.flush()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "# Markdown Cell\n", + "\n", + "$ e^{ \\pm i\\theta } = \\cos \\theta \\pm i\\sin \\theta + \\beta $\n", + "\n", + "*It* **really** is!" + ] + }, + { + "cell_type": "raw", + "metadata": { + "tags": [] + }, + "source": ["Raw Cell\n", "\n", "Second line"] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "ename": "SyntaxError", + "evalue": "invalid syntax (, line 1)", + "output_type": "error", + "traceback": [ + "\u001b[0;36m File \u001b[0;32m\"\"\u001b[0;36m, line \u001b[0;32m1\u001b[0m\n\u001b[0;31m this is a syntax error\u001b[0m\n\u001b[0m ^\u001b[0m\n\u001b[0;31mSyntaxError\u001b[0m\u001b[0;31m:\u001b[0m invalid syntax\n" + ] + } + ], + "source": ["this is a syntax error"] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": ["print('test')"] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/latex": [ + "The mass-energy equivalence is described by the famous equation\n", + " \n", + "$$E=mc^2$$\n", + " \n", + "discovered in 1905 by Albert Einstein. \n", + "In natural units ($c$ = 1), the formula expresses the identity\n", + " \n", + "\\begin{equation}\n", + "E=m\n", + "\\end{equation}" + ], + "text/plain": [""] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from IPython.display import Latex\n", + "Latex('''The mass-energy equivalence is described by the famous equation\n", + " \n", + "$$E=mc^2$$\n", + " \n", + "discovered in 1905 by Albert Einstein. \n", + "In natural units ($c$ = 1), the formula expresses the identity\n", + " \n", + "\\\\begin{equation}\n", + "E=m\n", + "\\\\end{equation}''')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "anaconda-cloud": {}, + "kernelspec": { + "display_name": "Python [default]", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.5.2" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/.yalc/@jupyterlab/notebook/src/executionindicator.tsx b/.yalc/@jupyterlab/notebook/src/executionindicator.tsx new file mode 100644 index 0000000000..ed8d0c42c4 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/executionindicator.tsx @@ -0,0 +1,670 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { ISessionContext, translateKernelStatuses } from '@jupyterlab/apputils'; + +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import React from 'react'; +import { ProgressCircle } from '@jupyterlab/statusbar'; + +import { + circleIcon, + LabIcon, + offlineBoltIcon, + VDomModel, + VDomRenderer +} from '@jupyterlab/ui-components'; + +import { Notebook } from './widget'; +import { KernelMessage } from '@jupyterlab/services'; +import { Kernel } from '@jupyterlab/services'; +import { NotebookPanel } from './panel'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { Widget } from '@lumino/widgets'; +import { JSONObject } from '@lumino/coreutils'; +import { IChangedArgs } from '@jupyterlab/coreutils'; + +/** + * A react functional component for rendering execution indicator. + */ +export function ExecutionIndicatorComponent( + props: ExecutionIndicatorComponent.IProps +): JSX.Element { + const translator = props.translator || nullTranslator; + const kernelStatuses = translateKernelStatuses(translator); + const trans = translator.load('jupyterlab'); + + const state = props.state; + const showOnToolBar = props.displayOption.showOnToolBar; + const showProgress = props.displayOption.showProgress; + const tooltipClass = showOnToolBar ? 'down' : 'up'; + const emptyDiv =
; + + if (!state) { + return emptyDiv; + } + + const kernelStatus = state.kernelStatus; + const circleIconProps: LabIcon.IProps = { + alignSelf: 'normal', + height: '24px' + }; + const time = state.totalTime; + + const scheduledCellNumber = state.scheduledCellNumber || 0; + const remainingCellNumber = state.scheduledCell.size || 0; + const executedCellNumber = scheduledCellNumber - remainingCellNumber; + let percentage = (100 * executedCellNumber) / scheduledCellNumber; + let displayClass = showProgress ? '' : 'hidden'; + if (!showProgress && percentage < 100) { + percentage = 0; + } + + const progressBar = (percentage: number) => ( + + ); + const titleFactory = (translatedStatus: string) => + trans.__('Kernel status: %1', translatedStatus); + + const reactElement = ( + status: ISessionContext.KernelDisplayStatus, + circle: JSX.Element, + popup: JSX.Element[] + ): JSX.Element => ( +
+ {circle} +
+ {titleFactory(kernelStatuses[status])} + {popup} +
+
+ ); + + if ( + state.kernelStatus === 'connecting' || + state.kernelStatus === 'disconnected' || + state.kernelStatus === 'unknown' + ) { + return reactElement( + kernelStatus, + , + [] + ); + } + if ( + state.kernelStatus === 'starting' || + state.kernelStatus === 'terminating' || + state.kernelStatus === 'restarting' || + state.kernelStatus === 'initializing' + ) { + return reactElement( + kernelStatus, + , + [] + ); + } + + if (state.executionStatus === 'busy') { + return reactElement('busy', progressBar(percentage), [ + + {trans.__( + `Executed ${executedCellNumber}/${scheduledCellNumber} cells` + )} + , + + {trans._n('Elapsed time: %1 second', 'Elapsed time: %1 seconds', time)} + + ]); + } else { + // No cell is scheduled, fall back to the status of kernel + const progress = state.kernelStatus === 'busy' ? 0 : 100; + const popup = + state.kernelStatus === 'busy' || time === 0 + ? [] + : [ + + {trans._n( + 'Executed %1 cell', + 'Executed %1 cells', + scheduledCellNumber + )} + , + + {trans._n( + 'Elapsed time: %1 second', + 'Elapsed time: %1 seconds', + time + )} + + ]; + + return reactElement(state.kernelStatus, progressBar(progress), popup); + } +} + +/** + * A namespace for ExecutionIndicatorComponent statics. + */ +namespace ExecutionIndicatorComponent { + /** + * Props for the execution status component. + */ + export interface IProps { + /** + * Display option for progress bar and elapsed time. + */ + displayOption: Private.DisplayOption; + + /** + * Execution state of selected notebook. + */ + state?: ExecutionIndicator.IExecutionState; + + /** + * The application language translator. + */ + translator?: ITranslator; + } +} + +/** + * A VDomRenderer widget for displaying the execution status. + */ +export class ExecutionIndicator extends VDomRenderer { + /** + * Construct the kernel status widget. + */ + constructor(translator?: ITranslator, showProgress: boolean = true) { + super(new ExecutionIndicator.Model()); + this.translator = translator || nullTranslator; + this.addClass('jp-mod-highlighted'); + } + + /** + * Render the execution status item. + */ + render(): JSX.Element | null { + if (this.model === null || !this.model.renderFlag) { + return
; + } else { + const nb = this.model.currentNotebook; + + if (!nb) { + return ( + + ); + } + + return ( + + ); + } + } + + private translator: ITranslator; +} + +/** + * A namespace for ExecutionIndicator statics. + */ +export namespace ExecutionIndicator { + /** + * Execution state of a notebook. + */ + export interface IExecutionState { + /** + * Execution status of kernel, this status is deducted from the + * number of scheduled code cells. + */ + executionStatus: string; + + /** + * Current status of kernel. + */ + kernelStatus: ISessionContext.KernelDisplayStatus; + + /** + * Total execution time. + */ + totalTime: number; + + /** + * Id of `setInterval`, it is used to start / stop the elapsed time + * counter. + */ + interval: number; + + /** + * Id of `setTimeout`, it is used to create / clear the state + * resetting request. + */ + timeout: number; + + /** + * Set of messages scheduled for executing, `executionStatus` is set + * to `idle if the length of this set is 0 and to `busy` otherwise. + */ + scheduledCell: Set; + + /** + * Total number of cells requested for executing, it is used to compute + * the execution progress in progress bar. + */ + scheduledCellNumber: number; + + /** + * Flag to reset the execution state when a code cell is scheduled for + * executing. + */ + needReset: boolean; + } + + /** + * A VDomModel for the execution status indicator. + */ + export class Model extends VDomModel { + constructor() { + super(); + this._displayOption = { showOnToolBar: true, showProgress: true }; + this._renderFlag = true; + } + + /** + * Attach a notebook with session context to model in order to keep + * track of multiple notebooks. If a session context is already + * attached, only set current activated notebook to input. + * + * @param data - The notebook and session context to be attached to model + */ + attachNotebook( + data: { content?: Notebook; context?: ISessionContext } | null + ): void { + if (data && data.content && data.context) { + const nb = data.content; + const context = data.context; + this._currentNotebook = nb; + if (!this._notebookExecutionProgress.has(nb)) { + this._notebookExecutionProgress.set(nb, { + executionStatus: 'idle', + kernelStatus: 'idle', + totalTime: 0, + interval: 0, + timeout: 0, + scheduledCell: new Set(), + scheduledCellNumber: 0, + needReset: true + }); + + const state = this._notebookExecutionProgress.get(nb); + const contextStatusChanged = (ctx: ISessionContext) => { + if (state) { + state.kernelStatus = ctx.kernelDisplayStatus; + } + this.stateChanged.emit(void 0); + }; + context.statusChanged.connect(contextStatusChanged, this); + + const contextConnectionStatusChanged = (ctx: ISessionContext) => { + if (state) { + state.kernelStatus = ctx.kernelDisplayStatus; + } + this.stateChanged.emit(void 0); + }; + context.connectionStatusChanged.connect( + contextConnectionStatusChanged, + this + ); + + context.disposed.connect(ctx => { + ctx.connectionStatusChanged.disconnect( + contextConnectionStatusChanged, + this + ); + ctx.statusChanged.disconnect(contextStatusChanged, this); + }); + const handleKernelMsg = ( + sender: Kernel.IKernelConnection, + msg: Kernel.IAnyMessageArgs + ) => { + const message = msg.msg; + const msgId = message.header.msg_id; + + if (message.header.msg_type === 'execute_request') { + // A cell code is scheduled for executing + this._cellScheduledCallback(nb, msgId); + } else if ( + KernelMessage.isStatusMsg(message) && + message.content.execution_state === 'idle' + ) { + // Idle status message case. + const parentId = (message.parent_header as KernelMessage.IHeader) + .msg_id; + this._cellExecutedCallback(nb, parentId); + } else if ( + KernelMessage.isStatusMsg(message) && + message.content.execution_state === 'restarting' + ) { + this._restartHandler(nb); + } else if (message.header.msg_type === 'execute_input') { + // A cell code starts executing. + this._startTimer(nb); + } + }; + context.session?.kernel?.anyMessage.connect(handleKernelMsg); + context.session?.kernel?.disposed.connect(kernel => + kernel.anyMessage.disconnect(handleKernelMsg) + ); + const kernelChangedSlot = ( + _: ISessionContext, + kernelData: IChangedArgs< + Kernel.IKernelConnection | null, + Kernel.IKernelConnection | null, + 'kernel' + > + ) => { + if (state) { + this._resetTime(state); + this.stateChanged.emit(void 0); + if (kernelData.newValue) { + kernelData.newValue.anyMessage.connect(handleKernelMsg); + } + } + }; + context.kernelChanged.connect(kernelChangedSlot); + context.disposed.connect(ctx => + ctx.kernelChanged.disconnect(kernelChangedSlot) + ); + } + } + } + + /** + * The current activated notebook in model. + */ + get currentNotebook(): Notebook | null { + return this._currentNotebook; + } + + /** + * The display options for progress bar and elapsed time. + */ + get displayOption(): Private.DisplayOption { + return this._displayOption; + } + + /** + * Set the display options for progress bar and elapsed time. + * + * @param options - Options to be used + */ + set displayOption(options: Private.DisplayOption) { + this._displayOption = options; + } + + /** + * Get the execution state associated with a notebook. + * + * @param nb - The notebook used to identify execution + * state. + * + * @returns - The associated execution state. + */ + executionState(nb: Notebook): IExecutionState | undefined { + return this._notebookExecutionProgress.get(nb); + } + + /** + * Schedule switch to idle status and clearing of the timer. + * + * ### Note + * + * To keep track of cells executed under 1 second, + * the execution state is marked as `needReset` 1 second after executing + * these cells. This `Timeout` will be cleared if there is any cell + * scheduled after that. + */ + private _scheduleSwitchToIdle(state: IExecutionState) { + window.setTimeout(() => { + state.executionStatus = 'idle'; + clearInterval(state.interval); + this.stateChanged.emit(void 0); + }, 150); + state.timeout = window.setTimeout(() => { + state.needReset = true; + }, 1000); + } + + /** + * The function is called on kernel's idle status message. + * It is used to keep track of number of executed + * cells or Comm custom messages and the status of kernel. + * + * @param nb - The notebook which contains the executed code cell. + * @param msg_id - The id of message. + */ + private _cellExecutedCallback(nb: Notebook, msg_id: string): void { + const state = this._notebookExecutionProgress.get(nb); + if (state && state.scheduledCell.has(msg_id)) { + state.scheduledCell.delete(msg_id); + if (state.scheduledCell.size === 0) { + this._scheduleSwitchToIdle(state); + } + } + } + + /** + * The function is called on kernel's restarting status message. + * It is used to clear the state tracking the number of executed + * cells. + * + * @param nb - The notebook which contains the executed code cell. + */ + private _restartHandler(nb: Notebook): void { + const state = this._notebookExecutionProgress.get(nb); + if (state) { + state.scheduledCell.clear(); + this._scheduleSwitchToIdle(state); + } + } + + /** + * This function is called on kernel's `execute_input` message to start + * the elapsed time counter. + * + * @param nb - The notebook which contains the scheduled execution request. + */ + private _startTimer(nb: Notebook) { + const state = this._notebookExecutionProgress.get(nb); + if (!state) { + return; + } + if (state.scheduledCell.size > 0) { + if (state.executionStatus !== 'busy') { + state.executionStatus = 'busy'; + clearTimeout(state.timeout); + this.stateChanged.emit(void 0); + state.interval = window.setInterval(() => { + this._tick(state); + }, 1000); + } + } else { + this._resetTime(state); + } + } + + /** + * The function is called on kernel's `execute_request` message or Comm message, it is + * used to keep track number of scheduled cell or Comm execution message + * and the status of kernel. + * + * @param nb - The notebook which contains the scheduled code. + * cell + * @param msg_id - The id of message. + */ + private _cellScheduledCallback(nb: Notebook, msg_id: string): void { + const state = this._notebookExecutionProgress.get(nb); + + if (state && !state.scheduledCell.has(msg_id)) { + if (state.needReset) { + this._resetTime(state); + } + state.scheduledCell.add(msg_id); + state.scheduledCellNumber += 1; + } + } + + /** + * Increment the executed time of input execution state + * and emit `stateChanged` signal to re-render the indicator. + * + * @param data - the state to be updated. + */ + private _tick(data: IExecutionState): void { + data.totalTime += 1; + this.stateChanged.emit(void 0); + } + + /** + * Reset the input execution state. + * + * @param data - the state to be rested. + */ + private _resetTime(data: IExecutionState): void { + data.totalTime = 0; + data.scheduledCellNumber = 0; + data.executionStatus = 'idle'; + data.scheduledCell = new Set(); + clearTimeout(data.timeout); + clearInterval(data.interval); + data.needReset = false; + } + + get renderFlag(): boolean { + return this._renderFlag; + } + + updateRenderOption(options: { + showOnToolBar: boolean; + showProgress: boolean; + }): void { + if (this.displayOption.showOnToolBar) { + if (!options.showOnToolBar) { + this._renderFlag = false; + } else { + this._renderFlag = true; + } + } + this.displayOption.showProgress = options.showProgress; + this.stateChanged.emit(void 0); + } + + /** + * The option to show the indicator on status bar or toolbar. + */ + private _displayOption: Private.DisplayOption; + + /** + * Current activated notebook. + */ + private _currentNotebook: Notebook; + + /** + * A weak map to hold execution status of multiple notebooks. + */ + private _notebookExecutionProgress = new WeakMap< + Notebook, + IExecutionState + >(); + + /** + * A flag to show or hide the indicator. + */ + private _renderFlag: boolean; + } + + export function createExecutionIndicatorItem( + panel: NotebookPanel, + translator?: ITranslator, + loadSettings?: Promise + ): Widget { + const toolbarItem = new ExecutionIndicator(translator); + toolbarItem.model.displayOption = { + showOnToolBar: true, + showProgress: true + }; + toolbarItem.model.attachNotebook({ + content: panel.content, + context: panel.sessionContext + }); + + if (loadSettings) { + loadSettings + .then(settings => { + const updateSettings = (newSettings: ISettingRegistry.ISettings) => { + toolbarItem.model.updateRenderOption(getSettingValue(newSettings)); + }; + settings.changed.connect(updateSettings); + updateSettings(settings); + toolbarItem.disposed.connect(() => { + settings.changed.disconnect(updateSettings); + }); + }) + .catch((reason: Error) => { + console.error(reason.message); + }); + } + return toolbarItem; + } + + export function getSettingValue(settings: ISettingRegistry.ISettings): { + showOnToolBar: boolean; + showProgress: boolean; + } { + let showOnToolBar = true; + let showProgress = true; + const configValues = settings.get('kernelStatus').composite as JSONObject; + if (configValues) { + showOnToolBar = !(configValues.showOnStatusBar as boolean); + showProgress = configValues.showProgress as boolean; + } + + return { showOnToolBar, showProgress }; + } +} + +/** + * A namespace for module-private data. + */ +namespace Private { + export type DisplayOption = { + /** + * The option to show the indicator on status bar or toolbar. + */ + showOnToolBar: boolean; + + /** + * The option to show the execution progress inside kernel + * status circle. + */ + showProgress: boolean; + }; +} diff --git a/.yalc/@jupyterlab/notebook/src/history.ts b/.yalc/@jupyterlab/notebook/src/history.ts new file mode 100644 index 0000000000..f78ae91d22 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/history.ts @@ -0,0 +1,412 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Cell } from '@jupyterlab/cells'; +import { ISessionContext } from '@jupyterlab/apputils'; +import { CodeEditor } from '@jupyterlab/codeeditor'; +import { KernelMessage } from '@jupyterlab/services'; +import { + ITranslator, + nullTranslator, + TranslationBundle +} from '@jupyterlab/translation'; +import { IDisposable } from '@lumino/disposable'; +import { Signal } from '@lumino/signaling'; +import { IKernelConnection } from '@jupyterlab/services/lib/kernel/kernel'; + +/** + * The definition of a console history manager object. + */ +export interface INotebookHistory extends IDisposable { + /** + * The current editor used by the history widget. + */ + editor: CodeEditor.IEditor | null; + + /** + * The placeholder text that a history session began with. + */ + readonly placeholder: string; + + /** + * The session number of the current kernel session + */ + readonly kernelSession: string; + + /** + * Get the previous item in the console history. + * + * @param activeCell - The currently selected Cell in the notebook. + * + * @returns A Promise for console command text or `undefined` if unavailable. + */ + back(activeCell: Cell): Promise; + + /** + * Get the next item in the console history. + * + * @param activeCell - The currently selected Cell in the notebook. + * + * @returns A Promise for console command text or `undefined` if unavailable. + */ + forward(activeCell: Cell): Promise; + + /** + * Reset the history navigation state, i.e., start a new history session. + */ + reset(): void; + + /** + * Get the next item in the console history. + * + * @param activeCell - The currently selected Cell in the notebook. + * @param content - the result from back or forward + */ + updateEditor(activeCell: Cell, content: string | undefined): void; +} + +/** + * A console history manager object. + */ +export class NotebookHistory implements INotebookHistory { + /** + * Construct a new console history object. + */ + constructor(options: NotebookHistory.IOptions) { + this._sessionContext = options.sessionContext; + this._trans = (options.translator || nullTranslator).load('jupyterlab'); + void this._handleKernel().then(() => { + this._sessionContext.kernelChanged.connect(this._handleKernel, this); + }); + this._toRequest = this._requestBatchSize; + } + + /** + * The client session used to query history. + */ + private _sessionContext: ISessionContext; + + /** + * Translator to be used for warnings + */ + private _trans: TranslationBundle; + + /** + * The number of history items to request. + */ + private _toRequest: number; + + /** + * The number of history items to increase a batch size by per subsequent request. + */ + private _requestBatchSize: number = 10; + + /** + * The current editor used by the history manager. + */ + get editor(): CodeEditor.IEditor | null { + return this._editor; + } + + set editor(value: CodeEditor.IEditor | null) { + if (this._editor === value) { + return; + } + + const prev = this._editor; + if (prev) { + prev.model.sharedModel.changed.disconnect(this.onTextChange, this); + } + + this._editor = value; + + if (value) { + value.model.sharedModel.changed.connect(this.onTextChange, this); + } + } + + /** + * The placeholder text that a history session began with. + */ + get placeholder(): string { + return this._placeholder; + } + + /** + * Kernel session number for filtering + */ + get kernelSession(): string { + return this._kernelSession; + } + + /** + * Get whether the notebook history manager is disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Dispose of the resources held by the notebook history manager. + */ + dispose(): void { + this._isDisposed = true; + this._history.length = 0; + Signal.clearData(this); + } + + /** + * Set placeholder and editor. Start session if one is not already started. + * + * @param activeCell - The currently selected Cell in the notebook. + */ + protected async checkSession(activeCell: Cell): Promise { + if (!this._hasSession) { + await this._retrieveHistory(); + this._hasSession = true; + this.editor = activeCell.editor; + this._placeholder = this._editor?.model.sharedModel.getSource() || ''; + // Filter the history with the placeholder string. + this.setFilter(this._placeholder); + this._cursor = this._filtered.length - 1; + } + } + + /** + * Get the previous item in the notebook history. + * + * @param activeCell - The currently selected Cell in the notebook. + * + * @returns A Promise resolving to the historical cell content text. + */ + async back(activeCell: Cell): Promise { + await this.checkSession(activeCell); + --this._cursor; + if (this._cursor < 0) { + await this.fetchBatch(); + } + this._cursor = Math.max(0, this._cursor); + const content = this._filtered[this._cursor]; + // This shouldn't ever be undefined as `setFilter` will always be run first + return content; + } + + /** + * Get the next item in the notebook history. + * + * @param activeCell - The currently selected Cell in the notebook. + * + * @returns A Promise resolving to the historical cell content text. + */ + async forward(activeCell: Cell): Promise { + await this.checkSession(activeCell); + ++this._cursor; + this._cursor = Math.min(this._filtered.length - 1, this._cursor); + const content = this._filtered[this._cursor]; + // This shouldn't ever be undefined as `setFilter` will always be run first + return content; + } + + /** + * Update the editor of the cell with provided text content. + * + * @param activeCell - The currently selected Cell in the notebook. + * @param content - the result from back or forward + */ + updateEditor(activeCell: Cell, content: string | undefined): void { + if (activeCell) { + const model = activeCell.editor?.model; + const source = model?.sharedModel.getSource(); + if (this.isDisposed || !content) { + return; + } + if (source === content) { + return; + } + this._setByHistory = true; + model?.sharedModel.setSource(content); + let columnPos = 0; + columnPos = content.indexOf('\n'); + if (columnPos < 0) { + columnPos = content.length; + } + activeCell.editor?.setCursorPosition({ line: 0, column: columnPos }); + } + } + + /** + * Reset the history navigation state, i.e., start a new history session. + */ + reset(): void { + this._hasSession = false; + this._placeholder = ''; + this._toRequest = this._requestBatchSize; + } + + /** + * Fetches a subsequent batch of history. Updates the filtered history and cursor to correct place in history, + * accounting for potentially new history items above it. + */ + private async fetchBatch() { + this._toRequest += this._requestBatchSize; + let oldFilteredReversed = this._filtered.slice().reverse(); + let oldHistory = this._history.slice(); + await this._retrieveHistory().then(() => { + this.setFilter(this._placeholder); + let cursorOffset = 0; + let filteredReversed = this._filtered.slice().reverse(); + for (let i = 0; i < oldFilteredReversed.length; i++) { + let item = oldFilteredReversed[i]; + for (let ij = i + cursorOffset; ij < filteredReversed.length; ij++) { + if (item === filteredReversed[ij]) { + break; + } else { + cursorOffset += 1; + } + } + } + this._cursor = + this._filtered.length - (oldFilteredReversed.length + 1) - cursorOffset; + }); + if (this._cursor < 0) { + if (this._history.length > oldHistory.length) { + await this.fetchBatch(); + } + } + } + + /** + * Populate the history collection on history reply from a kernel. + * + * @param value The kernel message history reply. + * + * #### Notes + * History entries have the shape: + * [session: number, line: number, input: string] + * Contiguous duplicates are stripped out of the API response. + */ + protected onHistory( + value: KernelMessage.IHistoryReplyMsg, + cell?: Cell + ): void { + this._history.length = 0; + let last = ['', '', '']; + let current = ['', '', '']; + let kernelSession = ''; + if (value.content.status === 'ok') { + for (let i = 0; i < value.content.history.length; i++) { + current = value.content.history[i] as string[]; + if (current !== last) { + kernelSession = (value.content.history[i] as string[])[0]; + this._history.push((last = current)); + } + } + // set the kernel session for filtering + if (!this.kernelSession) { + if (current[2] == cell?.model.sharedModel.getSource()) { + this._kernelSession = kernelSession; + } + } + } + } + + /** + * Handle a text change signal from the editor. + */ + protected onTextChange(): void { + if (this._setByHistory) { + this._setByHistory = false; + return; + } + this.reset(); + } + + /** + * Handle the current kernel changing. + */ + private async _handleKernel(): Promise { + this._kernel = this._sessionContext.session?.kernel; + if (!this._kernel) { + this._history.length = 0; + return; + } + await this._retrieveHistory().catch(); + return; + } + + /** + * retrieve the history from the kernel + * + * @param cell - The string to use when filtering the data. + */ + private async _retrieveHistory(cell?: Cell): Promise { + return await this._kernel + ?.requestHistory(request(this._toRequest)) + .then(v => { + this.onHistory(v, cell); + }) + .catch(() => { + console.warn(this._trans.__('History was unable to be retrieved')); + }); + } + + /** + * Set the filter data. + * + * @param filterStr - The string to use when filtering the data. + */ + protected setFilter(filterStr: string = ''): void { + // Apply the new filter and remove contiguous duplicates. + this._filtered.length = 0; + + let last = ''; + let current = ''; + for (let i = 0; i < this._history.length; i++) { + current = this._history[i][2] as string; + if (current !== last && filterStr !== current) { + this._filtered.push((last = current)); + } + } + this._filtered.push(filterStr); + } + + private _cursor = 0; + private _hasSession = false; + private _history: Array> = []; + private _placeholder: string = ''; + private _kernelSession: string = ''; + private _setByHistory = false; + private _isDisposed = false; + private _editor: CodeEditor.IEditor | null = null; + private _filtered: string[] = []; + private _kernel: IKernelConnection | null | undefined = null; +} + +/** + * A namespace for NotebookHistory statics. + */ +export namespace NotebookHistory { + /** + * The initialization options for a console history object. + */ + export interface IOptions { + /** + * The client session used by the foreign handler. + */ + sessionContext: ISessionContext; + + /** + * The application language translator. + */ + translator?: ITranslator; + } +} + +function request(n: number): KernelMessage.IHistoryRequestMsg['content'] { + return { + output: false, + raw: true, + hist_access_type: 'tail', + n: n + }; +} diff --git a/.yalc/@jupyterlab/notebook/src/index.ts b/.yalc/@jupyterlab/notebook/src/index.ts new file mode 100644 index 0000000000..63b200ea23 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/index.ts @@ -0,0 +1,27 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +/** + * @packageDocumentation + * @module notebook + */ + +export * from './actions'; +export * from './cellexecutor'; +export * from './celllist'; +export * from './default-toolbar'; +export * from './executionindicator'; +export * from './history'; +export * from './model'; +export * from './modelfactory'; +export * from './modestatus'; +export * from './notebooklspadapter'; +export * from './notebooktools'; +export * from './panel'; +export * from './searchprovider'; +export * from './toc'; +export * from './tokens'; +export * from './tracker'; +export * from './truststatus'; +export * from './widget'; +export * from './widgetfactory'; +export * from './windowing'; diff --git a/.yalc/@jupyterlab/notebook/src/model.ts b/.yalc/@jupyterlab/notebook/src/model.ts new file mode 100644 index 0000000000..a9e437b8b3 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/model.ts @@ -0,0 +1,538 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Dialog, showDialog } from '@jupyterlab/apputils'; +import { ICellModel } from '@jupyterlab/cells'; +import { IChangedArgs } from '@jupyterlab/coreutils'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import * as nbformat from '@jupyterlab/nbformat'; +import { IObservableList } from '@jupyterlab/observables'; +import { + IMapChange, + ISharedNotebook, + NotebookChange, + YNotebook +} from '@jupyter/ydoc'; +import { + ITranslator, + nullTranslator, + TranslationBundle +} from '@jupyterlab/translation'; +import { JSONExt } from '@lumino/coreutils'; +import { ISignal, Signal } from '@lumino/signaling'; +import { CellList } from './celllist'; + +/** + * The definition of a model object for a notebook widget. + */ +export interface INotebookModel extends DocumentRegistry.IModel { + /** + * The list of cells in the notebook. + */ + readonly cells: CellList; + + /** + * The major version number of the nbformat. + */ + readonly nbformat: number; + + /** + * The minor version number of the nbformat. + */ + readonly nbformatMinor: number; + + /** + * The metadata associated with the notebook. + * + * ### Notes + * This is a copy of the metadata. Changing a part of it + * won't affect the model. + * As this returns a copy of all metadata, it is advised to + * use `getMetadata` to speed up the process of getting a single key. + */ + readonly metadata: nbformat.INotebookMetadata; + + /** + * Signal emitted when notebook metadata changes. + */ + readonly metadataChanged: ISignal; + + /** + * The array of deleted cells since the notebook was last run. + */ + readonly deletedCells: string[]; + + /** + * Shared model + */ + readonly sharedModel: ISharedNotebook; + + /** + * Delete a metadata + * + * @param key Metadata key + */ + deleteMetadata(key: string): void; + + /** + * Get a metadata + * + * ### Notes + * This returns a copy of the key value. + * + * @param key Metadata key + */ + getMetadata(key: string): any; + + /** + * Set a metadata + * + * @param key Metadata key + * @param value Metadata value + */ + setMetadata(key: string, value: any): void; +} + +/** + * An implementation of a notebook Model. + */ +export class NotebookModel implements INotebookModel { + /** + * Construct a new notebook model. + */ + constructor(options: NotebookModel.IOptions = {}) { + this.standaloneModel = typeof options.sharedModel === 'undefined'; + + if (options.sharedModel) { + this.sharedModel = options.sharedModel; + } else { + this.sharedModel = YNotebook.create({ + disableDocumentWideUndoRedo: + options.disableDocumentWideUndoRedo ?? true, + data: { + nbformat: nbformat.MAJOR_VERSION, + nbformat_minor: nbformat.MINOR_VERSION, + metadata: { + kernelspec: { name: '', display_name: '' }, + language_info: { name: options.languagePreference ?? '' } + } + } + }); + } + + this._cells = new CellList(this.sharedModel); + this._trans = (options.translator || nullTranslator).load('jupyterlab'); + this._deletedCells = []; + this._collaborationEnabled = !!options?.collaborationEnabled; + + this._cells.changed.connect(this._onCellsChanged, this); + this.sharedModel.changed.connect(this._onStateChanged, this); + this.sharedModel.metadataChanged.connect(this._onMetadataChanged, this); + } + + /** + * A signal emitted when the document content changes. + */ + get contentChanged(): ISignal { + return this._contentChanged; + } + + /** + * Signal emitted when notebook metadata changes. + */ + get metadataChanged(): ISignal> { + return this._metadataChanged; + } + + /** + * A signal emitted when the document state changes. + */ + get stateChanged(): ISignal> { + return this._stateChanged; + } + + /** + * Get the observable list of notebook cells. + */ + get cells(): CellList { + return this._cells; + } + + /** + * The dirty state of the document. + */ + get dirty(): boolean { + return this._dirty; + } + set dirty(newValue: boolean) { + const oldValue = this._dirty; + if (newValue === oldValue) { + return; + } + this._dirty = newValue; + this.triggerStateChange({ + name: 'dirty', + oldValue, + newValue + }); + } + + /** + * The read only state of the document. + */ + get readOnly(): boolean { + return this._readOnly; + } + set readOnly(newValue: boolean) { + if (newValue === this._readOnly) { + return; + } + const oldValue = this._readOnly; + this._readOnly = newValue; + this.triggerStateChange({ name: 'readOnly', oldValue, newValue }); + } + + /** + * The metadata associated with the notebook. + * + * ### Notes + * This is a copy of the metadata. Changing a part of it + * won't affect the model. + * As this returns a copy of all metadata, it is advised to + * use `getMetadata` to speed up the process of getting a single key. + */ + get metadata(): nbformat.INotebookMetadata { + return this.sharedModel.metadata; + } + + /** + * The major version number of the nbformat. + */ + get nbformat(): number { + return this.sharedModel.nbformat; + } + + /** + * The minor version number of the nbformat. + */ + get nbformatMinor(): number { + return this.sharedModel.nbformat_minor; + } + + /** + * The default kernel name of the document. + */ + get defaultKernelName(): string { + const spec = this.getMetadata('kernelspec'); + return spec?.name ?? ''; + } + + /** + * A list of deleted cells for the notebook.. + */ + get deletedCells(): string[] { + return this._deletedCells; + } + + /** + * The default kernel language of the document. + */ + get defaultKernelLanguage(): string { + const info = this.getMetadata('language_info'); + return info?.name ?? ''; + } + + /** + * Whether the model is collaborative or not. + */ + get collaborative(): boolean { + return this._collaborationEnabled; + } + + /** + * Dispose of the resources held by the model. + */ + dispose(): void { + // Do nothing if already disposed. + if (this.isDisposed) { + return; + } + this._isDisposed = true; + + const cells = this.cells; + this._cells = null!; + cells.dispose(); + if (this.standaloneModel) { + this.sharedModel.dispose(); + } + Signal.clearData(this); + } + + /** + * Delete a metadata + * + * @param key Metadata key + */ + deleteMetadata(key: string): void { + return this.sharedModel.deleteMetadata(key); + } + + /** + * Get a metadata + * + * ### Notes + * This returns a copy of the key value. + * + * @param key Metadata key + */ + getMetadata(key: string): any { + return this.sharedModel.getMetadata(key); + } + + /** + * Set a metadata + * + * @param key Metadata key + * @param value Metadata value + */ + setMetadata(key: string, value: any): void { + if (typeof value === 'undefined') { + this.sharedModel.deleteMetadata(key); + } else { + this.sharedModel.setMetadata(key, value); + } + } + + /** + * Serialize the model to a string. + */ + toString(): string { + return JSON.stringify(this.toJSON()); + } + + /** + * Deserialize the model from a string. + * + * #### Notes + * Should emit a [contentChanged] signal. + */ + fromString(value: string): void { + this.fromJSON(JSON.parse(value)); + } + + /** + * Serialize the model to JSON. + */ + toJSON(): nbformat.INotebookContent { + this._ensureMetadata(); + return this.sharedModel.toJSON(); + } + + /** + * Deserialize the model from JSON. + * + * #### Notes + * Should emit a [contentChanged] signal. + */ + fromJSON(value: nbformat.INotebookContent): void { + const copy = JSONExt.deepCopy(value); + const origNbformat = value.metadata.orig_nbformat; + + // Alert the user if the format changes. + copy.nbformat = Math.max(value.nbformat, nbformat.MAJOR_VERSION); + if ( + copy.nbformat !== value.nbformat || + copy.nbformat_minor < nbformat.MINOR_VERSION + ) { + copy.nbformat_minor = nbformat.MINOR_VERSION; + } + if (origNbformat !== undefined && copy.nbformat !== origNbformat) { + const newer = copy.nbformat > origNbformat; + let msg: string; + + if (newer) { + msg = this._trans.__( + `This notebook has been converted from an older notebook format (v%1) +to the current notebook format (v%2). +The next time you save this notebook, the current notebook format (v%2) will be used. +'Older versions of Jupyter may not be able to read the new format.' To preserve the original format version, +close the notebook without saving it.`, + origNbformat, + copy.nbformat + ); + } else { + msg = this._trans.__( + `This notebook has been converted from an newer notebook format (v%1) +to the current notebook format (v%2). +The next time you save this notebook, the current notebook format (v%2) will be used. +Some features of the original notebook may not be available.' To preserve the original format version, +close the notebook without saving it.`, + origNbformat, + copy.nbformat + ); + } + void showDialog({ + title: this._trans.__('Notebook converted'), + body: msg, + buttons: [Dialog.okButton({ label: this._trans.__('Ok') })] + }); + } + + // Ensure there is at least one cell + if ((copy.cells?.length ?? 0) === 0) { + copy['cells'] = [ + { cell_type: 'code', source: '', metadata: { trusted: true } } + ]; + } + this.sharedModel.fromJSON(copy); + + this._ensureMetadata(); + this.dirty = true; + } + + /** + * Handle a change in the cells list. + */ + private _onCellsChanged( + list: CellList, + change: IObservableList.IChangedArgs + ): void { + switch (change.type) { + case 'add': + change.newValues.forEach(cell => { + cell.contentChanged.connect(this.triggerContentChange, this); + }); + break; + case 'remove': + break; + case 'set': + change.newValues.forEach(cell => { + cell.contentChanged.connect(this.triggerContentChange, this); + }); + break; + default: + break; + } + this.triggerContentChange(); + } + + private _onMetadataChanged( + sender: ISharedNotebook, + changes: IMapChange + ): void { + this._metadataChanged.emit(changes); + this.triggerContentChange(); + } + + private _onStateChanged( + sender: ISharedNotebook, + changes: NotebookChange + ): void { + if (changes.stateChange) { + changes.stateChange.forEach(value => { + if (value.name === 'dirty') { + // Setting `dirty` will trigger the state change. + // We always set `dirty` because the shared model state + // and the local attribute are synchronized one way shared model -> _dirty + this.dirty = value.newValue; + } else if (value.oldValue !== value.newValue) { + this.triggerStateChange({ + newValue: undefined, + oldValue: undefined, + ...value + }); + } + }); + } + } + + /** + * Make sure we have the required metadata fields. + */ + private _ensureMetadata(languageName: string = ''): void { + if (!this.getMetadata('language_info')) { + this.sharedModel.setMetadata('language_info', { name: languageName }); + } + if (!this.getMetadata('kernelspec')) { + this.sharedModel.setMetadata('kernelspec', { + name: '', + display_name: '' + }); + } + } + + /** + * Trigger a state change signal. + */ + protected triggerStateChange(args: IChangedArgs): void { + this._stateChanged.emit(args); + } + + /** + * Trigger a content changed signal. + */ + protected triggerContentChange(): void { + this._contentChanged.emit(void 0); + this.dirty = true; + } + + /** + * Whether the model is disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * The shared notebook model. + */ + readonly sharedModel: ISharedNotebook; + + /** + * Whether the model should disposed the shared model on disposal or not. + */ + protected standaloneModel = false; + + private _dirty = false; + private _readOnly = false; + private _contentChanged = new Signal(this); + private _stateChanged = new Signal>(this); + + private _trans: TranslationBundle; + private _cells: CellList; + private _deletedCells: string[]; + private _isDisposed = false; + private _metadataChanged = new Signal(this); + private _collaborationEnabled: boolean; +} + +/** + * The namespace for the `NotebookModel` class statics. + */ +export namespace NotebookModel { + /** + * An options object for initializing a notebook model. + */ + export interface IOptions + extends DocumentRegistry.IModelOptions { + /** + * Default cell type. + */ + defaultCell?: 'code' | 'markdown' | 'raw'; + + /** + * Language translator. + */ + translator?: ITranslator; + + /** + * Defines if the document can be undo/redo. + * + * Default: true + * + * @experimental + * @alpha + */ + disableDocumentWideUndoRedo?: boolean; + } +} diff --git a/.yalc/@jupyterlab/notebook/src/modelfactory.ts b/.yalc/@jupyterlab/notebook/src/modelfactory.ts new file mode 100644 index 0000000000..e280bebeb8 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/modelfactory.ts @@ -0,0 +1,135 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import type { ISharedNotebook } from '@jupyter/ydoc'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { Contents } from '@jupyterlab/services'; +import { INotebookModel, NotebookModel } from './model'; + +/** + * A model factory for notebooks. + */ +export class NotebookModelFactory + implements DocumentRegistry.IModelFactory +{ + /** + * Construct a new notebook model factory. + */ + constructor(options: NotebookModelFactory.IOptions = {}) { + this._disableDocumentWideUndoRedo = + options.disableDocumentWideUndoRedo ?? true; + this._collaborative = options.collaborative ?? true; + } + + /** + * Define the disableDocumentWideUndoRedo property. + * + * @experimental + * @alpha + */ + get disableDocumentWideUndoRedo(): boolean { + return this._disableDocumentWideUndoRedo; + } + set disableDocumentWideUndoRedo(disableDocumentWideUndoRedo: boolean) { + this._disableDocumentWideUndoRedo = disableDocumentWideUndoRedo; + } + + /** + * The name of the model. + */ + get name(): string { + return 'notebook'; + } + + /** + * The content type of the file. + */ + get contentType(): Contents.ContentType { + return 'notebook'; + } + + /** + * The format of the file. + */ + get fileFormat(): Contents.FileFormat { + return 'json'; + } + + /** + * Whether the model is collaborative or not. + */ + get collaborative(): boolean { + return this._collaborative; + } + + /** + * Get whether the model factory has been disposed. + */ + get isDisposed(): boolean { + return this._disposed; + } + + /** + * Dispose of the model factory. + */ + dispose(): void { + this._disposed = true; + } + + /** + * Create a new model for a given path. + * + * @param options Model options. + * + * @returns A new document model. + */ + createNew( + options: DocumentRegistry.IModelOptions = {} + ): INotebookModel { + return new NotebookModel({ + languagePreference: options.languagePreference, + sharedModel: options.sharedModel, + collaborationEnabled: options.collaborationEnabled && this.collaborative, + disableDocumentWideUndoRedo: this._disableDocumentWideUndoRedo + }); + } + + /** + * Get the preferred kernel language given a path. + */ + preferredLanguage(path: string): string { + return ''; + } + + /** + * Defines if the document can be undo/redo. + */ + private _disableDocumentWideUndoRedo: boolean; + private _disposed = false; + private _collaborative: boolean; +} + +/** + * The namespace for notebook model factory statics. + */ +export namespace NotebookModelFactory { + /** + * The options used to initialize a NotebookModelFactory. + */ + export interface IOptions { + /** + * Whether the model is collaborative or not. + */ + collaborative?: boolean; + + /** + * Defines if the document can be undo/redo. + * + * Default: true + * + * @experimental + * @alpha + */ + disableDocumentWideUndoRedo?: boolean; + } +} diff --git a/.yalc/@jupyterlab/notebook/src/modestatus.tsx b/.yalc/@jupyterlab/notebook/src/modestatus.tsx new file mode 100644 index 0000000000..9c4513709a --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/modestatus.tsx @@ -0,0 +1,166 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { TextItem } from '@jupyterlab/statusbar'; +import { + ITranslator, + nullTranslator, + TranslationBundle +} from '@jupyterlab/translation'; +import { VDomModel, VDomRenderer } from '@jupyterlab/ui-components'; +import * as React from 'react'; +import { Notebook, NotebookMode } from '.'; + +/** + * A pure function for rendering a Command/Edit mode component. + * + * @param props the props for rendering the component. + * + * @returns a tsx component for command/edit mode. + */ +function CommandEditComponent( + props: CommandEditComponent.IProps +): React.ReactElement { + const trans = (props.translator || nullTranslator).load('jupyterlab'); + return ( + + ); +} + +/** + * A namespace for CommandEditComponent statics. + */ +namespace CommandEditComponent { + /** + * The props for the CommandEditComponent. + */ + export interface IProps { + /** + * The current mode of the current notebook. + */ + notebookMode: NotebookMode; + + /** + * Language translator. + */ + translator?: ITranslator; + + /** + * Mapping translating the names of modes. + */ + modeNames: Record; + } +} + +/** + * StatusBar item to display which notebook mode user is in. + */ +export class CommandEditStatus extends VDomRenderer { + /** + * Construct a new CommandEdit status item. + */ + constructor(translator?: ITranslator) { + super(new CommandEditStatus.Model()); + this.translator = translator || nullTranslator; + this._trans = this.translator.load('jupyterlab'); + this._modeNames = { + command: this._trans.__('Command'), + edit: this._trans.__('Edit') + }; + } + + /** + * Render the CommandEdit status item. + */ + render(): JSX.Element | null { + if (!this.model) { + return null; + } + this.node.title = this._trans.__( + 'Notebook is in %1 mode', + this._modeNames[this.model.notebookMode] + ); + return ( + + ); + } + + protected translator: ITranslator; + private _trans: TranslationBundle; + private readonly _modeNames: Record; +} + +/** + * A namespace for CommandEdit statics. + */ +export namespace CommandEditStatus { + /** + * A VDomModel for the CommandEdit renderer. + */ + export class Model extends VDomModel { + /** + * The current mode of the current notebook. + */ + get notebookMode(): NotebookMode { + return this._notebookMode; + } + + /** + * Set the current notebook for the model. + */ + set notebook(notebook: Notebook | null) { + const oldNotebook = this._notebook; + if (oldNotebook !== null) { + oldNotebook.stateChanged.disconnect(this._onChanged, this); + oldNotebook.activeCellChanged.disconnect(this._onChanged, this); + oldNotebook.modelContentChanged.disconnect(this._onChanged, this); + } + + const oldMode = this._notebookMode; + this._notebook = notebook; + if (this._notebook === null) { + this._notebookMode = 'command'; + } else { + this._notebookMode = this._notebook.mode; + this._notebook.stateChanged.connect(this._onChanged, this); + this._notebook.activeCellChanged.connect(this._onChanged, this); + this._notebook.modelContentChanged.connect(this._onChanged, this); + } + + this._triggerChange(oldMode, this._notebookMode); + } + + /** + * On a change to the notebook, update the mode. + */ + private _onChanged = (_notebook: Notebook) => { + const oldMode = this._notebookMode; + if (this._notebook) { + this._notebookMode = _notebook.mode; + } else { + this._notebookMode = 'command'; + } + this._triggerChange(oldMode, this._notebookMode); + }; + + /** + * Trigger a state change for the renderer. + */ + private _triggerChange(oldState: NotebookMode, newState: NotebookMode) { + if (oldState !== newState) { + this.stateChanged.emit(void 0); + } + } + + private _notebookMode: NotebookMode = 'command'; + private _notebook: Notebook | null = null; + } +} diff --git a/.yalc/@jupyterlab/notebook/src/notebookfooter.ts b/.yalc/@jupyterlab/notebook/src/notebookfooter.ts new file mode 100644 index 0000000000..23561da57f --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/notebookfooter.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Widget } from '@lumino/widgets'; +import { Message } from '@lumino/messaging'; +import { Notebook } from './widget'; +import { NotebookActions } from './actions'; + +const NOTEBOOK_FOOTER_CLASS = 'jp-Notebook-footer'; + +/** + * A footer widget added after the last cell of the notebook. + */ +export class NotebookFooter extends Widget { + /** + * Construct a footer widget. + */ + constructor(protected notebook: Notebook) { + super({ node: document.createElement('button') }); + const trans = notebook.translator.load('jupyterlab'); + this.addClass(NOTEBOOK_FOOTER_CLASS); + this.node.setAttribute('tabindex', '-1'); + this.node.innerText = trans.__('Click to add a cell.'); + } + + /** + * Handle incoming events. + */ + handleEvent(event: Event): void { + switch (event.type) { + case 'click': + this.onClick(); + break; + case 'keydown': + if ((event as KeyboardEvent).key === 'ArrowUp') { + this.onArrowUp(); + break; + } + } + } + + /** + * On single click (mouse event), insert a cell below (at the end of the notebook as default behavior). + */ + protected onClick(): void { + if (this.notebook.widgets.length > 0) { + this.notebook.activeCellIndex = this.notebook.widgets.length - 1; + } + NotebookActions.insertBelow(this.notebook); + // Focus on the created cell. + void NotebookActions.focusActiveCell(this.notebook); + } + + /** + * On arrow up key pressed (keydown keyboard event). + * @deprecated To be removed in v5, this is a no-op + */ + protected onArrowUp(): void { + // The specific behavior has been removed in https://github.com/jupyterlab/jupyterlab/pull/14796 + } + + /* + * Handle `after-detach` messages for the widget. + */ + protected onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + this.node.addEventListener('click', this); + this.node.addEventListener('keydown', this); + } + + /** + * Handle `before-detach` messages for the widget. + */ + protected onBeforeDetach(msg: Message): void { + this.node.removeEventListener('click', this); + this.node.removeEventListener('keydown', this); + super.onBeforeDetach(msg); + } +} diff --git a/.yalc/@jupyterlab/notebook/src/notebooklspadapter.ts b/.yalc/@jupyterlab/notebook/src/notebooklspadapter.ts new file mode 100644 index 0000000000..f26a1eaf9f --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/notebooklspadapter.ts @@ -0,0 +1,495 @@ +// 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(); + this.isReady = this.isReady.bind(this); + 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 language' + ); + } + } 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 can 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(); +} diff --git a/.yalc/@jupyterlab/notebook/src/notebooktools.ts b/.yalc/@jupyterlab/notebook/src/notebooktools.ts new file mode 100644 index 0000000000..d1baa3b636 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/notebooktools.ts @@ -0,0 +1,612 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Cell, ICellModel } from '@jupyterlab/cells'; +import { CodeEditor, JSONEditor } from '@jupyterlab/codeeditor'; +import { ObservableJSON } from '@jupyterlab/observables'; +import { IMapChange } from '@jupyter/ydoc'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { Collapser } from '@jupyterlab/ui-components'; +import { ArrayExt } from '@lumino/algorithm'; +import { ReadonlyPartialJSONValue } from '@lumino/coreutils'; +import { ConflatableMessage, Message, MessageLoop } from '@lumino/messaging'; +import { PanelLayout, Widget } from '@lumino/widgets'; +import { INotebookModel } from './model'; +import { NotebookPanel } from './panel'; +import { INotebookTools, INotebookTracker } from './tokens'; + +class RankedPanel extends Widget { + constructor() { + super(); + this.layout = new PanelLayout(); + this.addClass('jp-RankedPanel'); + } + + addWidget(widget: Widget, rank: number): void { + const rankItem = { widget, rank }; + const index = ArrayExt.upperBound(this._items, rankItem, Private.itemCmp); + ArrayExt.insert(this._items, index, rankItem); + + const layout = this.layout as PanelLayout; + layout.insertWidget(index, widget); + } + + /** + * Handle the removal of a child + * + */ + protected onChildRemoved(msg: Widget.ChildMessage): void { + const index = ArrayExt.findFirstIndex( + this._items, + item => item.widget === msg.child + ); + if (index !== -1) { + ArrayExt.removeAt(this._items, index); + } + } + + private _items: Private.IRankItem[] = []; +} + +/** + * A widget that provides metadata tools. + */ +export class NotebookTools extends Widget implements INotebookTools { + /** + * Construct a new NotebookTools object. + */ + constructor(options: NotebookTools.IOptions) { + super(); + this.addClass('jp-NotebookTools'); + + this.translator = options.translator || nullTranslator; + + this._tools = []; + + this.layout = new PanelLayout(); + + this._tracker = options.tracker; + this._tracker.currentChanged.connect( + this._onActiveNotebookPanelChanged, + this + ); + this._tracker.activeCellChanged.connect(this._onActiveCellChanged, this); + this._tracker.selectionChanged.connect(this._onSelectionChanged, this); + this._onActiveNotebookPanelChanged(); + this._onActiveCellChanged(); + this._onSelectionChanged(); + } + + /** + * The active cell widget. + */ + get activeCell(): Cell | null { + return this._tracker.activeCell; + } + + /** + * The currently selected cells. + */ + get selectedCells(): Cell[] { + const panel = this._tracker.currentWidget; + if (!panel) { + return []; + } + const notebook = panel.content; + return notebook.widgets.filter(cell => notebook.isSelectedOrActive(cell)); + } + + /** + * The current notebook. + */ + get activeNotebookPanel(): NotebookPanel | null { + return this._tracker.currentWidget; + } + + /** + * Add a cell tool item. + */ + addItem(options: NotebookTools.IAddOptions): void { + const tool = options.tool; + const rank = options.rank ?? 100; + + let section: RankedPanel; + const extendedTool = this._tools.find( + extendedTool => extendedTool.section === options.section + ); + if (extendedTool) section = extendedTool.panel; + else { + throw new Error(`The section ${options.section} does not exist`); + } + + tool.addClass('jp-NotebookTools-tool'); + section.addWidget(tool, rank); + // TODO: perhaps the necessary notebookTools functionality should be + // consolidated into a single object, rather than a broad reference to this. + tool.notebookTools = this; + + // Trigger the tool to update its active notebook and cell. + MessageLoop.sendMessage(tool, NotebookTools.ActiveNotebookPanelMessage); + MessageLoop.sendMessage(tool, NotebookTools.ActiveCellMessage); + } + + /* + * Add a section to the notebook tool with its widget + */ + addSection(options: NotebookTools.IAddSectionOptions): void { + const sectionName = options.sectionName; + const label = options.label || options.sectionName; + const widget = options.tool; + let rank = options.rank ?? null; + + const newSection = new RankedPanel(); + newSection.title.label = label; + + if (widget) newSection.addWidget(widget, 0); + + this._tools.push({ + section: sectionName, + panel: newSection, + rank: rank + }); + + if (rank != null) + (this.layout as PanelLayout).insertWidget( + rank, + new Collapser({ widget: newSection }) + ); + else { + // If no rank is provided, try to add the new section before the AdvancedTools. + let advancedToolsRank = null; + const layout = this.layout as PanelLayout; + for (let i = 0; i < layout.widgets.length; i++) { + let w = layout.widgets[i]; + if (w instanceof Collapser) { + if (w.widget.id === 'advancedToolsSection') { + advancedToolsRank = i; + break; + } + } + } + + if (advancedToolsRank !== null) + (this.layout as PanelLayout).insertWidget( + advancedToolsRank, + new Collapser({ widget: newSection }) + ); + else + (this.layout as PanelLayout).addWidget( + new Collapser({ widget: newSection }) + ); + } + } + + /** + * Handle a change to the notebook panel. + */ + private _onActiveNotebookPanelChanged(): void { + if ( + this._prevActiveNotebookModel && + !this._prevActiveNotebookModel.isDisposed + ) { + this._prevActiveNotebookModel.metadataChanged.disconnect( + this._onActiveNotebookPanelMetadataChanged, + this + ); + } + const activeNBModel = + this.activeNotebookPanel && this.activeNotebookPanel.content + ? this.activeNotebookPanel.content.model + : null; + this._prevActiveNotebookModel = activeNBModel; + if (activeNBModel) { + activeNBModel.metadataChanged.connect( + this._onActiveNotebookPanelMetadataChanged, + this + ); + } + for (const widget of this._toolChildren()) { + MessageLoop.sendMessage(widget, NotebookTools.ActiveNotebookPanelMessage); + } + } + + /** + * Handle a change to the active cell. + */ + private _onActiveCellChanged(): void { + if (this._prevActiveCell && !this._prevActiveCell.isDisposed) { + this._prevActiveCell.metadataChanged.disconnect( + this._onActiveCellMetadataChanged, + this + ); + } + const activeCell = this.activeCell ? this.activeCell.model : null; + this._prevActiveCell = activeCell; + if (activeCell) { + activeCell.metadataChanged.connect( + this._onActiveCellMetadataChanged, + this + ); + } + for (const widget of this._toolChildren()) { + MessageLoop.sendMessage(widget, NotebookTools.ActiveCellMessage); + } + } + + /** + * Handle a change in the selection. + */ + private _onSelectionChanged(): void { + for (const widget of this._toolChildren()) { + MessageLoop.sendMessage(widget, NotebookTools.SelectionMessage); + } + } + + /** + * Handle a change in the active cell metadata. + */ + private _onActiveNotebookPanelMetadataChanged( + sender: INotebookModel, + args: IMapChange + ): void { + const message = new ObservableJSON.ChangeMessage( + 'activenotebookpanel-metadata-changed', + { oldValue: undefined, newValue: undefined, ...args } + ); + for (const widget of this._toolChildren()) { + MessageLoop.sendMessage(widget, message); + } + } + + /** + * Handle a change in the notebook model metadata. + */ + private _onActiveCellMetadataChanged( + sender: ICellModel, + args: IMapChange + ): void { + const message = new ObservableJSON.ChangeMessage( + 'activecell-metadata-changed', + { newValue: undefined, oldValue: undefined, ...args } + ); + for (const widget of this._toolChildren()) { + MessageLoop.sendMessage(widget, message); + } + } + + private *_toolChildren() { + for (let tool of this._tools) { + yield* tool.panel.children(); + } + } + + translator: ITranslator; + private _tools: Array; + private _tracker: INotebookTracker; + private _prevActiveCell: ICellModel | null; + private _prevActiveNotebookModel: INotebookModel | null; +} + +/** + * The namespace for NotebookTools class statics. + */ +export namespace NotebookTools { + /** + * A type alias for a readonly partial JSON tuples `[option, value]`. + * `option` should be localized. + * + * Note: Partial here means that JSON object attributes can be `undefined`. + */ + export type ReadonlyPartialJSONOptionValueArray = [ + ReadonlyPartialJSONValue | undefined, + ReadonlyPartialJSONValue + ][]; + + /** + * Interface for an extended panel section. + */ + export interface IToolPanel { + /** + * The name of the section. + */ + section: string; + + /** + * The associated panel, only one for a section. + */ + panel: RankedPanel; + + /** + * The rank of the section on the notebooktools panel. + */ + rank?: number | null; + } + + /** + * The options used to create a NotebookTools object. + */ + export interface IOptions { + /** + * The notebook tracker used by the notebook tools. + */ + tracker: INotebookTracker; + + /** + * Language translator. + */ + translator?: ITranslator; + } + + /** + * The options used to add an item to the notebook tools. + */ + export interface IAddOptions { + /** + * The tool to add to the notebook tools area. + */ + tool: INotebookTools.ITool; + + /** + * The section to which the tool should be added. + */ + section: string; + + /** + * The rank order of the widget among its siblings. + */ + rank?: number; + } + + /** + * The options used to add a section to the notebook tools. + */ + export interface IAddSectionOptions { + /** + * The name of the new section. + */ + sectionName: string; + + /** + * The tool to add to the notebook tools area. + */ + tool?: INotebookTools.ITool; + + /** + * The label of the new section. + */ + label?: string; + + /** + * The rank order of the section among its siblings. + */ + rank?: number; + } + + /** + * A singleton conflatable `'activenotebookpanel-changed'` message. + */ + export const ActiveNotebookPanelMessage = new ConflatableMessage( + 'activenotebookpanel-changed' + ); + + /** + * A singleton conflatable `'activecell-changed'` message. + */ + export const ActiveCellMessage = new ConflatableMessage('activecell-changed'); + + /** + * A singleton conflatable `'selection-changed'` message. + */ + export const SelectionMessage = new ConflatableMessage('selection-changed'); + + /** + * The base notebook tool, meant to be subclassed. + */ + export class Tool extends Widget implements INotebookTools.ITool { + /** + * The notebook tools object. + */ + notebookTools: INotebookTools; + + dispose(): void { + super.dispose(); + if (this.notebookTools) { + this.notebookTools = null!; + } + } + + /** + * Process a message sent to the widget. + * + * @param msg - The message sent to the widget. + */ + processMessage(msg: Message): void { + super.processMessage(msg); + switch (msg.type) { + case 'activenotebookpanel-changed': + this.onActiveNotebookPanelChanged(msg); + break; + case 'activecell-changed': + this.onActiveCellChanged(msg); + break; + case 'selection-changed': + this.onSelectionChanged(msg); + break; + case 'activecell-metadata-changed': + this.onActiveCellMetadataChanged(msg as ObservableJSON.ChangeMessage); + break; + case 'activenotebookpanel-metadata-changed': + this.onActiveNotebookPanelMetadataChanged( + msg as ObservableJSON.ChangeMessage + ); + break; + default: + break; + } + } + + /** + * Handle a change to the notebook panel. + * + * #### Notes + * The default implementation is a no-op. + */ + protected onActiveNotebookPanelChanged(msg: Message): void { + /* no-op */ + } + + /** + * Handle a change to the active cell. + * + * #### Notes + * The default implementation is a no-op. + */ + protected onActiveCellChanged(msg: Message): void { + /* no-op */ + } + + /** + * Handle a change to the selection. + * + * #### Notes + * The default implementation is a no-op. + */ + protected onSelectionChanged(msg: Message): void { + /* no-op */ + } + + /** + * Handle a change to the metadata of the active cell. + * + * #### Notes + * The default implementation is a no-op. + */ + protected onActiveCellMetadataChanged( + msg: ObservableJSON.ChangeMessage + ): void { + /* no-op */ + } + + /** + * Handle a change to the metadata of the active cell. + * + * #### Notes + * The default implementation is a no-op. + */ + protected onActiveNotebookPanelMetadataChanged( + msg: ObservableJSON.ChangeMessage + ): void { + /* no-op */ + } + } + + /** + * A raw metadata editor. + */ + export class MetadataEditorTool extends Tool { + /** + * Construct a new raw metadata tool. + */ + constructor(options: MetadataEditorTool.IOptions) { + super(); + const { editorFactory } = options; + this.addClass('jp-MetadataEditorTool'); + const layout = (this.layout = new PanelLayout()); + + this._editorFactory = editorFactory; + this._editorLabel = options.label || 'Edit Metadata'; + this.createEditor(); + const titleNode = new Widget({ node: document.createElement('label') }); + titleNode.node.textContent = options.label || 'Edit Metadata'; + layout.addWidget(titleNode); + layout.addWidget(this.editor); + } + + /** + * The editor used by the tool. + */ + get editor(): JSONEditor { + return this._editor; + } + + /** + * Handle a change to the notebook. + */ + protected onActiveNotebookPanelChanged(msg: Message): void { + this.editor.dispose(); + if (this.notebookTools.activeNotebookPanel) { + this.createEditor(); + } + } + + protected createEditor() { + this._editor = new JSONEditor({ + editorFactory: this._editorFactory + }); + this.editor.title.label = this._editorLabel; + + (this.layout as PanelLayout).addWidget(this.editor); + } + + private _editor: JSONEditor; + private _editorLabel: string; + private _editorFactory: CodeEditor.Factory; + } + + /** + * The namespace for `MetadataEditorTool` static data. + */ + export namespace MetadataEditorTool { + /** + * The options used to initialize a metadata editor tool. + */ + export interface IOptions { + /** + * The editor factory used by the tool. + */ + editorFactory: CodeEditor.Factory; + + /** + * The label for the JSON editor + */ + label?: string; + + /** + * Initial collapse state, defaults to true. + */ + collapsed?: boolean; + + /** + * Language translator. + */ + translator?: ITranslator; + } + } +} + +/** + * A namespace for private data. + */ +namespace Private { + /** + * An object which holds a widget and its sort rank. + */ + export interface IRankItem { + /** + * The widget for the item. + */ + widget: T; + + /** + * The sort rank of the menu. + */ + rank: number; + } + + /** + * A comparator function for widget rank items. + */ + export function itemCmp(first: IRankItem, second: IRankItem): number { + return first.rank - second.rank; + } +} diff --git a/.yalc/@jupyterlab/notebook/src/panel.ts b/.yalc/@jupyterlab/notebook/src/panel.ts new file mode 100644 index 0000000000..9e8f08766d --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/panel.ts @@ -0,0 +1,330 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + Dialog, + ISessionContext, + Printing, + showDialog +} from '@jupyterlab/apputils'; +import { isMarkdownCellModel } from '@jupyterlab/cells'; +import { PageConfig } from '@jupyterlab/coreutils'; +import { DocumentRegistry, DocumentWidget } from '@jupyterlab/docregistry'; +import { Kernel, KernelMessage, Session } from '@jupyterlab/services'; +import { ITranslator } from '@jupyterlab/translation'; +import { Token } from '@lumino/coreutils'; +import { INotebookModel } from './model'; +import { Notebook, StaticNotebook } from './widget'; +import { Message } from '@lumino/messaging'; + +/** + * The class name added to notebook panels. + */ +const NOTEBOOK_PANEL_CLASS = 'jp-NotebookPanel'; + +const NOTEBOOK_PANEL_TOOLBAR_CLASS = 'jp-NotebookPanel-toolbar'; + +const NOTEBOOK_PANEL_NOTEBOOK_CLASS = 'jp-NotebookPanel-notebook'; + +/** + * A widget that hosts a notebook toolbar and content area. + * + * #### Notes + * The widget keeps the document metadata in sync with the current + * kernel on the context. + */ +export class NotebookPanel extends DocumentWidget { + /** + * Construct a new notebook panel. + */ + constructor(options: DocumentWidget.IOptions) { + super(options); + + // Set up CSS classes + this.addClass(NOTEBOOK_PANEL_CLASS); + this.toolbar.addClass(NOTEBOOK_PANEL_TOOLBAR_CLASS); + this.content.addClass(NOTEBOOK_PANEL_NOTEBOOK_CLASS); + + // Set up things related to the context + this.content.model = this.context.model; + this.context.sessionContext.kernelChanged.connect( + this._onKernelChanged, + this + ); + this.context.sessionContext.statusChanged.connect( + this._onSessionStatusChanged, + this + ); + // this.content.fullyRendered.connect(this._onFullyRendered, this); + this.context.saveState.connect(this._onSave, this); + void this.revealed.then(() => { + if (this.isDisposed) { + // this widget has already been disposed, bail + return; + } + + // Set the document edit mode on initial open if it looks like a new document. + if (this.content.widgets.length === 1) { + const cellModel = this.content.widgets[0].model; + if ( + cellModel.type === 'code' && + cellModel.sharedModel.getSource() === '' + ) { + this.content.mode = 'edit'; + } + } + }); + } + + /** + * Handle a change to the document registry save state. + * + * @param sender The document registry context + * @param state The document registry save state + */ + private _onSave( + sender: DocumentRegistry.Context, + state: DocumentRegistry.SaveState + ): void { + if (state === 'started' && this.model) { + // Find markdown cells + for (const cell of this.model.cells) { + if (isMarkdownCellModel(cell)) { + for (const key of cell.attachments.keys) { + if (!cell.sharedModel.getSource().includes(key)) { + cell.attachments.remove(key); + } + } + } + } + } + } + + /** + * The session context used by the panel. + */ + get sessionContext(): ISessionContext { + return this.context.sessionContext; + } + + /** + * The model for the widget. + */ + get model(): INotebookModel | null { + return this.content.model; + } + + /** + * Update the options for the current notebook panel. + * + * @param config new options to set + */ + setConfig(config: NotebookPanel.IConfig): void { + this.content.editorConfig = config.editorConfig; + this.content.notebookConfig = config.notebookConfig; + // Update kernel shutdown behavior + const kernelPreference = this.context.sessionContext.kernelPreference; + this.context.sessionContext.kernelPreference = { + ...kernelPreference, + shutdownOnDispose: config.kernelShutdown, + autoStartDefault: config.autoStartDefault + }; + } + + /** + * Set URI fragment identifier. + */ + setFragment(fragment: string): void { + void this.context.ready.then(() => { + void this.content.setFragment(fragment); + }); + } + + /** + * Dispose of the resources used by the widget. + */ + dispose(): void { + this.content.dispose(); + super.dispose(); + } + + /** + * Prints the notebook by converting to HTML with nbconvert. + */ + [Printing.symbol]() { + return async (): Promise => { + // Save before generating HTML + if (this.context.model.dirty && !this.context.model.readOnly) { + await this.context.save(); + } + + await Printing.printURL( + PageConfig.getNBConvertURL({ + format: 'html', + download: false, + path: this.context.path + }) + ); + }; + } + + /** + * A message handler invoked on a 'before-hide' message. + */ + protected onBeforeHide(msg: Message): void { + super.onBeforeHide(msg); + // Inform the windowed list that the notebook is gonna be hidden + this.content.isParentHidden = true; + } + + /** + * A message handler invoked on a 'before-show' message. + */ + protected onBeforeShow(msg: Message): void { + // Inform the windowed list that the notebook is gonna be shown + // Use onBeforeShow instead of onAfterShow to take into account + // resizing (like sidebars got expanded before switching to the notebook tab) + this.content.isParentHidden = false; + super.onBeforeShow(msg); + } + + /** + * Handle a change in the kernel by updating the document metadata. + */ + private _onKernelChanged( + sender: any, + args: Session.ISessionConnection.IKernelChangedArgs + ): void { + if (!this.model || !args.newValue) { + return; + } + const { newValue } = args; + void newValue.info.then(info => { + if ( + this.model && + this.context.sessionContext.session?.kernel === newValue + ) { + this._updateLanguage(info.language_info); + } + }); + void this._updateSpec(newValue); + } + + private _onSessionStatusChanged( + sender: ISessionContext, + status: Kernel.Status + ) { + // If the status is autorestarting, and we aren't already in a series of + // autorestarts, show the dialog. + if (status === 'autorestarting' && !this._autorestarting) { + // The kernel died and the server is restarting it. We notify the user so + // they know why their kernel state is gone. + void showDialog({ + title: this._trans.__('Kernel Restarting'), + body: this._trans.__( + 'The kernel for %1 appears to have died. It will restart automatically.', + this.sessionContext.session?.path + ), + buttons: [Dialog.okButton({ label: this._trans.__('Ok') })] + }); + this._autorestarting = true; + } else if (status === 'restarting') { + // Another autorestart attempt will first change the status to + // restarting, then to autorestarting again, so we don't reset the + // autorestarting status if the status is 'restarting'. + /* no-op */ + } else { + this._autorestarting = false; + } + } + + /** + * Update the kernel language. + */ + private _updateLanguage(language: KernelMessage.ILanguageInfo): void { + this.model!.setMetadata('language_info', language); + } + + /** + * Update the kernel spec. + */ + private async _updateSpec(kernel: Kernel.IKernelConnection): Promise { + const spec = await kernel.spec; + if (this.isDisposed) { + return; + } + this.model!.setMetadata('kernelspec', { + name: kernel.name, + display_name: spec?.display_name, + language: spec?.language + }); + } + + /** + * Whether we are currently in a series of autorestarts we have already + * notified the user about. + */ + private _autorestarting = false; + translator: ITranslator; +} + +/** + * A namespace for `NotebookPanel` statics. + */ +export namespace NotebookPanel { + /** + * Notebook config interface for NotebookPanel + */ + export interface IConfig { + /** + * Whether to automatically start the preferred kernel + */ + autoStartDefault: boolean; + /** + * A config object for cell editors + */ + editorConfig: StaticNotebook.IEditorConfig; + /** + * A config object for notebook widget + */ + notebookConfig: StaticNotebook.INotebookConfig; + /** + * Whether to shut down the kernel when closing the panel or not + */ + kernelShutdown: boolean; + } + + /** + * A content factory interface for NotebookPanel. + */ + export interface IContentFactory extends Notebook.IContentFactory { + /** + * Create a new content area for the panel. + */ + createNotebook(options: Notebook.IOptions): Notebook; + } + + /** + * The default implementation of an `IContentFactory`. + */ + export class ContentFactory + extends Notebook.ContentFactory + implements IContentFactory + { + /** + * Create a new content area for the panel. + */ + createNotebook(options: Notebook.IOptions): Notebook { + return new Notebook(options); + } + } + + /** + * The notebook renderer token. + */ + export const IContentFactory = new Token( + '@jupyterlab/notebook:IContentFactory', + `A factory object that creates new notebooks. + Use this if you want to create and host notebooks in your own UI elements.` + ); +} diff --git a/.yalc/@jupyterlab/notebook/src/searchprovider.ts b/.yalc/@jupyterlab/notebook/src/searchprovider.ts new file mode 100644 index 0000000000..1445ec49e8 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/searchprovider.ts @@ -0,0 +1,937 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Dialog, showDialog } from '@jupyterlab/apputils'; +import { + CellSearchProvider, + CodeCell, + createCellSearchProvider, + ICellModel, + MarkdownCell +} from '@jupyterlab/cells'; +import { IHighlightAdjacentMatchOptions } from '@jupyterlab/codemirror'; +import { CodeEditor } from '@jupyterlab/codeeditor'; +import { IChangedArgs } from '@jupyterlab/coreutils'; +import { + IFilter, + IFilters, + IReplaceOptions, + IReplaceOptionsSupport, + ISearchMatch, + ISearchProvider, + SearchProvider, + SelectionState +} from '@jupyterlab/documentsearch'; +import { IObservableList, IObservableMap } from '@jupyterlab/observables'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { ArrayExt } from '@lumino/algorithm'; +import { Widget } from '@lumino/widgets'; +import { CellList } from './celllist'; +import { NotebookPanel } from './panel'; +import { Notebook } from './widget'; + +/** + * Notebook document search provider + */ +export class NotebookSearchProvider extends SearchProvider { + /** + * Constructor + * + * @param widget The widget to search in + * @param translator Application translator + */ + constructor( + widget: NotebookPanel, + protected translator: ITranslator = nullTranslator + ) { + super(widget); + + this._handleHighlightsAfterActiveCellChange = + this._handleHighlightsAfterActiveCellChange.bind(this); + this.widget.model!.cells.changed.connect(this._onCellsChanged, this); + this.widget.content.activeCellChanged.connect( + this._onActiveCellChanged, + this + ); + this.widget.content.selectionChanged.connect( + this._onCellSelectionChanged, + this + ); + this.widget.content.stateChanged.connect( + this._onNotebookStateChanged, + this + ); + this._observeActiveCell(); + this._filtersChanged.connect(this._setEnginesSelectionSearchMode, this); + } + + private _onNotebookStateChanged(_: Notebook, args: IChangedArgs) { + if (args.name === 'mode') { + // Delay the update to ensure that `document.activeElement` settled. + window.setTimeout(() => { + if ( + args.newValue === 'command' && + document.activeElement?.closest('.jp-DocumentSearch-overlay') + ) { + // Do not request updating mode when user switched focus to search overlay. + return; + } + this._updateSelectionMode(); + this._filtersChanged.emit(); + }, 0); + } + } + + /** + * Report whether or not this provider has the ability to search on the given object + * + * @param domain Widget to test + * @returns Search ability + */ + static isApplicable(domain: Widget): domain is NotebookPanel { + // check to see if the CMSearchProvider can search on the + // first cell, false indicates another editor is present + return domain instanceof NotebookPanel; + } + + /** + * Instantiate a search provider for the notebook panel. + * + * #### Notes + * The widget provided is always checked using `isApplicable` before calling + * this factory. + * + * @param widget The widget to search on + * @param translator [optional] The translator object + * + * @returns The search provider on the notebook panel + */ + static createNew( + widget: NotebookPanel, + translator?: ITranslator + ): ISearchProvider { + return new NotebookSearchProvider(widget, translator); + } + + /** + * The current index of the selected match. + */ + get currentMatchIndex(): number | null { + let agg = 0; + let found = false; + for (let idx = 0; idx < this._searchProviders.length; idx++) { + const provider = this._searchProviders[idx]; + if (this._currentProviderIndex == idx) { + const localMatch = provider.currentMatchIndex; + if (localMatch === null) { + return null; + } + agg += localMatch; + found = true; + break; + } else { + agg += provider.matchesCount; + } + } + return found ? agg : null; + } + + /** + * The number of matches. + */ + get matchesCount(): number | null { + return this._searchProviders.reduce( + (sum, provider) => (sum += provider.matchesCount), + 0 + ); + } + + /** + * Set to true if the widget under search is read-only, false + * if it is editable. Will be used to determine whether to show + * the replace option. + */ + get isReadOnly(): boolean { + return this.widget?.content.model?.readOnly ?? false; + } + + /** + * Support for options adjusting replacement behavior. + */ + get replaceOptionsSupport(): IReplaceOptionsSupport { + return { + preserveCase: true + }; + } + + getSelectionState(): SelectionState { + const cellMode = this._selectionSearchMode === 'cells'; + const selectedCount = cellMode ? this._selectedCells : this._selectedLines; + return selectedCount > 1 + ? 'multiple' + : selectedCount === 1 && !cellMode + ? 'single' + : 'none'; + } + + /** + * Dispose of the resources held by the search provider. + * + * #### Notes + * If the object's `dispose` method is called more than once, all + * calls made after the first will be a no-op. + * + * #### Undefined Behavior + * It is undefined behavior to use any functionality of the object + * after it has been disposed unless otherwise explicitly noted. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + + this.widget.content.activeCellChanged.disconnect( + this._onActiveCellChanged, + this + ); + + this.widget.model?.cells.changed.disconnect(this._onCellsChanged, this); + + this.widget.content.stateChanged.disconnect( + this._onNotebookStateChanged, + this + ); + this.widget.content.selectionChanged.disconnect( + this._onCellSelectionChanged, + this + ); + this._stopObservingLastCell(); + + super.dispose(); + + const index = this.widget.content.activeCellIndex; + this.endQuery() + .then(() => { + if (!this.widget.isDisposed) { + this.widget.content.activeCellIndex = index; + } + }) + .catch(reason => { + console.error(`Fail to end search query in notebook:\n${reason}`); + }); + } + + /** + * Get the filters for the given provider. + * + * @returns The filters. + */ + getFilters(): { [key: string]: IFilter } { + const trans = this.translator.load('jupyterlab'); + + return { + output: { + title: trans.__('Search Cell Outputs'), + description: trans.__('Search in the cell outputs.'), + disabledDescription: trans.__( + 'Search in the cell outputs (not available when replace options are shown).' + ), + default: false, + supportReplace: false + }, + selection: { + title: + this._selectionSearchMode === 'cells' + ? trans._n( + 'Search in %1 Selected Cell', + 'Search in %1 Selected Cells', + this._selectedCells + ) + : trans._n( + 'Search in %1 Selected Line', + 'Search in %1 Selected Lines', + this._selectedLines + ), + description: trans.__( + 'Search only in the selected cells or text (depending on edit/command mode).' + ), + default: false, + supportReplace: true + } + }; + } + + /** + * Update the search in selection mode; it should only be called when user + * navigates the notebook (enters editing/command mode, changes selection) + * but not when the searchbox gets focused (switching the notebook to command + * mode) nor when search highlights a match (switching notebook to edit mode). + */ + private _updateSelectionMode() { + if (this._selectionLock) { + return; + } + this._selectionSearchMode = + this._selectedCells === 1 && + this.widget.content.mode === 'edit' && + this._selectedLines !== 0 + ? 'text' + : 'cells'; + } + + /** + * Get an initial query value if applicable so that it can be entered + * into the search box as an initial query + * + * @returns Initial value used to populate the search box. + */ + getInitialQuery(): string { + // Get whatever is selected in the browser window. + return window.getSelection()?.toString() || ''; + } + + /** + * Clear currently highlighted match. + */ + async clearHighlight(): Promise { + this._selectionLock = true; + if ( + this._currentProviderIndex !== null && + this._currentProviderIndex < this._searchProviders.length + ) { + await this._searchProviders[this._currentProviderIndex].clearHighlight(); + this._currentProviderIndex = null; + } + this._selectionLock = false; + } + + /** + * Highlight the next match. + * + * @param loop Whether to loop within the matches list. + * + * @returns The next match if available. + */ + async highlightNext( + loop: boolean = true, + options?: IHighlightAdjacentMatchOptions + ): Promise { + const match = await this._stepNext(false, loop, options); + return match ?? undefined; + } + + /** + * Highlight the previous match. + * + * @param loop Whether to loop within the matches list. + * + * @returns The previous match if available. + */ + async highlightPrevious( + loop: boolean = true, + options?: IHighlightAdjacentMatchOptions + ): Promise { + const match = await this._stepNext(true, loop, options); + return match ?? undefined; + } + + /** + * Search for a regular expression with optional filters. + * + * @param query A regular expression to test for + * @param filters Filter parameters to pass to provider + * + */ + async startQuery( + query: RegExp, + filters: IFilters | undefined + ): Promise { + if (!this.widget) { + return; + } + await this.endQuery(); + this._searchActive = true; + let cells = this.widget.content.widgets; + + this._query = query; + this._filters = { + output: false, + selection: false, + ...(filters ?? {}) + }; + + this._onSelection = this._filters.selection; + + const currentProviderIndex = this.widget.content.activeCellIndex; + + // For each cell, create a search provider + this._searchProviders = await Promise.all( + cells.map(async (cell, index) => { + const cellSearchProvider = createCellSearchProvider(cell); + + await cellSearchProvider.setIsActive( + !this._filters!.selection || + this.widget.content.isSelectedOrActive(cell) + ); + + if ( + this._onSelection && + this._selectionSearchMode === 'text' && + index === currentProviderIndex + ) { + if (this._textSelection) { + await cellSearchProvider.setSearchSelection(this._textSelection); + } + } + + await cellSearchProvider.startQuery(query, this._filters); + + return cellSearchProvider; + }) + ); + this._currentProviderIndex = currentProviderIndex; + + // We do not want to show the first "current" closest to cursor as depending + // on which way the user dragged the selection it would be: + // - the first or last match when searching in selection + // - the next match when starting search using ctrl + f + // `scroll` and `select` are disabled because `startQuery` is also used as + // "restartQuery" after each text change and if those were enabled, we would + // steal the cursor. + await this.highlightNext(true, { + from: 'selection-start', + scroll: false, + select: false + }); + + return Promise.resolve(); + } + + /** + * Stop the search and clear all internal state. + */ + async endQuery(): Promise { + await Promise.all( + this._searchProviders.map(provider => { + return provider.endQuery().then(() => { + provider.dispose(); + }); + }) + ); + + this._searchActive = false; + this._searchProviders.length = 0; + this._currentProviderIndex = null; + } + + /** + * Replace the currently selected match with the provided text + * + * @param newText The replacement text. + * @param loop Whether to loop within the matches list. + * + * @returns A promise that resolves with a boolean indicating whether a replace occurred. + */ + async replaceCurrentMatch( + newText: string, + loop = true, + options?: IReplaceOptions + ): Promise { + let replaceOccurred = false; + + const unrenderMarkdownCell = async ( + highlightNext = false + ): Promise => { + // Unrendered markdown cell + const activeCell = this.widget?.content.activeCell; + if ( + activeCell?.model.type === 'markdown' && + (activeCell as MarkdownCell).rendered + ) { + (activeCell as MarkdownCell).rendered = false; + if (highlightNext) { + await this.highlightNext(loop); + } + } + }; + + if (this._currentProviderIndex !== null) { + await unrenderMarkdownCell(); + + const searchEngine = this._searchProviders[this._currentProviderIndex]; + replaceOccurred = await searchEngine.replaceCurrentMatch( + newText, + false, + options + ); + if (searchEngine.currentMatchIndex === null) { + // switch to next cell + await this.highlightNext(loop, { from: 'previous-match' }); + } + } + + // TODO: markdown unrendering/highlighting sequence is likely incorrect + // Force highlighting the first hit in the unrendered cell + await unrenderMarkdownCell(true); + return replaceOccurred; + } + + /** + * Replace all matches in the notebook with the provided text + * + * @param newText The replacement text. + * + * @returns A promise that resolves with a boolean indicating whether a replace occurred. + */ + async replaceAllMatches( + newText: string, + options?: IReplaceOptions + ): Promise { + const replacementOccurred = await Promise.all( + this._searchProviders.map(provider => { + return provider.replaceAllMatches(newText, options); + }) + ); + return replacementOccurred.includes(true); + } + + async validateFilter(name: string, value: boolean): Promise { + if (name !== 'output') { + // Bail early + return value; + } + + // If value is true and some cells have never been rendered, ask confirmation. + if ( + value && + this.widget.content.widgets.some( + w => w instanceof CodeCell && w.isPlaceholder() + ) + ) { + const trans = this.translator.load('jupyterlab'); + + const reply = await showDialog({ + title: trans.__('Confirmation'), + body: trans.__( + 'Searching outputs requires you to run all cells and render their outputs. Are you sure you want to search in the cell outputs?' + ), + buttons: [ + Dialog.cancelButton({ label: trans.__('Cancel') }), + Dialog.okButton({ label: trans.__('Ok') }) + ] + }); + if (reply.button.accept) { + this.widget.content.widgets.forEach((w, i) => { + if (w instanceof CodeCell && w.isPlaceholder()) { + this.widget.content.renderCellOutputs(i); + } + }); + } else { + return false; + } + } + + return value; + } + + private _addCellProvider(index: number) { + const cell = this.widget.content.widgets[index]; + const cellSearchProvider = createCellSearchProvider(cell); + + ArrayExt.insert(this._searchProviders, index, cellSearchProvider); + + void cellSearchProvider + .setIsActive( + !(this._filters?.selection ?? false) || + this.widget.content.isSelectedOrActive(cell) + ) + .then(() => { + if (this._searchActive) { + void cellSearchProvider.startQuery(this._query, this._filters); + } + }); + } + + private _removeCellProvider(index: number) { + const provider = ArrayExt.removeAt(this._searchProviders, index); + provider?.dispose(); + } + + private async _onCellsChanged( + cells: CellList, + changes: IObservableList.IChangedArgs + ): Promise { + switch (changes.type) { + case 'add': + changes.newValues.forEach((model, index) => { + this._addCellProvider(changes.newIndex + index); + }); + break; + case 'move': + ArrayExt.move( + this._searchProviders, + changes.oldIndex, + changes.newIndex + ); + break; + case 'remove': + for (let index = 0; index < changes.oldValues.length; index++) { + this._removeCellProvider(changes.oldIndex); + } + break; + case 'set': + changes.newValues.forEach((model, index) => { + this._addCellProvider(changes.newIndex + index); + this._removeCellProvider(changes.newIndex + index + 1); + }); + + break; + } + this._stateChanged.emit(); + } + + private async _stepNext( + reverse = false, + loop = false, + options?: IHighlightAdjacentMatchOptions + ): Promise { + const activateNewMatch = async (match: ISearchMatch) => { + const shouldScroll = options?.scroll ?? true; + if (!shouldScroll) { + // do not activate the match if scrolling was disabled + return; + } + + this._selectionLock = true; + if (this.widget.content.activeCellIndex !== this._currentProviderIndex!) { + this.widget.content.activeCellIndex = this._currentProviderIndex!; + } + if (this.widget.content.activeCellIndex === -1) { + console.warn('No active cell (no cells or no model), aborting search'); + this._selectionLock = false; + return; + } + const activeCell = this.widget.content.activeCell!; + + if (!activeCell.inViewport) { + try { + await this.widget.content.scrollToItem(this._currentProviderIndex!); + } catch (error) { + // no-op + } + } + + // Unhide cell + if (activeCell.inputHidden) { + activeCell.inputHidden = false; + } + + if (!activeCell.inViewport) { + this._selectionLock = false; + // It will not be possible the cell is not in the view + return; + } + + await activeCell.ready; + const editor = activeCell.editor!; + editor.revealPosition(editor.getPositionAt(match.position)!); + this._selectionLock = false; + }; + + if (this._currentProviderIndex === null) { + this._currentProviderIndex = this.widget.content.activeCellIndex; + } + + // When going to previous match in cell mode and there is no current we + // want to skip the active cell and go to the previous cell; in edit mode + // the appropriate behaviour is induced by searching from nearest cursor. + if (reverse && this.widget.content.mode === 'command') { + const searchEngine = this._searchProviders[this._currentProviderIndex]; + const currentMatch = searchEngine.getCurrentMatch(); + if (!currentMatch) { + this._currentProviderIndex -= 1; + } + if (loop) { + this._currentProviderIndex = + (this._currentProviderIndex + this._searchProviders.length) % + this._searchProviders.length; + } + } + + // If we're looking for the next match after the previous match, + // and we've reached the end of the current cell, start at the next one, if possible + const from = options?.from ?? ''; + const atEndOfCurrentCell = + from === 'previous-match' && + this._searchProviders[this._currentProviderIndex].currentMatchIndex === + null; + + const startIndex = this._currentProviderIndex; + // If we need to move to the next cell or loop, reset the position of the current search provider. + if (atEndOfCurrentCell) { + void this._searchProviders[this._currentProviderIndex].clearHighlight(); + } + + // If we're at the end of the last cell in the provider list and we need to loop, do so + if ( + loop && + atEndOfCurrentCell && + this._currentProviderIndex + 1 >= this._searchProviders.length + ) { + this._currentProviderIndex = 0; + } else { + this._currentProviderIndex += atEndOfCurrentCell ? 1 : 0; + } + do { + const searchEngine = this._searchProviders[this._currentProviderIndex]; + + const match = reverse + ? await searchEngine.highlightPrevious(false, options) + : await searchEngine.highlightNext(false, options); + + if (match) { + await activateNewMatch(match); + return match; + } else { + this._currentProviderIndex = + this._currentProviderIndex + (reverse ? -1 : 1); + + if (loop) { + this._currentProviderIndex = + (this._currentProviderIndex + this._searchProviders.length) % + this._searchProviders.length; + } + } + } while ( + loop + ? // We looped on all cells, no hit found + this._currentProviderIndex !== startIndex + : 0 <= this._currentProviderIndex && + this._currentProviderIndex < this._searchProviders.length + ); + + if (loop) { + // try the first provider again + const searchEngine = this._searchProviders[startIndex]; + const match = reverse + ? await searchEngine.highlightPrevious(false, options) + : await searchEngine.highlightNext(false, options); + if (match) { + await activateNewMatch(match); + return match; + } + } + + this._currentProviderIndex = null; + return null; + } + + private async _onActiveCellChanged() { + if (this._delayedActiveCellChangeHandler !== null) { + // Prevent handler from running twice if active cell is changed twice + // within the same task of the event loop. + clearTimeout(this._delayedActiveCellChangeHandler); + this._delayedActiveCellChangeHandler = null; + } + + if (this.widget.content.activeCellIndex !== this._currentProviderIndex) { + // At this time we cannot handle the change of active cell, because + // `activeCellChanged` is also emitted in the middle of cell selection + // change, and if selection is getting extended, we do not want to clear + // highlights just to re-apply them shortly after, which has side effects + // impacting the functionality and performance. + this._delayedActiveCellChangeHandler = window.setTimeout(() => { + this.delayedActiveCellChangeHandlerReady = + this._handleHighlightsAfterActiveCellChange(); + }, 0); + } + this._observeActiveCell(); + } + + private async _handleHighlightsAfterActiveCellChange() { + if (this._onSelection) { + const previousProviderCell = + this._currentProviderIndex !== null && + this._currentProviderIndex < this.widget.content.widgets.length + ? this.widget.content.widgets[this._currentProviderIndex] + : null; + + const previousProviderInCurrentSelection = + previousProviderCell && + this.widget.content.isSelectedOrActive(previousProviderCell); + + if (!previousProviderInCurrentSelection) { + await this._updateCellSelection(); + // Clear highlight from previous provider + await this.clearHighlight(); + // If we are searching in all cells, we should not change the active + // provider when switching active cell to preserve current match; + // if we are searching within selected cells we should update + this._currentProviderIndex = this.widget.content.activeCellIndex; + } + } + + await this._ensureCurrentMatch(); + } + + /** + * If there are results but no match is designated as current, + * mark a result as current and highlight it. + */ + private async _ensureCurrentMatch() { + if (this._currentProviderIndex !== null) { + const searchEngine = this._searchProviders[this._currentProviderIndex]; + if (!searchEngine) { + // This can happen when `startQuery()` has not finished yet. + return; + } + const currentMatch = searchEngine.getCurrentMatch(); + if (!currentMatch && this.matchesCount) { + // Select a match as current by highlighting next (with looping) from + // the selection start, to prevent "current" match from jumping around. + await this.highlightNext(true, { + from: 'start', + scroll: false, + select: false + }); + } + } + } + + private _observeActiveCell() { + const editor = this.widget.content.activeCell?.editor; + if (!editor) { + return; + } + this._stopObservingLastCell(); + + editor.model.selections.changed.connect(this._setSelectedLines, this); + this._editorSelectionsObservable = editor.model.selections; + } + + private _stopObservingLastCell() { + if (this._editorSelectionsObservable) { + this._editorSelectionsObservable.changed.disconnect( + this._setSelectedLines, + this + ); + } + } + + private _setSelectedLines() { + const editor = this.widget.content.activeCell?.editor; + if (!editor) { + return; + } + + const selection = editor.getSelection(); + const { start, end } = selection; + + const newLines = + end.line === start.line && end.column === start.column + ? 0 + : end.line - start.line + 1; + + this._textSelection = selection; + + if (newLines !== this._selectedLines) { + this._selectedLines = newLines; + this._updateSelectionMode(); + } + this._filtersChanged.emit(); + } + + private _textSelection: CodeEditor.IRange | null = null; + + /** + * Set whether the engines should search within selection only or full text. + */ + private async _setEnginesSelectionSearchMode() { + let textMode: boolean; + + if (!this._onSelection) { + // When search in selection is off we always search full text + textMode = false; + } else { + // When search in selection is off we either search in full cells + // (toggling off isActive flag on search engines of non-selected cells) + // or in selected text of the active cell. + textMode = this._selectionSearchMode === 'text'; + } + + if (this._selectionLock) { + return; + } + + // Clear old selection restrictions or if relevant, set current restrictions for active provider. + await Promise.all( + this._searchProviders.map((provider, index) => { + const isCurrent = this.widget.content.activeCellIndex === index; + provider.setProtectSelection(isCurrent && this._onSelection); + return provider.setSearchSelection( + isCurrent && textMode ? this._textSelection : null + ); + }) + ); + } + + private async _onCellSelectionChanged() { + if (this._delayedActiveCellChangeHandler !== null) { + // Avoid race condition due to `activeCellChanged` and `selectionChanged` + // signals firing in short sequence when selection gets extended, with + // handling of the former having potential to undo selection set by the latter. + clearTimeout(this._delayedActiveCellChangeHandler); + this._delayedActiveCellChangeHandler = null; + } + await this._updateCellSelection(); + if (this._currentProviderIndex === null) { + // For consistency we set the first cell in selection as current provider. + const firstSelectedCellIndex = this.widget.content.widgets.findIndex( + cell => this.widget.content.isSelectedOrActive(cell) + ); + this._currentProviderIndex = firstSelectedCellIndex; + } + await this._ensureCurrentMatch(); + } + + private async _updateCellSelection() { + const cells = this.widget.content.widgets; + let selectedCells = 0; + await Promise.all( + cells.map(async (cell, index) => { + const provider = this._searchProviders[index]; + const isSelected = this.widget.content.isSelectedOrActive(cell); + if (isSelected) { + selectedCells += 1; + } + if (provider && this._onSelection) { + await provider.setIsActive(isSelected); + } + }) + ); + + if (selectedCells !== this._selectedCells) { + this._selectedCells = selectedCells; + this._updateSelectionMode(); + } + + this._filtersChanged.emit(); + } + + // used for testing only + protected delayedActiveCellChangeHandlerReady: Promise; + private _currentProviderIndex: number | null = null; + private _delayedActiveCellChangeHandler: number | null = null; + private _filters: IFilters | undefined; + private _onSelection = false; + private _selectedCells: number = 1; + private _selectedLines: number = 0; + private _query: RegExp | null = null; + private _searchProviders: CellSearchProvider[] = []; + private _editorSelectionsObservable: IObservableMap< + CodeEditor.ITextSelection[] + > | null = null; + private _selectionSearchMode: 'cells' | 'text' = 'cells'; + private _selectionLock: boolean = false; + private _searchActive: boolean = false; +} diff --git a/.yalc/@jupyterlab/notebook/src/testutils.ts b/.yalc/@jupyterlab/notebook/src/testutils.ts new file mode 100644 index 0000000000..a8bbf6b4c1 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/testutils.ts @@ -0,0 +1,281 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + Clipboard, + ISessionContext, + SessionContextDialogs +} from '@jupyterlab/apputils'; +import { Cell, CodeCellModel } from '@jupyterlab/cells'; +import { CodeEditorWrapper, IEditorServices } from '@jupyterlab/codeeditor'; +import { + CodeMirrorEditorFactory, + CodeMirrorMimeTypeService, + EditorExtensionRegistry, + EditorLanguageRegistry, + ybinding +} from '@jupyterlab/codemirror'; +import { Context, DocumentRegistry } from '@jupyterlab/docregistry'; +import { INotebookContent } from '@jupyterlab/nbformat'; +import { RenderMimeRegistry } from '@jupyterlab/rendermime'; +import { + DEFAULT_OUTPUTS as TEST_OUTPUTS, + defaultRenderMime as testRenderMime +} from '@jupyterlab/rendermime/lib/testutils'; +import { ServiceManager } from '@jupyterlab/services'; +import { ServiceManagerMock } from '@jupyterlab/services/lib/testutils'; +import { UUID } from '@lumino/coreutils'; +import * as defaultContent from './default.json'; +import { INotebookModel, NotebookModel } from './model'; +import { NotebookModelFactory } from './modelfactory'; +import { NotebookPanel } from './panel'; +import { Notebook, StaticNotebook } from './widget'; +import { NotebookHistory } from './history'; +import { NotebookWidgetFactory } from './widgetfactory'; + +export const DEFAULT_CONTENT: INotebookContent = defaultContent; + +/** + * Create and initialize context for a notebook. + */ +export async function initNotebookContext( + options: { + path?: string; + manager?: ServiceManager.IManager; + startKernel?: boolean; + } = {} +): Promise> { + const factory = Private.notebookFactory; + const manager = options.manager || Private.getManager(); + const path = options.path || UUID.uuid4() + '.ipynb'; + console.debug( + 'Initializing notebook context for', + path, + 'kernel:', + options.startKernel + ); + + const startKernel = + options.startKernel === undefined ? false : options.startKernel; + await manager.ready; + + const context = new Context({ + sessionDialogs: new SessionContextDialogs(), + manager, + factory, + path, + kernelPreference: { + shouldStart: startKernel, + canStart: startKernel, + shutdownOnDispose: true, + name: manager.kernelspecs.specs?.default + } + }); + await context.initialize(true); + + if (startKernel) { + await context.sessionContext.initialize(); + await context.sessionContext.session?.kernel?.info; + } + + return context; +} + +/** + * The default notebook content. + */ + +export namespace NBTestUtils { + export const DEFAULT_OUTPUTS = TEST_OUTPUTS; + + export const defaultEditorConfig = { ...StaticNotebook.defaultEditorConfig }; + + const editorServices: IEditorServices = (function () { + const languages = new EditorLanguageRegistry(); + EditorLanguageRegistry.getDefaultLanguages() + .filter(lang => ['Python'].includes(lang.name)) + .forEach(lang => { + languages.addLanguage(lang); + }); + const extensions = new EditorExtensionRegistry(); + EditorExtensionRegistry.getDefaultExtensions() + .filter(ext => ['autoClosingBrackets'].includes(ext.name)) + .forEach(ext => { + extensions.addExtension(ext); + }); + extensions.addExtension({ + name: 'binding', + factory: ({ model }) => + EditorExtensionRegistry.createImmutableExtension( + ybinding({ + ytext: (model.sharedModel as any).ysource, + undoManager: (model.sharedModel as any).undoManager ?? undefined + }) + ) + }); + const factoryService = new CodeMirrorEditorFactory({ + languages, + extensions + }); + const mimeTypeService = new CodeMirrorMimeTypeService(languages); + return { + factoryService, + mimeTypeService + }; + })(); + + export const editorFactory = + editorServices.factoryService.newInlineEditor.bind( + editorServices.factoryService + ); + + export const mimeTypeService = editorServices.mimeTypeService; + + /** + * Get a copy of the default rendermime instance. + */ + export function defaultRenderMime(): RenderMimeRegistry { + return testRenderMime(); + } + + export const clipboard = Clipboard.getInstance(); + + /** + * Create a base cell content factory. + */ + export function createBaseCellFactory(): Cell.IContentFactory { + return new Cell.ContentFactory({ editorFactory }); + } + + /** + * Create a new code cell content factory. + */ + export function createCodeCellFactory(): Cell.IContentFactory { + return new Cell.ContentFactory({ editorFactory }); + } + + /** + * Create a cell editor widget. + */ + export function createCellEditor(model?: CodeCellModel): CodeEditorWrapper { + return new CodeEditorWrapper({ + model: model ?? new CodeCellModel(), + factory: editorFactory + }); + } + + /** + * Create a default notebook content factory. + */ + export function createNotebookFactory(): Notebook.IContentFactory { + return new Notebook.ContentFactory({ editorFactory }); + } + + /** + * Create a default notebook panel content factory. + */ + export function createNotebookPanelFactory(): NotebookPanel.IContentFactory { + return new NotebookPanel.ContentFactory({ editorFactory }); + } + + /** + * Create a notebook widget. + */ + export function createNotebook(sessionContext?: ISessionContext): Notebook { + let history = sessionContext + ? { + kernelHistory: new NotebookHistory({ sessionContext: sessionContext }) + } + : {}; + return new Notebook({ + rendermime: defaultRenderMime(), + contentFactory: createNotebookFactory(), + mimeTypeService, + notebookConfig: { + ...StaticNotebook.defaultNotebookConfig, + windowingMode: 'none' + }, + ...history + }); + } + + /** + * Create a notebook panel widget. + */ + export function createNotebookPanel( + context: Context + ): NotebookPanel { + return new NotebookPanel({ + content: createNotebook(context.sessionContext), + context + }); + } + + /** + * Populate a notebook with default content. + */ + export function populateNotebook(notebook: Notebook): void { + const model = new NotebookModel(); + model.fromJSON(DEFAULT_CONTENT); + notebook.model = model; + } + + export function createNotebookWidgetFactory( + toolbarFactory?: (widget: NotebookPanel) => DocumentRegistry.IToolbarItem[] + ): NotebookWidgetFactory { + return new NotebookWidgetFactory({ + name: 'notebook', + fileTypes: ['notebook'], + rendermime: defaultRenderMime(), + toolbarFactory, + contentFactory: createNotebookPanelFactory(), + mimeTypeService: mimeTypeService, + editorConfig: defaultEditorConfig + }); + } + + /** + * Create a context for a file. + */ + export async function createMockContext( + startKernel = false + ): Promise> { + const path = UUID.uuid4() + '.txt'; + const manager = new ServiceManagerMock(); + const factory = new NotebookModelFactory({}); + + const context = new Context({ + sessionDialogs: new SessionContextDialogs(), + manager, + factory, + path, + kernelPreference: { + shouldStart: startKernel, + canStart: startKernel, + autoStartDefault: startKernel + } + }); + await context.initialize(true); + await context.sessionContext.initialize(); + return context; + } +} + +/** + * A namespace for private data. + */ +namespace Private { + let manager: ServiceManager; + + export const notebookFactory = new NotebookModelFactory(); + + /** + * Get or create the service manager singleton. + */ + export function getManager(): ServiceManager { + if (!manager) { + manager = new ServiceManager({ standby: 'never' }); + } + return manager; + } +} diff --git a/.yalc/@jupyterlab/notebook/src/toc.ts b/.yalc/@jupyterlab/notebook/src/toc.ts new file mode 100644 index 0000000000..585e18b535 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/toc.ts @@ -0,0 +1,813 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Cell, CodeCell, ICellModel, MarkdownCell } from '@jupyterlab/cells'; +import { IMarkdownParser, IRenderMime } from '@jupyterlab/rendermime'; +import { + TableOfContents, + TableOfContentsFactory, + TableOfContentsModel, + TableOfContentsUtils +} from '@jupyterlab/toc'; +import { KernelError, NotebookActions } from './actions'; +import { NotebookPanel } from './panel'; +import { INotebookTracker } from './tokens'; +import { Notebook } from './widget'; + +/** + * Cell running status + */ +export enum RunningStatus { + /** + * Cell is idle + */ + Idle = -1, + /** + * Cell execution is unsuccessful + */ + Error = -0.5, + /** + * Cell execution is scheduled + */ + Scheduled = 0, + /** + * Cell is running + */ + Running = 1 +} + +/** + * Interface describing a notebook cell heading. + */ +export interface INotebookHeading extends TableOfContents.IHeading { + /** + * Reference to a notebook cell. + */ + cellRef: Cell; + + /** + * Running status of the cells in the heading + */ + isRunning: RunningStatus; + + /** + * Index of the output containing the heading + */ + outputIndex?: number; + + /** + * Type of heading + */ + type: Cell.HeadingType; +} + +/** + * Table of content model for Notebook files. + */ +export class NotebookToCModel extends TableOfContentsModel< + INotebookHeading, + NotebookPanel +> { + /** + * Constructor + * + * @param widget The widget to search in + * @param parser Markdown parser + * @param sanitizer Sanitizer + * @param configuration Default model configuration + */ + constructor( + widget: NotebookPanel, + protected parser: IMarkdownParser | null, + protected sanitizer: IRenderMime.ISanitizer, + configuration?: TableOfContents.IConfig + ) { + super(widget, configuration); + this._runningCells = new Array(); + this._errorCells = new Array(); + this._cellToHeadingIndex = new WeakMap(); + + void widget.context.ready.then(() => { + // Load configuration from metadata + this.setConfiguration({}); + }); + + this.widget.context.model.metadataChanged.connect( + this.onMetadataChanged, + this + ); + this.widget.content.activeCellChanged.connect( + this.onActiveCellChanged, + this + ); + NotebookActions.executionScheduled.connect(this.onExecutionScheduled, this); + NotebookActions.executed.connect(this.onExecuted, this); + NotebookActions.outputCleared.connect(this.onOutputCleared, this); + this.headingsChanged.connect(this.onHeadingsChanged, this); + } + + /** + * Type of document supported by the model. + * + * #### Notes + * A `data-document-type` attribute with this value will be set + * on the tree view `.jp-TableOfContents-content[data-document-type="..."]` + */ + get documentType(): string { + return 'notebook'; + } + + /** + * Whether the model gets updated even if the table of contents panel + * is hidden or not. + */ + protected get isAlwaysActive(): boolean { + return true; + } + + /** + * List of configuration options supported by the model. + */ + get supportedOptions(): (keyof TableOfContents.IConfig)[] { + return [ + 'baseNumbering', + 'maximalDepth', + 'numberingH1', + 'numberHeaders', + 'includeOutput', + 'syncCollapseState' + ]; + } + + /** + * Get the headings of a given cell. + * + * @param cell Cell + * @returns The associated headings + */ + getCellHeadings(cell: Cell): INotebookHeading[] { + const headings = new Array(); + let headingIndex = this._cellToHeadingIndex.get(cell); + + if (headingIndex !== undefined) { + const candidate = this.headings[headingIndex]; + headings.push(candidate); + while ( + this.headings[headingIndex - 1] && + this.headings[headingIndex - 1].cellRef === candidate.cellRef + ) { + headingIndex--; + headings.unshift(this.headings[headingIndex]); + } + } + + return headings; + } + + /** + * Dispose the object + */ + dispose(): void { + if (this.isDisposed) { + return; + } + + this.headingsChanged.disconnect(this.onHeadingsChanged, this); + this.widget.context?.model?.metadataChanged.disconnect( + this.onMetadataChanged, + this + ); + this.widget.content?.activeCellChanged.disconnect( + this.onActiveCellChanged, + this + ); + NotebookActions.executionScheduled.disconnect( + this.onExecutionScheduled, + this + ); + NotebookActions.executed.disconnect(this.onExecuted, this); + NotebookActions.outputCleared.disconnect(this.onOutputCleared, this); + + this._runningCells.length = 0; + this._errorCells.length = 0; + + super.dispose(); + } + + /** + * Model configuration setter. + * + * @param c New configuration + */ + setConfiguration(c: Partial): void { + // Ensure configuration update + const metadataConfig = this.loadConfigurationFromMetadata(); + super.setConfiguration({ ...this.configuration, ...metadataConfig, ...c }); + } + + /** + * Callback on heading collapse. + * + * @param options.heading The heading to change state (all headings if not provided) + * @param options.collapsed The new collapsed status (toggle existing status if not provided) + */ + toggleCollapse(options: { + heading?: INotebookHeading; + collapsed?: boolean; + }): void { + super.toggleCollapse(options); + this.updateRunningStatus(this.headings); + } + + /** + * Produce the headings for a document. + * + * @returns The list of new headings or `null` if nothing needs to be updated. + */ + protected getHeadings(): Promise { + const cells = this.widget.content.widgets; + const headings: INotebookHeading[] = []; + const documentLevels = new Array(); + + // Generate headings by iterating through all notebook cells... + for (let i = 0; i < cells.length; i++) { + const cell: Cell = cells[i]; + const model = cell.model; + + switch (model.type) { + case 'code': { + // Collapsing cells is incompatible with output headings + if ( + !this.configuration.syncCollapseState && + this.configuration.includeOutput + ) { + headings.push( + ...TableOfContentsUtils.filterHeadings( + cell.headings, + this.configuration, + documentLevels + ).map(heading => { + return { + ...heading, + cellRef: cell, + collapsed: false, + isRunning: RunningStatus.Idle + }; + }) + ); + } + + break; + } + case 'markdown': { + const cellHeadings = TableOfContentsUtils.filterHeadings( + cell.headings, + this.configuration, + documentLevels + ).map((heading, index) => { + return { + ...heading, + cellRef: cell, + collapsed: false, + isRunning: RunningStatus.Idle + }; + }); + // If there are multiple headings, only collapse the highest heading (i.e. minimal level) + // consistent with the cell.headingInfo + if ( + this.configuration.syncCollapseState && + (cell as MarkdownCell).headingCollapsed + ) { + const minLevel = Math.min(...cellHeadings.map(h => h.level)); + const minHeading = cellHeadings.find(h => h.level === minLevel); + minHeading!.collapsed = (cell as MarkdownCell).headingCollapsed; + } + headings.push(...cellHeadings); + break; + } + } + + if (headings.length > 0) { + this._cellToHeadingIndex.set(cell, headings.length - 1); + } + } + this.updateRunningStatus(headings); + return Promise.resolve(headings); + } + + /** + * Test if two headings are equal or not. + * + * @param heading1 First heading + * @param heading2 Second heading + * @returns Whether the headings are equal. + */ + protected override isHeadingEqual( + heading1: INotebookHeading, + heading2: INotebookHeading + ): boolean { + return ( + super.isHeadingEqual(heading1, heading2) && + heading1.cellRef === heading2.cellRef + ); + } + + /** + * Read table of content configuration from notebook metadata. + * + * @returns ToC configuration from metadata + */ + protected loadConfigurationFromMetadata(): Partial { + const nbModel = this.widget.content.model; + const newConfig: Partial = {}; + + if (nbModel) { + for (const option in this.configMetadataMap) { + const keys = this.configMetadataMap[option]; + for (const k of keys) { + let key = k; + const negate = key[0] === '!'; + if (negate) { + key = key.slice(1); + } + + const keyPath = key.split('/'); + let value = nbModel.getMetadata(keyPath[0]); + for (let p = 1; p < keyPath.length; p++) { + value = (value ?? {})[keyPath[p]]; + } + + if (value !== undefined) { + if (typeof value === 'boolean' && negate) { + value = !value; + } + newConfig[option] = value; + } + } + } + } + return newConfig; + } + + protected onActiveCellChanged( + notebook: Notebook, + cell: Cell + ): void { + // Highlight the first title as active (if multiple titles are in the same cell) + const activeHeading = this.getCellHeadings(cell)[0]; + this.setActiveHeading(activeHeading ?? null, false); + } + + protected onHeadingsChanged(): void { + if (this.widget.content.activeCell) { + this.onActiveCellChanged( + this.widget.content, + this.widget.content.activeCell + ); + } + } + + protected onExecuted( + _: unknown, + args: { + notebook: Notebook; + cell: Cell; + success: boolean; + error: KernelError | null; + } + ): void { + this._runningCells.forEach((cell, index) => { + if (cell === args.cell) { + this._runningCells.splice(index, 1); + + const headingIndex = this._cellToHeadingIndex.get(cell); + if (headingIndex !== undefined) { + const heading = this.headings[headingIndex]; + // when the execution is not successful but errorName is undefined, + // the execution is interrupted by previous cells + if (args.success || args.error?.errorName === undefined) { + heading.isRunning = RunningStatus.Idle; + return; + } + heading.isRunning = RunningStatus.Error; + if (!this._errorCells.includes(cell)) { + this._errorCells.push(cell); + } + } + } + }); + + this.updateRunningStatus(this.headings); + this.stateChanged.emit(); + } + + protected onExecutionScheduled( + _: unknown, + args: { notebook: Notebook; cell: Cell } + ): void { + if (!this._runningCells.includes(args.cell)) { + this._runningCells.push(args.cell); + } + this._errorCells.forEach((cell, index) => { + if (cell === args.cell) { + this._errorCells.splice(index, 1); + } + }); + + this.updateRunningStatus(this.headings); + this.stateChanged.emit(); + } + + protected onOutputCleared( + _: unknown, + args: { notebook: Notebook; cell: Cell } + ): void { + this._errorCells.forEach((cell, index) => { + if (cell === args.cell) { + this._errorCells.splice(index, 1); + + const headingIndex = this._cellToHeadingIndex.get(cell); + if (headingIndex !== undefined) { + const heading = this.headings[headingIndex]; + heading.isRunning = RunningStatus.Idle; + } + } + }); + this.updateRunningStatus(this.headings); + this.stateChanged.emit(); + } + + protected onMetadataChanged(): void { + this.setConfiguration({}); + } + + protected updateRunningStatus(headings: INotebookHeading[]): void { + // Update isRunning + this._runningCells.forEach((cell, index) => { + const headingIndex = this._cellToHeadingIndex.get(cell); + if (headingIndex !== undefined) { + const heading = this.headings[headingIndex]; + // Running is prioritized over Scheduled, so if a heading is + // running don't change status + if (heading.isRunning !== RunningStatus.Running) { + heading.isRunning = + index > 0 ? RunningStatus.Scheduled : RunningStatus.Running; + } + } + }); + + this._errorCells.forEach((cell, index) => { + const headingIndex = this._cellToHeadingIndex.get(cell); + if (headingIndex !== undefined) { + const heading = this.headings[headingIndex]; + // Running and Scheduled are prioritized over Error, so only if + // a heading is idle will it be set to Error + if (heading.isRunning === RunningStatus.Idle) { + heading.isRunning = RunningStatus.Error; + } + } + }); + + let globalIndex = 0; + while (globalIndex < headings.length) { + const heading = headings[globalIndex]; + globalIndex++; + if (heading.collapsed) { + const maxIsRunning = Math.max( + heading.isRunning, + getMaxIsRunning(headings, heading.level) + ); + heading.dataset = { + ...heading.dataset, + 'data-running': maxIsRunning.toString() + }; + } else { + heading.dataset = { + ...heading.dataset, + 'data-running': heading.isRunning.toString() + }; + } + } + + function getMaxIsRunning( + headings: INotebookHeading[], + collapsedLevel: number + ): RunningStatus { + let maxIsRunning = RunningStatus.Idle; + + while (globalIndex < headings.length) { + const heading = headings[globalIndex]; + heading.dataset = { + ...heading.dataset, + 'data-running': heading.isRunning.toString() + }; + + if (heading.level > collapsedLevel) { + globalIndex++; + maxIsRunning = Math.max(heading.isRunning, maxIsRunning); + if (heading.collapsed) { + maxIsRunning = Math.max( + maxIsRunning, + getMaxIsRunning(headings, heading.level) + ); + heading.dataset = { + ...heading.dataset, + 'data-running': maxIsRunning.toString() + }; + } + } else { + break; + } + } + + return maxIsRunning; + } + } + + /** + * Mapping between configuration options and notebook metadata. + * + * If it starts with `!`, the boolean value of the configuration option is + * opposite to the one stored in metadata. + * If it contains `/`, the metadata data is nested. + */ + protected configMetadataMap: { + [k: keyof TableOfContents.IConfig]: string[]; + } = { + numberHeaders: ['toc-autonumbering', 'toc/number_sections'], + numberingH1: ['!toc/skip_h1_title'], + baseNumbering: ['toc/base_numbering'] + }; + + private _runningCells: Cell[]; + private _errorCells: Cell[]; + private _cellToHeadingIndex: WeakMap; +} + +/** + * Table of content model factory for Notebook files. + */ +export class NotebookToCFactory extends TableOfContentsFactory { + /** + * Constructor + * + * @param tracker Widget tracker + * @param parser Markdown parser + * @param sanitizer Sanitizer + */ + constructor( + tracker: INotebookTracker, + protected parser: IMarkdownParser | null, + protected sanitizer: IRenderMime.ISanitizer + ) { + super(tracker); + } + + /** + * Whether to scroll the active heading to the top + * of the document or not. + */ + get scrollToTop(): boolean { + return this._scrollToTop; + } + set scrollToTop(v: boolean) { + this._scrollToTop = v; + } + + /** + * Create a new table of contents model for the widget + * + * @param widget - widget + * @param configuration - Table of contents configuration + * @returns The table of contents model + */ + protected _createNew( + widget: NotebookPanel, + configuration?: TableOfContents.IConfig + ): TableOfContentsModel { + const model = new NotebookToCModel( + widget, + this.parser, + this.sanitizer, + configuration + ); + + // Connect model signals to notebook panel + + let headingToElement = new WeakMap(); + + const onActiveHeadingChanged = ( + model: NotebookToCModel, + heading: INotebookHeading | null + ) => { + if (heading) { + const onCellInViewport = async (cell: Cell): Promise => { + if (!cell.inViewport) { + // Bail early + return; + } + + const el = headingToElement.get(heading); + + if (el) { + if (this.scrollToTop) { + el.scrollIntoView({ block: 'start' }); + } else { + const widgetBox = widget.content.node.getBoundingClientRect(); + const elementBox = el.getBoundingClientRect(); + + if ( + elementBox.top > widgetBox.bottom || + elementBox.bottom < widgetBox.top + ) { + el.scrollIntoView({ block: 'center' }); + } + } + } else { + console.debug('scrolling to heading: using fallback strategy'); + await widget.content.scrollToItem( + widget.content.activeCellIndex, + this.scrollToTop ? 'start' : undefined, + 0 + ); + } + }; + + const cell = heading.cellRef; + const cells = widget.content.widgets; + const idx = cells.indexOf(cell); + // Switch to command mode to avoid entering Markdown cell in edit mode + // if the document was in edit mode + if (cell.model.type == 'markdown' && widget.content.mode != 'command') { + widget.content.mode = 'command'; + } + + widget.content.activeCellIndex = idx; + + if (cell.inViewport) { + onCellInViewport(cell).catch(reason => { + console.error( + `Fail to scroll to cell to display the required heading (${reason}).` + ); + }); + } else { + widget.content + .scrollToItem(idx, this.scrollToTop ? 'start' : undefined) + .then(() => { + return onCellInViewport(cell); + }) + .catch(reason => { + console.error( + `Fail to scroll to cell to display the required heading (${reason}).` + ); + }); + } + } + }; + + const findHeadingElement = (cell: Cell): void => { + model.getCellHeadings(cell).forEach(async heading => { + const elementId = await getIdForHeading( + heading, + this.parser!, + this.sanitizer + ); + + const selector = elementId + ? `h${heading.level}[id="${CSS.escape(elementId)}"]` + : `h${heading.level}`; + + if (heading.outputIndex !== undefined) { + // Code cell + headingToElement.set( + heading, + TableOfContentsUtils.addPrefix( + (heading.cellRef as CodeCell).outputArea.widgets[ + heading.outputIndex + ].node, + selector, + heading.prefix ?? '' + ) + ); + } else { + headingToElement.set( + heading, + TableOfContentsUtils.addPrefix( + heading.cellRef.node, + selector, + heading.prefix ?? '' + ) + ); + } + }); + }; + + const onHeadingsChanged = (model: NotebookToCModel) => { + if (!this.parser) { + return; + } + // Clear all numbering items + TableOfContentsUtils.clearNumbering(widget.content.node); + + // Create a new mapping + headingToElement = new WeakMap(); + + widget.content.widgets.forEach(cell => { + findHeadingElement(cell); + }); + }; + + const onHeadingCollapsed = ( + _: NotebookToCModel, + heading: INotebookHeading | null + ) => { + if (model.configuration.syncCollapseState) { + if (heading !== null) { + const cell = heading.cellRef as MarkdownCell; + if (cell.headingCollapsed !== (heading.collapsed ?? false)) { + cell.headingCollapsed = heading.collapsed ?? false; + } + } else { + const collapseState = model.headings[0]?.collapsed ?? false; + widget.content.widgets.forEach(cell => { + if (cell instanceof MarkdownCell) { + if (cell.headingInfo.level >= 0) { + cell.headingCollapsed = collapseState; + } + } + }); + } + } + }; + const onCellCollapsed = (_: unknown, cell: MarkdownCell) => { + if (model.configuration.syncCollapseState) { + const h = model.getCellHeadings(cell)[0]; + if (h) { + model.toggleCollapse({ + heading: h, + collapsed: cell.headingCollapsed + }); + } + } + }; + + const onCellInViewportChanged = (_: unknown, cell: Cell) => { + if (cell.inViewport) { + findHeadingElement(cell); + } else { + // Needed to remove prefix in cell outputs + TableOfContentsUtils.clearNumbering(cell.node); + } + }; + + void widget.context.ready.then(() => { + onHeadingsChanged(model); + + model.activeHeadingChanged.connect(onActiveHeadingChanged); + model.headingsChanged.connect(onHeadingsChanged); + model.collapseChanged.connect(onHeadingCollapsed); + widget.content.cellCollapsed.connect(onCellCollapsed); + widget.content.cellInViewportChanged.connect(onCellInViewportChanged); + widget.disposed.connect(() => { + model.activeHeadingChanged.disconnect(onActiveHeadingChanged); + model.headingsChanged.disconnect(onHeadingsChanged); + model.collapseChanged.disconnect(onHeadingCollapsed); + widget.content.cellCollapsed.disconnect(onCellCollapsed); + widget.content.cellInViewportChanged.disconnect( + onCellInViewportChanged + ); + }); + }); + + return model; + } + + private _scrollToTop: boolean = true; +} + +/** + * Get the element id for an heading + * @param heading Heading + * @param parser The markdownparser + * @returns The element id + */ +export async function getIdForHeading( + heading: INotebookHeading, + parser: IRenderMime.IMarkdownParser, + sanitizer: IRenderMime.ISanitizer +) { + let elementId: string | null = null; + if (heading.type === Cell.HeadingType.Markdown) { + elementId = await TableOfContentsUtils.Markdown.getHeadingId( + parser, + // Type from TableOfContentsUtils.Markdown.IMarkdownHeading + (heading as any).raw, + heading.level, + sanitizer + ); + } else if (heading.type === Cell.HeadingType.HTML) { + // Type from TableOfContentsUtils.IHTMLHeading + elementId = (heading as any).id; + } + return elementId; +} diff --git a/.yalc/@jupyterlab/notebook/src/tokens.ts b/.yalc/@jupyterlab/notebook/src/tokens.ts new file mode 100644 index 0000000000..11c66593ff --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/tokens.ts @@ -0,0 +1,207 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import type { + ISessionContext, + ISessionContextDialogs, + IWidgetTracker +} from '@jupyterlab/apputils'; +import type { Cell } from '@jupyterlab/cells'; +import type { ITranslator } from '@jupyterlab/translation'; +import { Token } from '@lumino/coreutils'; +import type { ISignal } from '@lumino/signaling'; +import type { Widget } from '@lumino/widgets'; +import type { KernelError } from './actions'; +import type { INotebookModel } from './model'; +import type { NotebookTools } from './notebooktools'; +import type { NotebookPanel } from './panel'; +import type { StaticNotebook } from './widget'; +import type { NotebookWidgetFactory } from './widgetfactory'; + +/** + * The notebook widget factory token. + */ +export const INotebookWidgetFactory = new Token( + '@jupyterlab/notebook:INotebookWidgetFactory', + 'A service to create the notebook viewer.' +); + +/** + * The notebook tools token. + */ +export const INotebookTools = new Token( + '@jupyterlab/notebook:INotebookTools', + `A service for the "Notebook Tools" panel in the + right sidebar. Use this to add your own functionality to the panel.` +); + +/** + * The interface for notebook metadata tools. + */ +export interface INotebookTools extends Widget { + activeNotebookPanel: NotebookPanel | null; + activeCell: Cell | null; + selectedCells: Cell[]; + addItem(options: NotebookTools.IAddOptions): void; + addSection(options: NotebookTools.IAddSectionOptions): void; +} + +/** + * The namespace for NotebookTools class statics. + */ +export namespace INotebookTools { + /** + * The options used to add an item to the notebook tools. + */ + export interface IAddOptions { + /** + * The tool to add to the notebook tools area. + */ + tool: ITool; + + /** + * The section to which the tool should be added. + */ + section: 'advanced' | string; + + /** + * The rank order of the widget among its siblings. + */ + rank?: number; + } + + /** + * The options used to add a section to the notebook tools. + */ + export interface IAddSectionOptions { + /** + * The name of the new section. + */ + sectionName: string; + + /** + * The tool to add to the notebook tools area. + */ + tool?: INotebookTools.ITool; + + /** + * The label of the new section. + */ + label?: string; + + /** + * The rank order of the section among its siblings. + */ + rank?: number; + } + + export interface ITool extends Widget { + /** + * The notebook tools object. + */ + notebookTools: INotebookTools; + } +} + +/** + * The notebook tracker token. + */ +export const INotebookTracker = new Token( + '@jupyterlab/notebook:INotebookTracker', + `A widget tracker for notebooks. + Use this if you want to be able to iterate over and interact with notebooks + created by the application.` +); + +/** + * An object that tracks notebook widgets. + */ +export interface INotebookTracker extends IWidgetTracker { + /** + * The currently focused cell. + * + * #### Notes + * If there is no cell with the focus, then this value is `null`. + */ + readonly activeCell: Cell | null; + + /** + * A signal emitted when the current active cell changes. + * + * #### Notes + * If there is no cell with the focus, then `null` will be emitted. + */ + readonly activeCellChanged: ISignal; + + /** + * A signal emitted when the selection state changes. + */ + readonly selectionChanged: ISignal; +} + +/** + * Notebook cell executor namespace + */ +export namespace INotebookCellExecutor { + /** + * Execution options for notebook cell executor. + */ + export interface IRunCellOptions { + /** + * Cell to execute + */ + cell: Cell; + /** + * Notebook to which the cell belongs + */ + notebook: INotebookModel; + /** + * Notebook widget configuration + */ + notebookConfig: StaticNotebook.INotebookConfig; + /** + * A callback to notify a cell completed execution. + */ + onCellExecuted: (args: { + cell: Cell; + success: boolean; + error?: KernelError | null; + }) => void; + /** + * A callback to notify that a cell execution is scheduled. + */ + onCellExecutionScheduled: (args: { cell: Cell }) => void; + /** + * Document session context + */ + sessionContext?: ISessionContext; + /** + * Session dialogs + */ + sessionDialogs?: ISessionContextDialogs; + /** + * Application translator + */ + translator?: ITranslator; + } +} + +/** + * Notebook cell executor interface + */ +export interface INotebookCellExecutor { + /** + * Execute a cell. + * + * @param options Cell execution options + */ + runCell(options: INotebookCellExecutor.IRunCellOptions): Promise; +} + +/** + * The notebook cell executor token. + */ +export const INotebookCellExecutor = new Token( + '@jupyterlab/notebook:INotebookCellExecutor', + `The notebook cell executor` +); diff --git a/.yalc/@jupyterlab/notebook/src/tracker.ts b/.yalc/@jupyterlab/notebook/src/tracker.ts new file mode 100644 index 0000000000..26254f7706 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/tracker.ts @@ -0,0 +1,104 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { WidgetTracker } from '@jupyterlab/apputils'; +import { Cell } from '@jupyterlab/cells'; +import { ISignal, Signal } from '@lumino/signaling'; +import { NotebookPanel } from './panel'; +import { INotebookTracker } from './tokens'; +import { Notebook } from './widget'; + +export class NotebookTracker + extends WidgetTracker + implements INotebookTracker +{ + /** + * The currently focused cell. + * + * #### Notes + * This is a read-only property. If there is no cell with the focus, then this + * value is `null`. + */ + get activeCell(): Cell | null { + const widget = this.currentWidget; + if (!widget) { + return null; + } + return widget.content.activeCell || null; + } + + /** + * A signal emitted when the current active cell changes. + * + * #### Notes + * If there is no cell with the focus, then `null` will be emitted. + */ + get activeCellChanged(): ISignal { + return this._activeCellChanged; + } + + /** + * A signal emitted when the selection state changes. + */ + get selectionChanged(): ISignal { + return this._selectionChanged; + } + + /** + * Add a new notebook panel to the tracker. + * + * @param panel - The notebook panel being added. + */ + add(panel: NotebookPanel): Promise { + const promise = super.add(panel); + panel.content.activeCellChanged.connect(this._onActiveCellChanged, this); + panel.content.selectionChanged.connect(this._onSelectionChanged, this); + return promise; + } + + /** + * Dispose of the resources held by the tracker. + */ + dispose(): void { + this._activeCell = null; + super.dispose(); + } + + /** + * Handle the current change event. + */ + protected onCurrentChanged(widget: NotebookPanel): void { + // Store an internal reference to active cell to prevent false positives. + const activeCell = this.activeCell; + if (activeCell && activeCell === this._activeCell) { + return; + } + this._activeCell = activeCell; + + if (!widget) { + return; + } + + // Since the notebook has changed, immediately signal an active cell change + this._activeCellChanged.emit(widget.content.activeCell || null); + } + + private _onActiveCellChanged(sender: Notebook, cell: Cell): void { + // Check if the active cell change happened for the current notebook. + if (this.currentWidget && this.currentWidget.content === sender) { + this._activeCell = cell || null; + this._activeCellChanged.emit(this._activeCell); + } + } + + private _onSelectionChanged(sender: Notebook): void { + // Check if the selection change happened for the current notebook. + if (this.currentWidget && this.currentWidget.content === sender) { + this._selectionChanged.emit(void 0); + } + } + + private _activeCell: Cell | null = null; + private _activeCellChanged = new Signal(this); + private _selectionChanged = new Signal(this); +} diff --git a/.yalc/@jupyterlab/notebook/src/truststatus.tsx b/.yalc/@jupyterlab/notebook/src/truststatus.tsx new file mode 100644 index 0000000000..e02cb22fee --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/truststatus.tsx @@ -0,0 +1,289 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Cell } from '@jupyterlab/cells'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { + notTrustedIcon, + trustedIcon, + VDomModel, + VDomRenderer +} from '@jupyterlab/ui-components'; +import React from 'react'; +import { INotebookModel, Notebook } from '.'; + +const TRUST_CLASS = 'jp-StatusItem-trust'; + +/** + * Determine the notebook trust status message. + */ +function cellTrust( + props: NotebookTrustComponent.IProps | NotebookTrustStatus.Model, + translator?: ITranslator +): string { + translator = translator || nullTranslator; + const trans = translator.load('jupyterlab'); + + if (props.trustedCells === props.totalCells) { + return trans.__( + 'Notebook trusted: %1 of %2 code cells trusted.', + props.trustedCells, + props.totalCells + ); + } else if (props.activeCellTrusted) { + return trans.__( + 'Active cell trusted: %1 of %2 code cells trusted.', + props.trustedCells, + props.totalCells + ); + } else { + return trans.__( + 'Notebook not trusted: %1 of %2 code cells trusted.', + props.trustedCells, + props.totalCells + ); + } +} + +/** + * A pure function for a notebook trust status component. + * + * @param props the props for the component. + * + * @returns a tsx component for notebook trust. + */ +function NotebookTrustComponent( + props: NotebookTrustComponent.IProps +): React.ReactElement { + if (props.allCellsTrusted) { + return ; + } else { + return ; + } +} + +/** + * A namespace for NotebookTrustComponent statics. + */ +namespace NotebookTrustComponent { + /** + * Props for the NotebookTrustComponent. + */ + export interface IProps { + /** + * Whether all the cells are trusted. + */ + allCellsTrusted: boolean; + + /** + * Whether the currently active cell is trusted. + */ + activeCellTrusted: boolean; + + /** + * The total number of code cells for the current notebook. + */ + totalCells: number; + + /** + * The number of trusted code cells for the current notebook. + */ + trustedCells: number; + } +} + +/** + * The NotebookTrust status item. + */ +export class NotebookTrustStatus extends VDomRenderer { + /** + * Construct a new status item. + */ + constructor(translator?: ITranslator) { + super(new NotebookTrustStatus.Model()); + this.translator = translator || nullTranslator; + this.node.classList.add(TRUST_CLASS); + } + + /** + * Render the NotebookTrust status item. + */ + render(): JSX.Element | null { + if (!this.model) { + return null; + } + const newTitle = cellTrust(this.model, this.translator); + if (newTitle !== this.node.title) { + this.node.title = newTitle; + } + return ( + + ); + } + + translator: ITranslator; +} + +/** + * A namespace for NotebookTrust statics. + */ +export namespace NotebookTrustStatus { + /** + * A VDomModel for the NotebookTrust status item. + */ + export class Model extends VDomModel { + /** + * The number of trusted code cells in the current notebook. + */ + get trustedCells(): number { + return this._trustedCells; + } + + /** + * The total number of code cells in the current notebook. + */ + get totalCells(): number { + return this._totalCells; + } + + /** + * Whether the active cell is trusted. + */ + get activeCellTrusted(): boolean { + return this._activeCellTrusted; + } + + /** + * The current notebook for the model. + */ + get notebook(): Notebook | null { + return this._notebook; + } + set notebook(model: Notebook | null) { + const oldNotebook = this._notebook; + if (oldNotebook !== null) { + oldNotebook.activeCellChanged.disconnect( + this._onActiveCellChanged, + this + ); + + oldNotebook.modelContentChanged.disconnect(this._onModelChanged, this); + } + + const oldState = this._getAllState(); + this._notebook = model; + if (this._notebook === null) { + this._trustedCells = 0; + this._totalCells = 0; + this._activeCellTrusted = false; + } else { + // Add listeners + this._notebook.activeCellChanged.connect( + this._onActiveCellChanged, + this + ); + this._notebook.modelContentChanged.connect(this._onModelChanged, this); + + // Derive values + if (this._notebook.activeCell) { + this._activeCellTrusted = this._notebook.activeCell.model.trusted; + } else { + this._activeCellTrusted = false; + } + + const { total, trusted } = this._deriveCellTrustState( + this._notebook.model + ); + + this._totalCells = total; + this._trustedCells = trusted; + } + + this._triggerChange(oldState, this._getAllState()); + } + + /** + * When the notebook model changes, update the trust state. + */ + private _onModelChanged(notebook: Notebook): void { + const oldState = this._getAllState(); + const { total, trusted } = this._deriveCellTrustState(notebook.model); + + this._totalCells = total; + this._trustedCells = trusted; + this._triggerChange(oldState, this._getAllState()); + } + + /** + * When the active cell changes, update the trust state. + */ + private _onActiveCellChanged(model: Notebook, cell: Cell | null): void { + const oldState = this._getAllState(); + if (cell) { + this._activeCellTrusted = cell.model.trusted; + } else { + this._activeCellTrusted = false; + } + this._triggerChange(oldState, this._getAllState()); + } + + /** + * Given a notebook model, figure out how many of the code cells are trusted. + */ + private _deriveCellTrustState(model: INotebookModel | null): { + total: number; + trusted: number; + } { + if (model === null) { + return { total: 0, trusted: 0 }; + } + let total = 0; + let trusted = 0; + for (const cell of model.cells) { + if (cell.type !== 'code') { + continue; + } + total++; + if (cell.trusted) { + trusted++; + } + } + return { total, trusted }; + } + + /** + * Get the current state of the model. + */ + private _getAllState(): [number, number, boolean] { + return [this._trustedCells, this._totalCells, this.activeCellTrusted]; + } + + /** + * Trigger a change in the renderer. + */ + private _triggerChange( + oldState: [number, number, boolean], + newState: [number, number, boolean] + ) { + if ( + oldState[0] !== newState[0] || + oldState[1] !== newState[1] || + oldState[2] !== newState[2] + ) { + this.stateChanged.emit(void 0); + } + } + + private _trustedCells: number = 0; + private _totalCells: number = 0; + private _activeCellTrusted: boolean = false; + private _notebook: Notebook | null = null; + } +} diff --git a/.yalc/@jupyterlab/notebook/src/widget.ts b/.yalc/@jupyterlab/notebook/src/widget.ts new file mode 100644 index 0000000000..547661cbea --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/widget.ts @@ -0,0 +1,3384 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { DOMUtils } from '@jupyterlab/apputils'; +import { + Cell, + CodeCell, + ICellModel, + ICodeCellModel, + IMarkdownCellModel, + IRawCellModel, + MarkdownCell, + RawCell +} from '@jupyterlab/cells'; +import { CodeEditor, IEditorMimeTypeService } from '@jupyterlab/codeeditor'; +import { IChangedArgs } from '@jupyterlab/coreutils'; +import * as nbformat from '@jupyterlab/nbformat'; +import { IObservableList } from '@jupyterlab/observables'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import type { IMapChange } from '@jupyter/ydoc'; +import { TableOfContentsUtils } from '@jupyterlab/toc'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { WindowedList } from '@jupyterlab/ui-components'; +import { ArrayExt, findIndex } from '@lumino/algorithm'; +import { MimeData } from '@lumino/coreutils'; +import { ElementExt } from '@lumino/domutils'; +import { Drag } from '@lumino/dragdrop'; +import { Message } from '@lumino/messaging'; +import { AttachedProperty } from '@lumino/properties'; +import { ISignal, Signal } from '@lumino/signaling'; +import { h, VirtualDOM } from '@lumino/virtualdom'; +import { PanelLayout, Widget } from '@lumino/widgets'; +import { NotebookActions } from './actions'; +import { CellList } from './celllist'; +import { DROP_SOURCE_CLASS, DROP_TARGET_CLASS } from './constants'; +import { INotebookHistory } from './history'; +import { INotebookModel } from './model'; +import { NotebookViewModel, NotebookWindowedLayout } from './windowing'; +import { NotebookFooter } from './notebookfooter'; +import { CodeCellModel } from '../../cells/src/model'; + +/** + * The data attribute added to a widget that has an active kernel. + */ +const KERNEL_USER = 'jpKernelUser'; + +/** + * The data attribute added to a widget that can run code. + */ +const CODE_RUNNER = 'jpCodeRunner'; + +/** + * The data attribute added to a widget that can undo. + */ +const UNDOER = 'jpUndoer'; + +/** + * The class name added to notebook widgets. + */ +const NB_CLASS = 'jp-Notebook'; + +/** + * The class name added to notebook widget cells. + */ +const NB_CELL_CLASS = 'jp-Notebook-cell'; + +/** + * The class name added to a notebook in edit mode. + */ +const EDIT_CLASS = 'jp-mod-editMode'; + +/** + * The class name added to a notebook in command mode. + */ +const COMMAND_CLASS = 'jp-mod-commandMode'; + +/** + * The class name added to the active cell. + */ +const ACTIVE_CLASS = 'jp-mod-active'; + +/** + * The class name added to selected cells. + */ +const SELECTED_CLASS = 'jp-mod-selected'; + +/** + * The class name added to the cell when dirty. + */ +const DIRTY_CLASS = 'jp-mod-dirty'; + +/** + * The class name added to an active cell when there are other selected cells. + */ +const OTHER_SELECTED_CLASS = 'jp-mod-multiSelected'; + +/** + * The class name added to unconfined images. + */ +const UNCONFINED_CLASS = 'jp-mod-unconfined'; + +/** + * The class name added to the notebook when an element within it is focused + * and takes keyboard input, such as focused or
. + * + * This class is also effective when the focused element is in shadow DOM. + */ +const READ_WRITE_CLASS = 'jp-mod-readWrite'; + +/** + * The class name added to drag images. + */ +const DRAG_IMAGE_CLASS = 'jp-dragImage'; + +/** + * The class name added to singular drag images + */ +const SINGLE_DRAG_IMAGE_CLASS = 'jp-dragImage-singlePrompt'; + +/** + * The class name added to the drag image cell content. + */ +const CELL_DRAG_CONTENT_CLASS = 'jp-dragImage-content'; + +/** + * The class name added to the drag image cell content. + */ +const CELL_DRAG_PROMPT_CLASS = 'jp-dragImage-prompt'; + +/** + * The class name added to the drag image cell content. + */ +const CELL_DRAG_MULTIPLE_BACK = 'jp-dragImage-multipleBack'; + +/** + * The mimetype used for Jupyter cell data. + */ +const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells'; + +/** + * The threshold in pixels to start a drag event. + */ +const DRAG_THRESHOLD = 5; + +/** + * Maximal remaining time for idle callback + * + * Ref: https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API#getting_the_most_out_of_idle_callbacks + */ +const MAXIMUM_TIME_REMAINING = 50; + +/* + * The rendering mode for the notebook. + */ +type RenderingLayout = 'default' | 'side-by-side'; + +/** + * The class attached to the heading collapser button + */ +const HEADING_COLLAPSER_CLASS = 'jp-collapseHeadingButton'; + +/** + * The class that controls the visibility of "heading collapser" and "show hidden cells" buttons. + */ +const HEADING_COLLAPSER_VISBILITY_CONTROL_CLASS = + 'jp-mod-showHiddenCellsButton'; + +const SIDE_BY_SIDE_CLASS = 'jp-mod-sideBySide'; + +/** + * The interactivity modes for the notebook. + */ +export type NotebookMode = 'command' | 'edit'; + +if ((window as any).requestIdleCallback === undefined) { + // On Safari, requestIdleCallback is not available, so we use replacement functions for `idleCallbacks` + // See: https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API#falling_back_to_settimeout + // eslint-disable-next-line @typescript-eslint/ban-types + (window as any).requestIdleCallback = function (handler: Function) { + let startTime = Date.now(); + return setTimeout(function () { + handler({ + didTimeout: false, + timeRemaining: function () { + return Math.max(0, 50.0 - (Date.now() - startTime)); + } + }); + }, 1); + }; + + (window as any).cancelIdleCallback = function (id: number) { + clearTimeout(id); + }; +} + +/** + * A widget which renders static non-interactive notebooks. + * + * #### Notes + * The widget model must be set separately and can be changed + * at any time. Consumers of the widget must account for a + * `null` model, and may want to listen to the `modelChanged` + * signal. + */ +export class StaticNotebook extends WindowedList { + /** + * Construct a notebook widget. + */ + constructor(options: StaticNotebook.IOptions) { + const cells = new Array(); + const windowingActive = + (options.notebookConfig?.windowingMode ?? + StaticNotebook.defaultNotebookConfig.windowingMode) === 'full'; + super({ + model: new NotebookViewModel(cells, { + overscanCount: + options.notebookConfig?.overscanCount ?? + StaticNotebook.defaultNotebookConfig.overscanCount, + windowingActive + }), + layout: new NotebookWindowedLayout(), + renderer: options.renderer ?? WindowedList.defaultRenderer, + scrollbar: false + }); + this.addClass(NB_CLASS); + this.cellsArray = cells; + + this._idleCallBack = null; + + this._editorConfig = StaticNotebook.defaultEditorConfig; + this._notebookConfig = StaticNotebook.defaultNotebookConfig; + this._mimetype = IEditorMimeTypeService.defaultMimeType; + this._notebookModel = null; + this._modelChanged = new Signal(this); + this._modelContentChanged = new Signal(this); + + this.node.dataset[KERNEL_USER] = 'true'; + this.node.dataset[UNDOER] = 'true'; + this.node.dataset[CODE_RUNNER] = 'true'; + this.rendermime = options.rendermime; + this.translator = options.translator || nullTranslator; + this.contentFactory = options.contentFactory; + this.editorConfig = + options.editorConfig || StaticNotebook.defaultEditorConfig; + this.notebookConfig = + options.notebookConfig || StaticNotebook.defaultNotebookConfig; + this._updateNotebookConfig(); + this._mimetypeService = options.mimeTypeService; + this.renderingLayout = options.notebookConfig?.renderingLayout; + this.kernelHistory = options.kernelHistory; + } + + get cellCollapsed(): ISignal { + return this._cellCollapsed; + } + + get cellInViewportChanged(): ISignal { + return this._cellInViewportChanged; + } + + /** + * A signal emitted when the model of the notebook changes. + */ + get modelChanged(): ISignal { + return this._modelChanged; + } + + /** + * A signal emitted when the model content changes. + * + * #### Notes + * This is a convenience signal that follows the current model. + */ + get modelContentChanged(): ISignal { + return this._modelContentChanged; + } + + /** + * A signal emitted when the rendering layout of the notebook changes. + */ + get renderingLayoutChanged(): ISignal { + return this._renderingLayoutChanged; + } + + /** + * The cell factory used by the widget. + */ + readonly contentFactory: StaticNotebook.IContentFactory; + + /** + * The Rendermime instance used by the widget. + */ + readonly rendermime: IRenderMimeRegistry; + + /** + * Translator to be used by cell renderers + */ + readonly translator: ITranslator; + + /** + * The model for the widget. + */ + get model(): INotebookModel | null { + return this._notebookModel; + } + set model(newValue: INotebookModel | null) { + newValue = newValue || null; + if (this._notebookModel === newValue) { + return; + } + const oldValue = this._notebookModel; + this._notebookModel = newValue; + // Trigger private, protected, and public changes. + this._onModelChanged(oldValue, newValue); + this.onModelChanged(oldValue, newValue); + this._modelChanged.emit(void 0); + + // Trigger state change + this.viewModel.itemsList = newValue?.cells ?? null; + } + + /** + * Get the mimetype for code cells. + */ + get codeMimetype(): string { + return this._mimetype; + } + + /** + * A read-only sequence of the widgets in the notebook. + */ + get widgets(): ReadonlyArray { + return this.cellsArray as ReadonlyArray; + } + + /** + * A configuration object for cell editor settings. + */ + get editorConfig(): StaticNotebook.IEditorConfig { + return this._editorConfig; + } + set editorConfig(value: StaticNotebook.IEditorConfig) { + this._editorConfig = value; + this._updateEditorConfig(); + } + + /** + * A configuration object for notebook settings. + */ + get notebookConfig(): StaticNotebook.INotebookConfig { + return this._notebookConfig; + } + set notebookConfig(value: StaticNotebook.INotebookConfig) { + this._notebookConfig = value; + this._updateNotebookConfig(); + } + + get renderingLayout(): RenderingLayout | undefined { + return this._renderingLayout; + } + set renderingLayout(value: RenderingLayout | undefined) { + this._renderingLayout = value; + if (this._renderingLayout === 'side-by-side') { + this.node.classList.add(SIDE_BY_SIDE_CLASS); + } else { + this.node.classList.remove(SIDE_BY_SIDE_CLASS); + } + this._renderingLayoutChanged.emit(this._renderingLayout ?? 'default'); + } + + /** + * Dispose of the resources held by the widget. + */ + dispose(): void { + // Do nothing if already disposed. + if (this.isDisposed) { + return; + } + this._notebookModel = null; + (this.layout as NotebookWindowedLayout).header?.dispose(); + super.dispose(); + } + + /** + * Move cells preserving widget view state. + * + * #### Notes + * This is required because at the model level a move is a deletion + * followed by an insertion. Hence the view state is not preserved. + * + * @param from The index of the cell to move + * @param to The new index of the cell + * @param n Number of cells to move + */ + moveCell(from: number, to: number, n = 1): void { + if (!this.model) { + return; + } + + const boundedTo = Math.min(this.model.cells.length - 1, Math.max(0, to)); + + if (boundedTo === from) { + return; + } + + const viewModel: { [k: string]: any }[] = new Array(n); + let dirtyState: boolean[] = new Array(n); + + for (let i = 0; i < n; i++) { + viewModel[i] = {}; + const oldCell = this.widgets[from + i]; + if (oldCell.model.type === 'markdown') { + for (const k of ['rendered', 'headingCollapsed']) { + // @ts-expect-error Cell has no index signature + viewModel[i][k] = oldCell[k]; + } + } else if (oldCell.model.type === 'code') { + const oldCodeCell = oldCell.model as ICodeCellModel; + dirtyState[i] = oldCodeCell.isDirty; + } + } + + this.model!.sharedModel.moveCells(from, boundedTo, n); + + for (let i = 0; i < n; i++) { + const newCell = this.widgets[to + i]; + const view = viewModel[i]; + for (const state in view) { + // @ts-expect-error Cell has no index signature + newCell[state] = view[state]; + } + + if (from > to) { + if (this.widgets[to + i].model.type === 'code') { + (this.widgets[to + i].model as CodeCellModel).isDirty = dirtyState[i]; + } + } else { + if (this.widgets[to + i - n + 1].model.type === 'code') { + (this.widgets[to + i - n + 1].model as CodeCellModel).isDirty = + dirtyState[i]; + } + } + } + } + + /** + * Force rendering the cell outputs of a given cell if it is still a placeholder. + * + * #### Notes + * The goal of this method is to allow search on cell outputs (that is based + * on DOM tree introspection). + * + * @param index The cell index + */ + renderCellOutputs(index: number): void { + const cell = this.viewModel.widgetRenderer(index) as Cell; + if (cell instanceof CodeCell && cell.isPlaceholder()) { + cell.dataset.windowedListIndex = `${index}`; + this.layout.insertWidget(index, cell); + if (this.notebookConfig.windowingMode === 'full') { + // We need to delay slightly the removal to let codemirror properly initialize + requestAnimationFrame(() => { + this.layout.removeWidget(cell); + }); + } + } + } + + /** + * Adds a message to the notebook as a header. + */ + protected addHeader(): void { + const trans = this.translator.load('jupyterlab'); + const info = new Widget(); + info.node.textContent = trans.__( + 'The notebook is empty. Click the + button on the toolbar to add a new cell.' + ); + (this.layout as NotebookWindowedLayout).header = info; + } + + /** + * Removes the header. + */ + protected removeHeader(): void { + (this.layout as NotebookWindowedLayout).header?.dispose(); + (this.layout as NotebookWindowedLayout).header = null; + } + + /** + * Handle a new model. + * + * #### Notes + * This method is called after the model change has been handled + * internally and before the `modelChanged` signal is emitted. + * The default implementation is a no-op. + */ + protected onModelChanged( + oldValue: INotebookModel | null, + newValue: INotebookModel | null + ): void { + // No-op. + } + + /** + * Handle changes to the notebook model content. + * + * #### Notes + * The default implementation emits the `modelContentChanged` signal. + */ + protected onModelContentChanged(model: INotebookModel, args: void): void { + this._modelContentChanged.emit(void 0); + } + + /** + * Handle changes to the notebook model metadata. + * + * #### Notes + * The default implementation updates the mimetypes of the code cells + * when the `language_info` metadata changes. + */ + protected onMetadataChanged(sender: INotebookModel, args: IMapChange): void { + switch (args.key) { + case 'language_info': + this._updateMimetype(); + break; + default: + break; + } + } + + /** + * Handle a cell being inserted. + * + * The default implementation is a no-op + */ + protected onCellInserted(index: number, cell: Cell): void { + // This is a no-op. + } + + /** + * Handle a cell being removed. + * + * The default implementation is a no-op + */ + protected onCellRemoved(index: number, cell: Cell): void { + // This is a no-op. + } + + /** + * A message handler invoked on an `'update-request'` message. + * + * #### Notes + * The default implementation of this handler is a no-op. + */ + protected onUpdateRequest(msg: Message): void { + if (this.notebookConfig.windowingMode === 'defer') { + void this._runOnIdleTime(); + } else { + super.onUpdateRequest(msg); + } + } + + /** + * Handle a new model on the widget. + */ + private _onModelChanged( + oldValue: INotebookModel | null, + newValue: INotebookModel | null + ): void { + if (oldValue) { + oldValue.contentChanged.disconnect(this.onModelContentChanged, this); + oldValue.metadataChanged.disconnect(this.onMetadataChanged, this); + oldValue.cells.changed.disconnect(this._onCellsChanged, this); + while (this.cellsArray.length) { + this._removeCell(0); + } + } + if (!newValue) { + this._mimetype = IEditorMimeTypeService.defaultMimeType; + return; + } + this._updateMimetype(); + const cells = newValue.cells; + const collab = newValue.collaborative ?? false; + if (!collab && !cells.length) { + newValue.sharedModel.insertCell(0, { + cell_type: this.notebookConfig.defaultCell, + metadata: + this.notebookConfig.defaultCell === 'code' + ? { + // This is an empty cell created in empty notebook, thus is trusted + trusted: true + } + : {} + }); + } + let index = -1; + for (const cell of cells) { + this._insertCell(++index, cell); + } + newValue.cells.changed.connect(this._onCellsChanged, this); + newValue.metadataChanged.connect(this.onMetadataChanged, this); + newValue.contentChanged.connect(this.onModelContentChanged, this); + } + + /** + * Handle a change cells event. + */ + protected _onCellsChanged( + sender: CellList, + args: IObservableList.IChangedArgs + ): void { + this.removeHeader(); + switch (args.type) { + case 'add': { + let index = 0; + index = args.newIndex; + for (const value of args.newValues) { + this._insertCell(index++, value); + } + this._updateDataWindowedListIndex( + args.newIndex, + this.model!.cells.length, + args.newValues.length + ); + break; + } + case 'remove': + for (let length = args.oldValues.length; length > 0; length--) { + this._removeCell(args.oldIndex); + } + this._updateDataWindowedListIndex( + args.oldIndex, + this.model!.cells.length + args.oldValues.length, + -1 * args.oldValues.length + ); + // Add default cell if there are no cells remaining. + if (!sender.length) { + const model = this.model; + // Add the cell in a new context to avoid triggering another + // cell changed event during the handling of this signal. + requestAnimationFrame(() => { + if (model && !model.isDisposed && !model.sharedModel.cells.length) { + model.sharedModel.insertCell(0, { + cell_type: this.notebookConfig.defaultCell, + metadata: + this.notebookConfig.defaultCell === 'code' + ? { + // This is an empty cell created in empty notebook, thus is trusted + trusted: true + } + : {} + }); + } + }); + } + break; + default: + return; + } + + if (!this.model!.sharedModel.cells.length) { + this.addHeader(); + } + + this.update(); + } + + /** + * Create a cell widget and insert into the notebook. + */ + private _insertCell(index: number, cell: ICellModel): void { + let widget: Cell; + switch (cell.type) { + case 'code': + widget = this._createCodeCell(cell as ICodeCellModel); + widget.model.mimeType = this._mimetype; + break; + case 'markdown': + widget = this._createMarkdownCell(cell as IMarkdownCellModel); + if (cell.sharedModel.getSource() === '') { + (widget as MarkdownCell).rendered = false; + } + break; + default: + widget = this._createRawCell(cell as IRawCellModel); + } + widget.inViewportChanged.connect(this._onCellInViewportChanged, this); + widget.addClass(NB_CELL_CLASS); + + ArrayExt.insert(this.cellsArray, index, widget); + this.onCellInserted(index, widget); + + this._scheduleCellRenderOnIdle(); + } + + /** + * Create a code cell widget from a code cell model. + */ + private _createCodeCell(model: ICodeCellModel): CodeCell { + const rendermime = this.rendermime; + const contentFactory = this.contentFactory; + const editorConfig = this.editorConfig.code; + const options: CodeCell.IOptions = { + contentFactory, + editorConfig, + inputHistoryScope: this.notebookConfig.inputHistoryScope, + showInputPlaceholder: this.notebookConfig.showInputPlaceholder, + maxNumberOutputs: this.notebookConfig.maxNumberOutputs, + model, + placeholder: this._notebookConfig.windowingMode !== 'none', + rendermime, + translator: this.translator + }; + const cell = this.contentFactory.createCodeCell(options); + cell.syncCollapse = true; + cell.syncEditable = true; + cell.syncScrolled = true; + cell.outputArea.inputRequested.connect((_, stdin) => { + this._onInputRequested(cell).catch(reason => { + console.error('Failed to scroll to cell requesting input.', reason); + }); + stdin.disposed.connect(() => { + // The input field is removed from the DOM after the user presses Enter. + // This causes focus to be lost if we don't explicitly re-focus + // somewhere else. + cell.node.focus(); + }); + }); + return cell; + } + + /** + * Create a markdown cell widget from a markdown cell model. + */ + private _createMarkdownCell(model: IMarkdownCellModel): MarkdownCell { + const rendermime = this.rendermime; + const contentFactory = this.contentFactory; + const editorConfig = this.editorConfig.markdown; + const options: MarkdownCell.IOptions = { + contentFactory, + editorConfig, + model, + placeholder: this._notebookConfig.windowingMode !== 'none', + rendermime, + showEditorForReadOnlyMarkdown: + this._notebookConfig.showEditorForReadOnlyMarkdown + }; + const cell = this.contentFactory.createMarkdownCell(options); + cell.syncCollapse = true; + cell.syncEditable = true; + // Connect collapsed signal for each markdown cell widget + cell.headingCollapsedChanged.connect(this._onCellCollapsed, this); + return cell; + } + + /** + * Create a raw cell widget from a raw cell model. + */ + private _createRawCell(model: IRawCellModel): RawCell { + const contentFactory = this.contentFactory; + const editorConfig = this.editorConfig.raw; + const options: RawCell.IOptions = { + editorConfig, + model, + contentFactory, + placeholder: this._notebookConfig.windowingMode !== 'none' + }; + const cell = this.contentFactory.createRawCell(options); + cell.syncCollapse = true; + cell.syncEditable = true; + return cell; + } + + /** + * Remove a cell widget. + */ + private _removeCell(index: number): void { + const widget = this.cellsArray[index]; + widget.parent = null; + ArrayExt.removeAt(this.cellsArray, index); + this.onCellRemoved(index, widget); + widget.dispose(); + } + + /** + * Update the mimetype of the notebook. + */ + private _updateMimetype(): void { + const info = this._notebookModel?.getMetadata('language_info'); + if (!info) { + return; + } + this._mimetype = this._mimetypeService.getMimeTypeByLanguage(info); + for (const widget of this.widgets) { + if (widget.model.type === 'code') { + widget.model.mimeType = this._mimetype; + } + } + } + + /** + * Callback when a cell collapsed status changes. + * + * @param cell Cell changed + * @param collapsed New collapsed status + */ + private _onCellCollapsed(cell: Cell, collapsed: boolean): void { + NotebookActions.setHeadingCollapse(cell, collapsed, this); + this._cellCollapsed.emit(cell); + } + + /** + * Callback when a cell viewport status changes. + * + * @param cell Cell changed + */ + private _onCellInViewportChanged(cell: Cell): void { + this._cellInViewportChanged.emit(cell); + } + + /** + * Ensure to load in the DOM a cell requesting an user input + * + * @param cell Cell requesting an input + */ + private async _onInputRequested(cell: Cell): Promise { + if (!cell.inViewport) { + const cellIndex = this.widgets.findIndex(c => c === cell); + if (cellIndex >= 0) { + await this.scrollToItem(cellIndex); + + const inputEl = cell.node.querySelector('.jp-Stdin'); + if (inputEl) { + ElementExt.scrollIntoViewIfNeeded(this.node, inputEl); + (inputEl as HTMLElement).focus(); + } + } + } + } + + private _scheduleCellRenderOnIdle() { + if (this.notebookConfig.windowingMode !== 'none' && !this.isDisposed) { + if (!this._idleCallBack) { + this._idleCallBack = requestIdleCallback( + (deadline: IdleDeadline) => { + this._idleCallBack = null; + + // In case of timeout, render for some time even if it means freezing the UI + // This avoids the cells to never be loaded. + void this._runOnIdleTime( + deadline.didTimeout + ? MAXIMUM_TIME_REMAINING + : deadline.timeRemaining() + ); + }, + { + timeout: 3000 + } + ); + } + } + } + + private _updateDataWindowedListIndex( + start: number, + end: number, + delta: number + ): void { + for ( + let cellIdx = 0; + cellIdx < this.viewportNode.childElementCount; + cellIdx++ + ) { + const cell = this.viewportNode.children[cellIdx]; + const globalIndex = parseInt( + (cell as HTMLElement).dataset.windowedListIndex!, + 10 + ); + if (globalIndex >= start && globalIndex < end) { + (cell as HTMLElement).dataset.windowedListIndex = `${ + globalIndex + delta + }`; + } + } + } + + /** + * Update editor settings for notebook cells. + */ + private _updateEditorConfig() { + for (let i = 0; i < this.widgets.length; i++) { + const cell = this.widgets[i]; + let config: Record = {}; + switch (cell.model.type) { + case 'code': + config = this._editorConfig.code; + break; + case 'markdown': + config = this._editorConfig.markdown; + break; + default: + config = this._editorConfig.raw; + break; + } + cell.updateEditorConfig({ ...config }); + } + } + + private async _runOnIdleTime( + remainingTime: number = MAXIMUM_TIME_REMAINING + ): Promise { + const startTime = Date.now(); + let cellIdx = 0; + while ( + Date.now() - startTime < remainingTime && + cellIdx < this.cellsArray.length + ) { + const cell = this.cellsArray[cellIdx]; + if (cell.isPlaceholder()) { + if (['defer', 'full'].includes(this.notebookConfig.windowingMode)) { + await this._updateForDeferMode(cell, cellIdx); + if (this.notebookConfig.windowingMode === 'full') { + // We need to delay slightly the removal to let codemirror properly initialize + requestAnimationFrame(() => { + this.viewModel.setEstimatedWidgetSize( + cell.model.id, + cell.node.getBoundingClientRect().height + ); + this.layout.removeWidget(cell); + }); + } + } + } + cellIdx++; + } + + // If the notebook is not fully rendered + if (cellIdx < this.cellsArray.length) { + // If we are deferring the cell rendering and the rendered cells do + // not fill the viewport yet + if ( + this.notebookConfig.windowingMode === 'defer' && + this.viewportNode.clientHeight < this.node.clientHeight + ) { + // Spend more time rendering cells to fill the viewport + await this._runOnIdleTime(); + } else { + this._scheduleCellRenderOnIdle(); + } + } else { + if (this._idleCallBack) { + window.cancelIdleCallback(this._idleCallBack); + this._idleCallBack = null; + } + } + } + + private async _updateForDeferMode( + cell: Cell, + cellIdx: number + ): Promise { + cell.dataset.windowedListIndex = `${cellIdx}`; + this.layout.insertWidget(cellIdx, cell); + await cell.ready; + } + + /** + * Apply updated notebook settings. + */ + private _updateNotebookConfig() { + // Apply scrollPastEnd setting. + this.toggleClass( + 'jp-mod-scrollPastEnd', + this._notebookConfig.scrollPastEnd + ); + // Control visibility of heading collapser UI + this.toggleClass( + HEADING_COLLAPSER_VISBILITY_CONTROL_CLASS, + this._notebookConfig.showHiddenCellsButton + ); + // Control editor visibility for read-only Markdown cells + const showEditorForReadOnlyMarkdown = + this._notebookConfig.showEditorForReadOnlyMarkdown; + if (showEditorForReadOnlyMarkdown !== undefined) { + for (const cell of this.cellsArray) { + if (cell.model.type === 'markdown') { + (cell as MarkdownCell).showEditorForReadOnly = + showEditorForReadOnlyMarkdown; + } + } + } + + this.viewModel.windowingActive = + this._notebookConfig.windowingMode === 'full'; + } + + protected cellsArray: Array; + + private _cellCollapsed = new Signal(this); + private _cellInViewportChanged = new Signal(this); + private _editorConfig: StaticNotebook.IEditorConfig; + private _idleCallBack: number | null; + private _mimetype: string; + private _mimetypeService: IEditorMimeTypeService; + readonly kernelHistory: INotebookHistory | undefined; + private _modelChanged: Signal; + private _modelContentChanged: Signal; + private _notebookConfig: StaticNotebook.INotebookConfig; + private _notebookModel: INotebookModel | null; + private _renderingLayout: RenderingLayout | undefined; + private _renderingLayoutChanged = new Signal(this); +} + +/** + * The namespace for the `StaticNotebook` class statics. + */ +export namespace StaticNotebook { + /** + * An options object for initializing a static notebook. + */ + export interface IOptions { + /** + * The rendermime instance used by the widget. + */ + rendermime: IRenderMimeRegistry; + + /** + * The language preference for the model. + */ + languagePreference?: string; + + /** + * A factory for creating content. + */ + contentFactory: IContentFactory; + + /** + * A configuration object for the cell editor settings. + */ + editorConfig?: IEditorConfig; + + /** + * A configuration object for notebook settings. + */ + notebookConfig?: INotebookConfig; + + /** + * The service used to look up mime types. + */ + mimeTypeService: IEditorMimeTypeService; + + /** + * The application language translator. + */ + translator?: ITranslator; + + /** + * The kernel history retrieval object + */ + kernelHistory?: INotebookHistory; + + /** + * The renderer used by the underlying windowed list. + */ + renderer?: WindowedList.IRenderer; + } + + /** + * A factory for creating notebook content. + * + * #### Notes + * This extends the content factory of the cell itself, which extends the content + * factory of the output area and input area. The result is that there is a single + * factory for creating all child content of a notebook. + */ + export interface IContentFactory extends Cell.IContentFactory { + /** + * Create a new code cell widget. + */ + createCodeCell(options: CodeCell.IOptions): CodeCell; + + /** + * Create a new markdown cell widget. + */ + createMarkdownCell(options: MarkdownCell.IOptions): MarkdownCell; + + /** + * Create a new raw cell widget. + */ + createRawCell(options: RawCell.IOptions): RawCell; + } + + /** + * A config object for the cell editors. + */ + export interface IEditorConfig { + /** + * Config options for code cells. + */ + readonly code: Record; + /** + * Config options for markdown cells. + */ + readonly markdown: Record; + /** + * Config options for raw cells. + */ + readonly raw: Record; + } + + /** + * Default configuration options for cell editors. + */ + export const defaultEditorConfig: IEditorConfig = { + code: { + lineNumbers: false, + lineWrap: false, + matchBrackets: true, + tabFocusable: false + }, + markdown: { + lineNumbers: false, + lineWrap: true, + matchBrackets: false, + tabFocusable: false + }, + raw: { + lineNumbers: false, + lineWrap: true, + matchBrackets: false, + tabFocusable: false + } + }; + + /** + * A config object for the notebook widget + */ + export interface INotebookConfig { + /** + * The default type for new notebook cells. + */ + defaultCell: nbformat.CellType; + + /** + * Defines if the document can be undo/redo. + */ + disableDocumentWideUndoRedo: boolean; + + /** + * Whether to display notification if code cell is run while kernel is still initializing. + */ + enableKernelInitNotification: boolean; + + /** + * Defines the maximum number of outputs per cell. + */ + maxNumberOutputs: number; + + /** + * Show placeholder text for standard input + */ + showInputPlaceholder: boolean; + + /** + * Whether to split stdin line history by kernel session or keep globally accessible. + */ + inputHistoryScope: 'global' | 'session'; + + /** + * Number of cells to render in addition to those + * visible in the viewport. + * + * ### Notes + * In 'full' windowing mode, this is the number of cells above and below the + * viewport. + * In 'defer' windowing mode, this is the number of cells to render initially + * in addition to the one of the viewport. + */ + overscanCount: number; + + /** + * Should timing be recorded in metadata + */ + recordTiming: boolean; + + /** + * Defines the rendering layout to use. + */ + renderingLayout: RenderingLayout; + + /** + * Automatically render markdown when the cursor leaves a markdown cell + */ + autoRenderMarkdownCells: boolean; + + /** + * Enable scrolling past the last cell + */ + scrollPastEnd: boolean; + + /** + * Show hidden cells button if collapsed + */ + showHiddenCellsButton: boolean; + + /** + * Should an editor be shown for read-only markdown + */ + showEditorForReadOnlyMarkdown?: boolean; + + /** + * Override the side-by-side left margin. + */ + sideBySideLeftMarginOverride: string; + + /** + * Override the side-by-side right margin. + */ + sideBySideRightMarginOverride: string; + + /** + * Side-by-side output ratio. + */ + sideBySideOutputRatio: number; + + /** + * Windowing mode + * + * - 'defer': Wait for idle CPU cycles to attach out of viewport cells + * - 'full': Attach to the DOM only cells in viewport + * - 'none': Attach all cells to the viewport + */ + windowingMode: 'defer' | 'full' | 'none'; + accessKernelHistory?: boolean; + } + + /** + * Default configuration options for notebooks. + */ + export const defaultNotebookConfig: INotebookConfig = { + enableKernelInitNotification: false, + showHiddenCellsButton: true, + scrollPastEnd: true, + defaultCell: 'code', + recordTiming: false, + inputHistoryScope: 'global', + maxNumberOutputs: 50, + showEditorForReadOnlyMarkdown: true, + disableDocumentWideUndoRedo: true, + autoRenderMarkdownCells: false, + renderingLayout: 'default', + sideBySideLeftMarginOverride: '10px', + sideBySideRightMarginOverride: '10px', + sideBySideOutputRatio: 1, + overscanCount: 1, + windowingMode: 'full', + accessKernelHistory: false, + showInputPlaceholder: true + }; + + /** + * The default implementation of an `IContentFactory`. + */ + export class ContentFactory + extends Cell.ContentFactory + implements IContentFactory + { + /** + * Create a new code cell widget. + * + * #### Notes + * If no cell content factory is passed in with the options, the one on the + * notebook content factory is used. + */ + createCodeCell(options: CodeCell.IOptions): CodeCell { + return new CodeCell(options).initializeState(); + } + + /** + * Create a new markdown cell widget. + * + * #### Notes + * If no cell content factory is passed in with the options, the one on the + * notebook content factory is used. + */ + createMarkdownCell(options: MarkdownCell.IOptions): MarkdownCell { + return new MarkdownCell(options).initializeState(); + } + + /** + * Create a new raw cell widget. + * + * #### Notes + * If no cell content factory is passed in with the options, the one on the + * notebook content factory is used. + */ + createRawCell(options: RawCell.IOptions): RawCell { + return new RawCell(options).initializeState(); + } + } + + /** + * A namespace for the static notebook content factory. + */ + export namespace ContentFactory { + /** + * Options for the content factory. + */ + export interface IOptions extends Cell.ContentFactory.IOptions {} + } +} + +/** + * A virtual scrollbar item representing a notebook cell. + */ +class ScrollbarItem implements WindowedList.IRenderer.IScrollbarItem { + /** + * Construct a scrollbar item. + */ + constructor(options: { notebook: Notebook; model: ICellModel }) { + // Note: there should be no DOM operations in the constructor + this._model = options.model; + this._notebook = options.notebook; + } + + /** + * Render the scrollbar item as an HTML element. + */ + render = (props: { index: number }) => { + if (!this._element) { + this._element = this._createElement(); + this._notebook.activeCellChanged.connect(this._updateActive); + this._notebook.selectionChanged.connect(this._updateSelection); + if (this._model.type === 'code') { + const model = this._model as ICodeCellModel; + model.outputs.changed.connect(this._updatePrompt); + model.stateChanged.connect(this._updateState); + } + } + // Add cell type (code/markdown/raw) + if (this._model.type != this._element.dataset.type) { + this._element.dataset.type = this._model.type; + } + const source = this._model.sharedModel.source; + const trimmedSource = + source.length > 10000 ? source.substring(0, 10000) : source; + if (trimmedSource !== this._source.textContent) { + this._source.textContent = trimmedSource; + } + + this._updateActive(); + this._updateSelection(); + this._updatePrompt(); + this._updateDirty(); + return this._element; + }; + + /** + * Unique item key used for caching. + */ + get key(): string { + return this._model.id; + } + + /** + * Test whether the item has been disposed. + */ + get isDisposed(): boolean { + // Ensure the state is up-to-date in case if the model was disposed + // (the model can be disposed when cells are moved/recreated). + if (!this._isDisposed && this._model.isDisposed) { + this.dispose(); + } + return this._isDisposed; + } + + /** + * Dispose of the resources held by the item. + */ + dispose = () => { + this._isDisposed = true; + this._notebook.activeCellChanged.disconnect(this._updateActive); + this._notebook.selectionChanged.disconnect(this._updateSelection); + if (this._model.type === 'code') { + const model = this._model as ICodeCellModel; + if (model.outputs) { + model.outputs.changed.disconnect(this._updatePrompt); + model.stateChanged.disconnect(this._updateState); + } + } + }; + + private _updateState = ( + _: ICellModel, + change: IChangedArgs< + any, + any, + 'trusted' | 'isDirty' | 'executionCount' | 'executionState' + > + ) => { + switch (change.name) { + case 'executionCount': + case 'executionState': + this._updatePrompt(); + break; + case 'isDirty': { + this._updateDirty(); + break; + } + } + }; + + private _updateDirty() { + if (this._model.type !== 'code' || !this._element) { + return; + } + const model = this._model as ICodeCellModel; + const wasDirty = this._element.classList.contains(DIRTY_CLASS); + if (wasDirty !== model.isDirty) { + if (model.isDirty) { + this._element.classList.add(DIRTY_CLASS); + } else { + this._element.classList.remove(DIRTY_CLASS); + } + } + } + + private _updatePrompt = () => { + if (this._model.type !== 'code') { + return; + } + const model = this._model as ICodeCellModel; + let hasError = false; + for (let i = 0; i < model.outputs.length; i++) { + const output = model.outputs.get(i); + if (output.type === 'error') { + hasError = true; + break; + } + } + let content: string; + let state: string = ''; + if (hasError) { + content = '[!]'; + state = 'error'; + } else if (model.executionState == 'running') { + content = '[*]'; + } else if (model.executionCount) { + content = `[${model.executionCount}]`; + } else { + content = '[ ]'; + } + if (this._executionIndicator.textContent !== content) { + this._executionIndicator.textContent = content; + } + if (this._element!.dataset.output !== state) { + this._element!.dataset.output = state; + } + }; + + private _createElement() { + const li = document.createElement('li'); + const executionIndicator = (this._executionIndicator = + document.createElement('div')); + executionIndicator.className = 'jp-scrollbarItem-executionIndicator'; + const source = (this._source = document.createElement('div')); + source.className = 'jp-scrollbarItem-source'; + li.append(executionIndicator); + li.append(source); + return li; + } + private _executionIndicator: HTMLElement; + private _source: HTMLElement; + + private _updateActive = () => { + if (!this._element) { + this._element = this._createElement(); + } + const li = this._element; + const wasActive = li.classList.contains(ACTIVE_CLASS); + if (this._notebook.activeCell?.model === this._model) { + if (!wasActive) { + li.classList.add(ACTIVE_CLASS); + } + } else if (wasActive) { + li.classList.remove(ACTIVE_CLASS); + // Needed due to order in which selection and active changed signals fire + li.classList.remove(SELECTED_CLASS); + } + }; + + private _updateSelection = () => { + if (!this._element) { + this._element = this._createElement(); + } + const li = this._element; + const wasSelected = li.classList.contains(SELECTED_CLASS); + if (this._notebook.selectedCells.some(cell => this._model === cell.model)) { + if (!wasSelected) { + li.classList.add(SELECTED_CLASS); + } + } else if (wasSelected) { + li.classList.remove(SELECTED_CLASS); + } + }; + + private _model: ICellModel; + private _notebook: Notebook; + private _isDisposed: boolean = false; + private _element: HTMLElement | null = null; +} + +/** + * A notebook widget that supports interactivity. + */ +export class Notebook extends StaticNotebook { + /** + * Construct a notebook widget. + */ + constructor(options: Notebook.IOptions) { + super({ + renderer: { + createOuter(): HTMLElement { + return document.createElement('div'); + }, + + createViewport(): HTMLElement { + const el = document.createElement('div'); + el.setAttribute('role', 'feed'); + el.setAttribute('aria-label', 'Cells'); + return el; + }, + + createScrollbar(): HTMLOListElement { + return document.createElement('ol'); + }, + + createScrollbarViewportIndicator(): HTMLElement { + return document.createElement('div'); + }, + + createScrollbarItem( + notebook: Notebook, + _index: number, + model: ICellModel + ): WindowedList.IRenderer.IScrollbarItem { + return new ScrollbarItem({ + notebook, + model + }); + } + }, + ...options + }); + // Allow the node to scroll while dragging items. + this.outerNode.setAttribute('data-lm-dragscroll', 'true'); + this.activeCellChanged.connect(this._updateSelectedCells, this); + this.jumped.connect((_, index: number) => (this.activeCellIndex = index)); + this.selectionChanged.connect(this._updateSelectedCells, this); + + this.addFooter(); + } + + /** + * List of selected and active cells + */ + get selectedCells(): Cell[] { + return this._selectedCells; + } + + /** + * Adds a footer to the notebook. + */ + protected addFooter(): void { + const info = new NotebookFooter(this); + (this.layout as NotebookWindowedLayout).footer = info; + } + + /** + * Handle a change cells event. + */ + protected _onCellsChanged( + sender: CellList, + args: IObservableList.IChangedArgs + ): void { + const activeCellId = this.activeCell?.model.id; + super._onCellsChanged(sender, args); + if (activeCellId) { + const newActiveCellIndex = this.model?.sharedModel.cells.findIndex( + cell => cell.getId() === activeCellId + ); + if (newActiveCellIndex != null) { + this.activeCellIndex = newActiveCellIndex; + } + } + } + + /** + * A signal emitted when the active cell changes. + * + * #### Notes + * This can be due to the active index changing or the + * cell at the active index changing. + */ + get activeCellChanged(): ISignal { + return this._activeCellChanged; + } + + /** + * A signal emitted when the state of the notebook changes. + */ + get stateChanged(): ISignal> { + return this._stateChanged; + } + + /** + * A signal emitted when the selection state of the notebook changes. + */ + get selectionChanged(): ISignal { + return this._selectionChanged; + } + + /** + * The interactivity mode of the notebook. + */ + get mode(): NotebookMode { + return this._mode; + } + set mode(newValue: NotebookMode) { + this.setMode(newValue); + } + + /** + * Set the notebook mode. + * + * @param newValue Notebook mode + * @param options Control mode side-effect + * @param options.focus Whether to ensure focus (default) or not when setting the mode. + */ + protected setMode( + newValue: NotebookMode, + options: { focus?: boolean } = {} + ): void { + const setFocus = options.focus ?? true; + const activeCell = this.activeCell; + if (!activeCell) { + newValue = 'command'; + } + if (newValue === this._mode) { + if (setFocus) { + this._ensureFocus(); + } + return; + } + // Post an update request. + this.update(); + const oldValue = this._mode; + this._mode = newValue; + + if (newValue === 'edit') { + // Edit mode deselects all cells. + for (const widget of this.widgets) { + this.deselect(widget); + } + // Edit mode unrenders an active markdown widget. + if (activeCell instanceof MarkdownCell) { + activeCell.rendered = false; + } + activeCell!.inputHidden = false; + } else { + if (setFocus) { + void NotebookActions.focusActiveCell(this, { + // Do not await the active cell because that creates a bug. If the user + // is editing a code cell and presses Accel Shift C to open the command + // palette, then the command palette opens before + // activeCell.node.focus() is called, which closes the command palette. + // To the end user, it looks as if all the keyboard shortcut did was + // move focus from the cell editor to the cell as a whole. + waitUntilReady: false, + preventScroll: true + }); + } + } + this._stateChanged.emit({ name: 'mode', oldValue, newValue }); + if (setFocus) { + this._ensureFocus(); + } + } + + /** + * The active cell index of the notebook. + * + * #### Notes + * The index will be clamped to the bounds of the notebook cells. + */ + get activeCellIndex(): number { + if (!this.model) { + return -1; + } + return this.widgets.length ? this._activeCellIndex : -1; + } + set activeCellIndex(newValue: number) { + const oldValue = this._activeCellIndex; + if (!this.model || !this.widgets.length) { + newValue = -1; + } else { + newValue = Math.max(newValue, 0); + newValue = Math.min(newValue, this.widgets.length - 1); + } + + this._activeCellIndex = newValue; + const oldCell = this.widgets[oldValue] ?? null; + const cell = this.widgets[newValue] ?? null; + (this.layout as NotebookWindowedLayout).activeCell = cell; + const cellChanged = cell !== this._activeCell; + if (cellChanged) { + // Post an update request. + this.update(); + this._activeCell = cell; + } + + if (cellChanged || newValue != oldValue) { + this._activeCellChanged.emit(cell); + } + + if (this.mode === 'edit') { + if (cell instanceof MarkdownCell) { + cell.rendered = false; + } + if ( + this.notebookConfig.autoRenderMarkdownCells && + cellChanged && + oldCell instanceof MarkdownCell + ) { + oldCell.rendered = true; + } + } + + this._ensureFocus(); + if (newValue === oldValue) { + return; + } + this._trimSelections(); + this._stateChanged.emit({ name: 'activeCellIndex', oldValue, newValue }); + } + + /** + * Get the active cell widget. + * + * #### Notes + * This is a cell or `null` if there is no active cell. + */ + get activeCell(): Cell | null { + return this._activeCell; + } + + get lastClipboardInteraction(): 'copy' | 'cut' | 'paste' | null { + return this._lastClipboardInteraction; + } + set lastClipboardInteraction(newValue: 'copy' | 'cut' | 'paste' | null) { + this._lastClipboardInteraction = newValue; + } + + /** + * Dispose of the resources held by the widget. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._activeCell = null; + super.dispose(); + } + + /** + * Move cells preserving widget view state. + * + * #### Notes + * This is required because at the model level a move is a deletion + * followed by an insertion. Hence the view state is not preserved. + * + * @param from The index of the cell to move + * @param to The new index of the cell + * @param n Number of cells to move + */ + moveCell(from: number, to: number, n = 1): void { + // Save active cell id to be restored + const newActiveCellIndex = + from <= this.activeCellIndex && this.activeCellIndex < from + n + ? this.activeCellIndex + to - from - (from > to ? 0 : n - 1) + : -1; + const isSelected = this.widgets + .slice(from, from + n) + .map(w => this.isSelected(w)); + + super.moveCell(from, to, n); + + if (newActiveCellIndex >= 0) { + this.activeCellIndex = newActiveCellIndex; + } + if (from > to) { + isSelected.forEach((selected, idx) => { + if (selected) { + this.select(this.widgets[to + idx]); + } + }); + } else { + isSelected.forEach((selected, idx) => { + if (selected) { + this.select(this.widgets[to - n + 1 + idx]); + } + }); + } + } + + /** + * Select a cell widget. + * + * #### Notes + * It is a no-op if the value does not change. + * It will emit the `selectionChanged` signal. + */ + select(widget: Cell): void { + if (Private.selectedProperty.get(widget)) { + return; + } + Private.selectedProperty.set(widget, true); + this._selectionChanged.emit(void 0); + this.update(); + } + + /** + * Deselect a cell widget. + * + * #### Notes + * It is a no-op if the value does not change. + * It will emit the `selectionChanged` signal. + */ + deselect(widget: Cell): void { + if (!Private.selectedProperty.get(widget)) { + return; + } + Private.selectedProperty.set(widget, false); + this._selectionChanged.emit(void 0); + this.update(); + } + + /** + * Whether a cell is selected. + */ + isSelected(widget: Cell): boolean { + return Private.selectedProperty.get(widget); + } + + /** + * Whether a cell is selected or is the active cell. + */ + isSelectedOrActive(widget: Cell): boolean { + if (widget === this._activeCell) { + return true; + } + return Private.selectedProperty.get(widget); + } + + /** + * Deselect all of the cells. + */ + deselectAll(): void { + let changed = false; + for (const widget of this.widgets) { + if (Private.selectedProperty.get(widget)) { + changed = true; + } + Private.selectedProperty.set(widget, false); + } + if (changed) { + this._selectionChanged.emit(void 0); + } + // Make sure we have a valid active cell. + this.activeCellIndex = this.activeCellIndex; // eslint-disable-line + this.update(); + } + + /** + * Move the head of an existing contiguous selection to extend the selection. + * + * @param index - The new head of the existing selection. + * + * #### Notes + * If there is no existing selection, the active cell is considered an + * existing one-cell selection. + * + * If the new selection is a single cell, that cell becomes the active cell + * and all cells are deselected. + * + * There is no change if there are no cells (i.e., activeCellIndex is -1). + */ + extendContiguousSelectionTo(index: number): void { + let { head, anchor } = this.getContiguousSelection(); + let i: number; + + // Handle the case of no current selection. + if (anchor === null || head === null) { + if (index === this.activeCellIndex) { + // Already collapsed selection, nothing more to do. + return; + } + + // We will start a new selection below. + head = this.activeCellIndex; + anchor = this.activeCellIndex; + } + + // Move the active cell. We do this before the collapsing shortcut below. + this.activeCellIndex = index; + + // Make sure the index is valid, according to the rules for setting and clipping the + // active cell index. This may change the index. + index = this.activeCellIndex; + + // Collapse the selection if it is only the active cell. + if (index === anchor) { + this.deselectAll(); + return; + } + + let selectionChanged = false; + + if (head < index) { + if (head < anchor) { + Private.selectedProperty.set(this.widgets[head], false); + selectionChanged = true; + } + + // Toggle everything strictly between head and index except anchor. + for (i = head + 1; i < index; i++) { + if (i !== anchor) { + Private.selectedProperty.set( + this.widgets[i], + !Private.selectedProperty.get(this.widgets[i]) + ); + selectionChanged = true; + } + } + } else if (index < head) { + if (anchor < head) { + Private.selectedProperty.set(this.widgets[head], false); + selectionChanged = true; + } + + // Toggle everything strictly between index and head except anchor. + for (i = index + 1; i < head; i++) { + if (i !== anchor) { + Private.selectedProperty.set( + this.widgets[i], + !Private.selectedProperty.get(this.widgets[i]) + ); + selectionChanged = true; + } + } + } + + // Anchor and index should *always* be selected. + if (!Private.selectedProperty.get(this.widgets[anchor])) { + selectionChanged = true; + } + Private.selectedProperty.set(this.widgets[anchor], true); + + if (!Private.selectedProperty.get(this.widgets[index])) { + selectionChanged = true; + } + Private.selectedProperty.set(this.widgets[index], true); + + if (selectionChanged) { + this._selectionChanged.emit(void 0); + } + } + + /** + * Get the head and anchor of a contiguous cell selection. + * + * The head of a contiguous selection is always the active cell. + * + * If there are no cells selected, `{head: null, anchor: null}` is returned. + * + * Throws an error if the currently selected cells do not form a contiguous + * selection. + */ + getContiguousSelection(): + | { head: number; anchor: number } + | { head: null; anchor: null } { + const cells = this.widgets; + const first = ArrayExt.findFirstIndex(cells, c => this.isSelected(c)); + + // Return early if no cells are selected. + if (first === -1) { + return { head: null, anchor: null }; + } + + const last = ArrayExt.findLastIndex( + cells, + c => this.isSelected(c), + -1, + first + ); + + // Check that the selection is contiguous. + for (let i = first; i <= last; i++) { + if (!this.isSelected(cells[i])) { + throw new Error('Selection not contiguous'); + } + } + + // Check that the active cell is one of the endpoints of the selection. + const activeIndex = this.activeCellIndex; + if (first !== activeIndex && last !== activeIndex) { + throw new Error('Active cell not at endpoint of selection'); + } + + // Determine the head and anchor of the selection. + if (first === activeIndex) { + return { head: first, anchor: last }; + } else { + return { head: last, anchor: first }; + } + } + + /** + * Scroll so that the given cell is in view. Selects and activates cell. + * + * @param cell - A cell in the notebook widget. + * @param align - Type of alignment. + * + */ + async scrollToCell( + cell: Cell, + align: WindowedList.ScrollToAlign = 'auto' + ): Promise { + try { + await this.scrollToItem( + this.widgets.findIndex(c => c === cell), + align + ); + } catch (r) { + //no-op + } + // change selection and active cell: + this.deselectAll(); + this.select(cell); + cell.activate(); + } + + private _parseFragment(fragment: string): Private.IFragmentData | undefined { + const cleanedFragment = fragment.slice(1); + + if (!cleanedFragment) { + // Bail early + return; + } + + const parts = cleanedFragment.split('='); + if (parts.length === 1) { + // Default to heading if no prefix is given. + return { + kind: 'heading', + value: cleanedFragment + }; + } + return { + kind: parts[0] as any, + value: parts.slice(1).join('=') + }; + } + + /** + * Set URI fragment identifier. + */ + async setFragment(fragment: string): Promise { + const parsedFragment = this._parseFragment(fragment); + + if (!parsedFragment) { + // Bail early + return; + } + + let result; + + switch (parsedFragment.kind) { + case 'heading': + result = await this._findHeading(parsedFragment.value); + break; + case 'cell-id': + result = this._findCellById(parsedFragment.value); + break; + default: + console.warn( + `Unknown target type for URI fragment ${fragment}, interpreting as a heading` + ); + result = await this._findHeading( + parsedFragment.kind + '=' + parsedFragment.value + ); + break; + } + + if (result == null) { + return; + } + let { cell, element } = result; + + if (!cell.inViewport) { + await this.scrollToCell(cell, 'center'); + } + + if (element == null) { + element = cell.node; + } + const widgetBox = this.node.getBoundingClientRect(); + const elementBox = element.getBoundingClientRect(); + + if ( + elementBox.top > widgetBox.bottom || + elementBox.bottom < widgetBox.top + ) { + element.scrollIntoView({ block: 'center' }); + } + } + + /** + * Handle the DOM events for the widget. + * + * @param event - The DOM event sent to the widget. + * + * #### Notes + * This method implements the DOM `EventListener` interface and is + * called in response to events on the notebook panel's node. It should + * not be called directly by user code. + */ + handleEvent(event: Event): void { + if (!this.model) { + return; + } + + switch (event.type) { + case 'contextmenu': + if (event.eventPhase === Event.CAPTURING_PHASE) { + this._evtContextMenuCapture(event as PointerEvent); + } + break; + case 'mousedown': + if (event.eventPhase === Event.CAPTURING_PHASE) { + this._evtMouseDownCapture(event as MouseEvent); + } else { + // Skip processing the event when it resulted from a toolbar button click + if (!event.defaultPrevented) { + this._evtMouseDown(event as MouseEvent); + } + } + break; + case 'mouseup': + if (event.currentTarget === document) { + this._evtDocumentMouseup(event as MouseEvent); + } + break; + case 'mousemove': + if (event.currentTarget === document) { + this._evtDocumentMousemove(event as MouseEvent); + } + break; + case 'keydown': + // This works because CodeMirror does not stop the event propagation + this._ensureFocus(true); + break; + case 'dblclick': + this._evtDblClick(event as MouseEvent); + break; + case 'focusin': + this._evtFocusIn(event as MouseEvent); + break; + case 'focusout': + this._evtFocusOut(event as MouseEvent); + break; + case 'lm-dragenter': + this._evtDragEnter(event as Drag.Event); + break; + case 'lm-dragleave': + this._evtDragLeave(event as Drag.Event); + break; + case 'lm-dragover': + this._evtDragOver(event as Drag.Event); + break; + case 'lm-drop': + this._evtDrop(event as Drag.Event); + break; + default: + super.handleEvent(event); + break; + } + } + + /** + * Handle `after-attach` messages for the widget. + */ + protected onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + const node = this.node; + node.addEventListener('contextmenu', this, true); + node.addEventListener('mousedown', this, true); + node.addEventListener('mousedown', this); + node.addEventListener('keydown', this); + node.addEventListener('dblclick', this); + + node.addEventListener('focusin', this); + node.addEventListener('focusout', this); + // Capture drag events for the notebook widget + // in order to preempt the drag/drop handlers in the + // code editor widgets, which can take text data. + node.addEventListener('lm-dragenter', this, true); + node.addEventListener('lm-dragleave', this, true); + node.addEventListener('lm-dragover', this, true); + node.addEventListener('lm-drop', this, true); + } + + /** + * Handle `before-detach` messages for the widget. + */ + protected onBeforeDetach(msg: Message): void { + const node = this.node; + node.removeEventListener('contextmenu', this, true); + node.removeEventListener('mousedown', this, true); + node.removeEventListener('mousedown', this); + node.removeEventListener('keydown', this); + node.removeEventListener('dblclick', this); + node.removeEventListener('focusin', this); + node.removeEventListener('focusout', this); + node.removeEventListener('lm-dragenter', this, true); + node.removeEventListener('lm-dragleave', this, true); + node.removeEventListener('lm-dragover', this, true); + node.removeEventListener('lm-drop', this, true); + document.removeEventListener('mousemove', this, true); + document.removeEventListener('mouseup', this, true); + super.onBeforeAttach(msg); + } + + /** + * A message handler invoked on an `'after-show'` message. + */ + protected onAfterShow(msg: Message): void { + super.onAfterShow(msg); + this._checkCacheOnNextResize = true; + } + + /** + * A message handler invoked on a `'resize'` message. + */ + protected onResize(msg: Widget.ResizeMessage): void { + // TODO + if (!this._checkCacheOnNextResize) { + return super.onResize(msg); + } + super.onResize(msg); + this._checkCacheOnNextResize = false; + const cache = this._cellLayoutStateCache; + const width = parseInt(this.node.style.width, 10); + if (cache) { + if (width === cache.width) { + // Cache identical, do nothing + return; + } + } + // Update cache + this._cellLayoutStateCache = { width }; + + // Fallback: + for (const w of this.widgets) { + if (w instanceof Cell && w.inViewport) { + w.editorWidget?.update(); + } + } + } + + /** + * A message handler invoked on an `'before-hide'` message. + */ + protected onBeforeHide(msg: Message): void { + super.onBeforeHide(msg); + // Update cache + const width = parseInt(this.node.style.width, 10); + this._cellLayoutStateCache = { width }; + } + + /** + * Handle `'activate-request'` messages. + */ + protected onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + this._ensureFocus(true); + } + + /** + * Handle `update-request` messages sent to the widget. + */ + protected onUpdateRequest(msg: Message): void { + super.onUpdateRequest(msg); + const activeCell = this.activeCell; + + // Set the appropriate classes on the cells. + if (this.mode === 'edit') { + this.addClass(EDIT_CLASS); + this.removeClass(COMMAND_CLASS); + } else { + this.addClass(COMMAND_CLASS); + this.removeClass(EDIT_CLASS); + } + + let count = 0; + for (const widget of this.widgets) { + // Set tabIndex to -1 to allow calling .focus() on cell without allowing + // focus via tab key. This allows focus (document.activeElement) to move + // up and down the document, cell by cell, when the user presses J/K or + // ArrowDown/ArrowUp, but (unlike tabIndex = 0) does not add the notebook + // cells (which could be numerous) to the set of nodes that the user would + // have to visit when pressing the tab key to move about the UI. + // NOTE: we need to be very careful to avoid modifying DOM to avoid triggering layout on scroll + if (widget === activeCell) { + activeCell.addClass(ACTIVE_CLASS); + activeCell.addClass(SELECTED_CLASS); + // Set tab index to 0 on the active cell so that if the user tabs away from + // the notebook then tabs back, they will return to the cell where they + // left off. + activeCell.node.tabIndex = 0; + } else { + widget.node.tabIndex = -1; + widget.removeClass(ACTIVE_CLASS); + widget.removeClass(OTHER_SELECTED_CLASS); + } + + if (this.isSelectedOrActive(widget)) { + widget.addClass(SELECTED_CLASS); + count++; + } else { + widget.removeClass(SELECTED_CLASS); + } + } + + if (activeCell && count > 1) { + activeCell.addClass(OTHER_SELECTED_CLASS); + } + } + + /** + * Handle a cell being inserted. + */ + protected onCellInserted(index: number, cell: Cell): void { + void cell.ready.then(() => { + if (!cell.isDisposed) { + cell.editor!.edgeRequested.connect(this._onEdgeRequest, this); + } + }); + cell.scrollRequested.connect((_emitter, scrollRequest) => { + if (cell !== this.activeCell) { + // Do nothing for cells other than the active cell + // to avoid scroll requests from editor extensions + // stealing user focus (this may be revisited). + return; + } + if (!scrollRequest.defaultPrevented) { + // Nothing to do if scroll request was already handled. + return; + } + // Node which allows to scroll the notebook + const scroller = this.outerNode; + + if (cell.inViewport) { + // If cell got scrolled to the viewport in the meantime, + // proceed with scrolling within the cell. + return scrollRequest.scrollWithinCell({ scroller }); + } + // If cell is not in the viewport and needs scrolling, + // first scroll to the cell and then scroll within the cell. + this.scrollToItem(this.activeCellIndex) + .then(() => { + void cell.ready.then(() => { + scrollRequest.scrollWithinCell({ scroller }); + }); + }) + .catch(reason => { + // no-op + }); + }); + // If the insertion happened above, increment the active cell + // index, otherwise it stays the same. + this.activeCellIndex = + index <= this.activeCellIndex + ? this.activeCellIndex + 1 + : this.activeCellIndex; + } + + /** + * Handle a cell being removed. + */ + protected onCellRemoved(index: number, cell: Cell): void { + // If the removal happened above, decrement the active + // cell index, otherwise it stays the same. + this.activeCellIndex = + index <= this.activeCellIndex + ? this.activeCellIndex - 1 + : this.activeCellIndex; + if (this.isSelected(cell)) { + this._selectionChanged.emit(void 0); + } + } + + /** + * Handle a new model. + */ + protected onModelChanged( + oldValue: INotebookModel, + newValue: INotebookModel + ): void { + super.onModelChanged(oldValue, newValue); + + // Try to set the active cell index to 0. + // It will be set to `-1` if there is no new model or the model is empty. + this.activeCellIndex = 0; + } + + /** + * Handle edge request signals from cells. + */ + private _onEdgeRequest( + editor: CodeEditor.IEditor, + location: CodeEditor.EdgeLocation + ): void { + const prev = this.activeCellIndex; + if (location === 'top') { + this.activeCellIndex--; + // Move the cursor to the first position on the last line. + if (this.activeCellIndex < prev) { + const editor = this.activeCell!.editor; + if (editor) { + const lastLine = editor.lineCount - 1; + editor.setCursorPosition({ line: lastLine, column: 0 }); + } + } + } else if (location === 'bottom') { + this.activeCellIndex++; + // Move the cursor to the first character. + if (this.activeCellIndex > prev) { + const editor = this.activeCell!.editor; + if (editor) { + editor.setCursorPosition({ line: 0, column: 0 }); + } + } + } + this.mode = 'edit'; + } + + /** + * Ensure that the notebook has proper focus. + */ + private _ensureFocus(force = false): void { + // No-op is the footer has the focus. + const footer = (this.layout as NotebookWindowedLayout).footer; + if (footer && document.activeElement === footer.node) { + return; + } + const activeCell = this.activeCell; + if (this.mode === 'edit' && activeCell) { + // Test for !== true to cover hasFocus is false and editor is not yet rendered. + if (activeCell.editor?.hasFocus() !== true) { + if (activeCell.inViewport) { + activeCell.editor?.focus(); + } else { + this.scrollToItem(this.activeCellIndex) + .then(() => { + void activeCell.ready.then(() => { + activeCell.editor?.focus(); + }); + }) + .catch(reason => { + // no-op + }); + } + } + } + if ( + force && + activeCell && + !activeCell.node.contains(document.activeElement) + ) { + void NotebookActions.focusActiveCell(this, { + preventScroll: true + }); + } + } + + /** + * Find the cell index containing the target html element. + * + * #### Notes + * Returns -1 if the cell is not found. + */ + private _findCell(node: HTMLElement): number { + // Trace up the DOM hierarchy to find the root cell node. + // Then find the corresponding child and select it. + let n: HTMLElement | null = node; + while (n && n !== this.node) { + if (n.classList.contains(NB_CELL_CLASS)) { + const i = ArrayExt.findFirstIndex( + this.widgets, + widget => widget.node === n + ); + if (i !== -1) { + return i; + } + break; + } + n = n.parentElement; + } + return -1; + } + + /** + * Find the target of html mouse event and cell index containing this target. + * + * #### Notes + * Returned index is -1 if the cell is not found. + */ + private _findEventTargetAndCell(event: MouseEvent): [HTMLElement, number] { + let target = event.target as HTMLElement; + let index = this._findCell(target); + if (index === -1) { + // `event.target` sometimes gives an orphaned node in Firefox 57, which + // can have `null` anywhere in its parent line. If we fail to find a cell + // using `event.target`, try again using a target reconstructed from the + // position of the click event. + target = document.elementFromPoint( + event.clientX, + event.clientY + ) as HTMLElement; + index = this._findCell(target); + } + return [target, index]; + } + + /** + * Find heading with given ID in any of the cells. + */ + async _findHeading(queryId: string): Promise { + // Loop on cells, get headings and search for first matching id. + for (let cellIdx = 0; cellIdx < this.widgets.length; cellIdx++) { + const cell = this.widgets[cellIdx]; + if ( + cell.model.type === 'raw' || + (cell.model.type === 'markdown' && !(cell as MarkdownCell).rendered) + ) { + // Bail early + continue; + } + for (const heading of cell.headings) { + let id: string | undefined | null = ''; + switch (heading.type) { + case Cell.HeadingType.HTML: + id = (heading as TableOfContentsUtils.IHTMLHeading).id; + break; + case Cell.HeadingType.Markdown: + { + const mdHeading = + heading as any as TableOfContentsUtils.Markdown.IMarkdownHeading; + id = await TableOfContentsUtils.Markdown.getHeadingId( + this.rendermime.markdownParser!, + mdHeading.raw, + mdHeading.level, + this.rendermime.sanitizer + ); + } + break; + } + if (id === queryId) { + const element = this.node.querySelector( + `h${heading.level}[id="${CSS.escape(id)}"]` + ) as HTMLElement; + + return { + cell, + element + }; + } + } + } + return null; + } + + /** + * Find cell by its unique ID. + */ + _findCellById(queryId: string): Private.IScrollTarget | null { + for (let cellIdx = 0; cellIdx < this.widgets.length; cellIdx++) { + const cell = this.widgets[cellIdx]; + if (cell.model.id === queryId) { + return { + cell + }; + } + } + return null; + } + + /** + * Handle `contextmenu` event. + */ + private _evtContextMenuCapture(event: PointerEvent): void { + // Allow the event to propagate un-modified if the user + // is holding the shift-key (and probably requesting + // the native context menu). + if (event.shiftKey) { + return; + } + + const [target, index] = this._findEventTargetAndCell(event); + const widget = this.widgets[index]; + + if (widget && widget.editorWidget?.node.contains(target)) { + // Prevent CodeMirror from focusing the editor. + // TODO: find an editor-agnostic solution. + event.preventDefault(); + } + } + + /** + * Handle `mousedown` event in the capture phase for the widget. + */ + private _evtMouseDownCapture(event: MouseEvent): void { + const { button, shiftKey } = event; + + const [target, index] = this._findEventTargetAndCell(event); + const widget = this.widgets[index]; + + // On OS X, the context menu may be triggered with ctrl-left-click. In + // Firefox, ctrl-left-click gives an event with button 2, but in Chrome, + // ctrl-left-click gives an event with button 0 with the ctrl modifier. + if ( + button === 2 && + !shiftKey && + widget && + widget.editorWidget?.node.contains(target) + ) { + this.mode = 'command'; + + // Prevent CodeMirror from focusing the editor. + // TODO: find an editor-agnostic solution. + event.preventDefault(); + } + } + + /** + * Handle `mousedown` events for the widget. + */ + private _evtMouseDown(event: MouseEvent): void { + const { button, shiftKey } = event; + + // We only handle main or secondary button actions. + if (!(button === 0 || button === 2)) { + return; + } + + // Shift right-click gives the browser default behavior. + if (shiftKey && button === 2) { + return; + } + + const [target, index] = this._findEventTargetAndCell(event); + const widget = this.widgets[index]; + + let targetArea: 'input' | 'prompt' | 'cell' | 'notebook'; + if (widget) { + if (widget.editorWidget?.node.contains(target)) { + targetArea = 'input'; + } else if (widget.promptNode?.contains(target)) { + targetArea = 'prompt'; + } else { + targetArea = 'cell'; + } + } else { + targetArea = 'notebook'; + } + + // Make sure we go to command mode if the click isn't in the cell editor If + // we do click in the cell editor, the editor handles the focus event to + // switch to edit mode. + if (targetArea !== 'input') { + this.mode = 'command'; + } + + if (targetArea === 'notebook') { + this.deselectAll(); + } else if (targetArea === 'prompt' || targetArea === 'cell') { + // We don't want to prevent the default selection behavior + // if there is currently text selected in an output. + const hasSelection = (window.getSelection() ?? '').toString() !== ''; + if ( + button === 0 && + shiftKey && + !hasSelection && + !['INPUT', 'OPTION'].includes(target.tagName) + ) { + // Prevent browser selecting text in prompt or output + event.preventDefault(); + + // Shift-click - extend selection + try { + this.extendContiguousSelectionTo(index); + } catch (e) { + console.error(e); + this.deselectAll(); + return; + } + // Enter selecting mode + this._mouseMode = 'select'; + + // We don't want to block the shift-click mouse up handler + // when the current cell is (and remains) the active cell. + this._selectData = { + startedOnActiveCell: index == this.activeCellIndex, + startingCellIndex: this.activeCellIndex + }; + document.addEventListener('mouseup', this, true); + document.addEventListener('mousemove', this, true); + } else if (button === 0 && !shiftKey) { + // Prepare to start a drag if we are on the drag region. + if (targetArea === 'prompt') { + // Prepare for a drag start + this._dragData = { + pressX: event.clientX, + pressY: event.clientY, + index: index + }; + + // Enter possible drag mode + this._mouseMode = 'couldDrag'; + document.addEventListener('mouseup', this, true); + document.addEventListener('mousemove', this, true); + event.preventDefault(); + } + + if (!this.isSelectedOrActive(widget)) { + this.deselectAll(); + this.activeCellIndex = index; + } + } else if (button === 2) { + if (!this.isSelectedOrActive(widget)) { + this.deselectAll(); + this.activeCellIndex = index; + } + event.preventDefault(); + } + } else if (targetArea === 'input') { + if (button === 2 && !this.isSelectedOrActive(widget)) { + this.deselectAll(); + this.activeCellIndex = index; + } + } + + // If we didn't set focus above, make sure we get focus now. + this._ensureFocus(true); + } + + /** + * Handle the `'mouseup'` event on the document. + */ + private _evtDocumentMouseup(event: MouseEvent): void { + const [, index] = this._findEventTargetAndCell(event); + + let shouldPreventDefault = true; + if (this._mouseMode === 'select' && this._selectData) { + // User did not move the mouse over to a difference cell, so there was no selection + const { startedOnActiveCell, startingCellIndex } = this._selectData; + if (startedOnActiveCell && index === startingCellIndex) { + shouldPreventDefault = false; + } + this._selectData = null; + } + if (shouldPreventDefault) { + event.preventDefault(); + event.stopPropagation(); + } + + // Remove the event listeners we put on the document + document.removeEventListener('mousemove', this, true); + document.removeEventListener('mouseup', this, true); + + if (this._mouseMode === 'couldDrag') { + // We didn't end up dragging if we are here, so treat it as a click event. + + this.deselectAll(); + this.activeCellIndex = index; + // Focus notebook if active cell changes but does not have focus. + if (!this.activeCell!.node.contains(document.activeElement)) { + void NotebookActions.focusActiveCell(this); + } + } + + this._mouseMode = null; + } + + /** + * Handle the `'mousemove'` event for the widget. + */ + private _evtDocumentMousemove(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + + // If in select mode, update the selection + switch (this._mouseMode) { + case 'select': { + const target = event.target as HTMLElement; + const index = this._findCell(target); + if (index !== -1) { + this.extendContiguousSelectionTo(index); + } + break; + } + case 'couldDrag': { + // Check for a drag initialization. + const data = this._dragData!; + const dx = Math.abs(event.clientX - data.pressX); + const dy = Math.abs(event.clientY - data.pressY); + if (dx >= DRAG_THRESHOLD || dy >= DRAG_THRESHOLD) { + this._mouseMode = null; + this._startDrag(data.index, event.clientX, event.clientY); + } + break; + } + default: + break; + } + } + + /** + * Handle the `'lm-dragenter'` event for the widget. + */ + private _evtDragEnter(event: Drag.Event): void { + if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const target = event.target as HTMLElement; + const index = this._findCell(target); + if (index === -1) { + return; + } + + const widget = this.cellsArray[index]; + widget.node.classList.add(DROP_TARGET_CLASS); + } + + /** + * Handle the `'lm-dragleave'` event for the widget. + */ + private _evtDragLeave(event: Drag.Event): void { + if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const elements = this.node.getElementsByClassName(DROP_TARGET_CLASS); + if (elements.length) { + (elements[0] as HTMLElement).classList.remove(DROP_TARGET_CLASS); + } + } + + /** + * Handle the `'lm-dragover'` event for the widget. + */ + private _evtDragOver(event: Drag.Event): void { + if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dropAction = event.proposedAction; + const elements = this.node.getElementsByClassName(DROP_TARGET_CLASS); + if (elements.length) { + (elements[0] as HTMLElement).classList.remove(DROP_TARGET_CLASS); + } + const target = event.target as HTMLElement; + const index = this._findCell(target); + if (index === -1) { + return; + } + const widget = this.cellsArray[index]; + widget.node.classList.add(DROP_TARGET_CLASS); + } + + /** + * Handle the `'lm-drop'` event for the widget. + */ + private _evtDrop(event: Drag.Event): void { + if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.proposedAction === 'none') { + event.dropAction = 'none'; + return; + } + + let target = event.target as HTMLElement; + while (target && target.parentElement) { + if (target.classList.contains(DROP_TARGET_CLASS)) { + target.classList.remove(DROP_TARGET_CLASS); + break; + } + target = target.parentElement; + } + + // Model presence should be checked before calling event handlers + const model = this.model!; + + const source: Notebook = event.source; + if (source === this) { + // Handle the case where we are moving cells within + // the same notebook. + event.dropAction = 'move'; + const toMove: Cell[] = event.mimeData.getData('internal:cells'); + + // For collapsed markdown headings with hidden "child" cells, move all + // child cells as well as the markdown heading. + const cell = toMove[toMove.length - 1]; + if (cell instanceof MarkdownCell && cell.headingCollapsed) { + const nextParent = NotebookActions.findNextParentHeading(cell, source); + if (nextParent > 0) { + const index = findIndex(source.widgets, (possibleCell: Cell) => { + return cell.model.id === possibleCell.model.id; + }); + toMove.push(...source.widgets.slice(index + 1, nextParent)); + } + } + + // Compute the to/from indices for the move. + let fromIndex = ArrayExt.firstIndexOf(this.widgets, toMove[0]); + let toIndex = this._findCell(target); + // This check is needed for consistency with the view. + if (toIndex !== -1 && toIndex > fromIndex) { + toIndex -= 1; + } else if (toIndex === -1) { + // If the drop is within the notebook but not on any cell, + // most often this means it is past the cell areas, so + // set it to move the cells to the end of the notebook. + toIndex = this.widgets.length - 1; + } + // Don't move if we are within the block of selected cells. + if (toIndex >= fromIndex && toIndex < fromIndex + toMove.length) { + return; + } + + // Move the cells one by one + this.moveCell(fromIndex, toIndex, toMove.length); + } else { + // Handle the case where we are copying cells between + // notebooks. + event.dropAction = 'copy'; + // Find the target cell and insert the copied cells. + let index = this._findCell(target); + if (index === -1) { + index = this.widgets.length; + } + const start = index; + const values = event.mimeData.getData(JUPYTER_CELL_MIME); + // Insert the copies of the original cells. + // We preserve trust status of pasted cells by not modifying metadata. + model.sharedModel.insertCells(index, values); + // Select the inserted cells. + this.deselectAll(); + this.activeCellIndex = start; + this.extendContiguousSelectionTo(index - 1); + } + void NotebookActions.focusActiveCell(this); + } + + /** + * Start a drag event. + */ + private _startDrag(index: number, clientX: number, clientY: number): void { + const cells = this.model!.cells; + const selected: nbformat.ICell[] = []; + const toMove: Cell[] = []; + let i = -1; + for (const widget of this.widgets) { + const cell = cells.get(++i); + if (this.isSelectedOrActive(widget)) { + widget.addClass(DROP_SOURCE_CLASS); + selected.push(cell.toJSON()); + toMove.push(widget); + } + } + const activeCell = this.activeCell; + let dragImage: HTMLElement | null = null; + let countString: string; + if (activeCell?.model.type === 'code') { + const executionCount = (activeCell.model as ICodeCellModel) + .executionCount; + countString = ' '; + if (executionCount) { + countString = executionCount.toString(); + } + } else { + countString = ''; + } + + // Create the drag image. + dragImage = Private.createDragImage( + selected.length, + countString, + activeCell?.model.sharedModel.getSource().split('\n')[0].slice(0, 26) ?? + '' + ); + + // Set up the drag event. + this._drag = new Drag({ + mimeData: new MimeData(), + dragImage, + supportedActions: 'copy-move', + proposedAction: 'copy', + source: this + }); + this._drag.mimeData.setData(JUPYTER_CELL_MIME, selected); + // Add mimeData for the fully reified cell widgets, for the + // case where the target is in the same notebook and we + // can just move the cells. + this._drag.mimeData.setData('internal:cells', toMove); + // Add mimeData for the text content of the selected cells, + // allowing for drag/drop into plain text fields. + const textContent = toMove + .map(cell => cell.model.sharedModel.getSource()) + .join('\n'); + this._drag.mimeData.setData('text/plain', textContent); + + // Remove mousemove and mouseup listeners and start the drag. + document.removeEventListener('mousemove', this, true); + document.removeEventListener('mouseup', this, true); + this._mouseMode = null; + void this._drag.start(clientX, clientY).then(action => { + if (this.isDisposed) { + return; + } + this._drag = null; + for (const widget of toMove) { + widget.removeClass(DROP_SOURCE_CLASS); + } + }); + } + + /** + * Update the notebook node with class indicating read-write state. + */ + private _updateReadWrite(): void { + const inReadWrite = DOMUtils.hasActiveEditableElement(this.node); + this.node.classList.toggle(READ_WRITE_CLASS, inReadWrite); + } + + /** + * Handle `focus` events for the widget. + */ + private _evtFocusIn(event: FocusEvent): void { + // Update read-write class state. + this._updateReadWrite(); + + const target = event.target as HTMLElement; + const index = this._findCell(target); + if (index !== -1) { + const widget = this.widgets[index]; + // If the editor itself does not have focus, ensure command mode. + if (widget.editorWidget && !widget.editorWidget.node.contains(target)) { + this.setMode('command', { focus: false }); + } + + // Cell index needs to be updated before changing mode, + // otherwise the previous cell may get un-rendered. + this.activeCellIndex = index; + + // If the editor has focus, ensure edit mode. + const node = widget.editorWidget?.node; + if (node?.contains(target)) { + this.setMode('edit', { focus: false }); + } + } else { + // No cell has focus, ensure command mode. + this.setMode('command', { focus: false }); + + // Prevents the parent element to get the focus. + event.preventDefault(); + + // Check if the focus was previously in the active cell to avoid focus looping + // between the cell and the cell toolbar. + const source = event.relatedTarget as HTMLElement; + + // Focuses on the active cell if the focus did not come from it. + // Otherwise focus on the footer element (add cell button). + if (this._activeCell && !this._activeCell.node.contains(source)) { + this._activeCell.ready + .then(() => { + this._activeCell?.node.focus({ + preventScroll: true + }); + }) + .catch(() => { + (this.layout as NotebookWindowedLayout).footer?.node.focus({ + preventScroll: true + }); + }); + } else { + (this.layout as NotebookWindowedLayout).footer?.node.focus({ + preventScroll: true + }); + } + } + } + + /** + * Handle `focusout` events for the notebook. + */ + private _evtFocusOut(event: FocusEvent): void { + // Update read-write class state. + this._updateReadWrite(); + + const relatedTarget = event.relatedTarget as HTMLElement; + + // Bail if the window is losing focus, to preserve edit mode. This test + // assumes that we explicitly focus things rather than calling blur() + if (!relatedTarget) { + return; + } + + // Bail if the item gaining focus is another cell, + // and we should not be entering command mode. + const index = this._findCell(relatedTarget); + if (index !== -1) { + const widget = this.widgets[index]; + if (widget.editorWidget?.node.contains(relatedTarget)) { + return; + } + } + + // Otherwise enter command mode if not already. + if (this.mode !== 'command') { + this.setMode('command', { focus: false }); + } + } + + /** + * Handle `dblclick` events for the widget. + */ + private _evtDblClick(event: MouseEvent): void { + const model = this.model; + if (!model) { + return; + } + this.deselectAll(); + + const [target, index] = this._findEventTargetAndCell(event); + + if ( + (event.target as HTMLElement).classList.contains(HEADING_COLLAPSER_CLASS) + ) { + return; + } + if (index === -1) { + return; + } + this.activeCellIndex = index; + if (model.cells.get(index).type === 'markdown') { + const widget = this.widgets[index] as MarkdownCell; + widget.rendered = false; + } else if (target.localName === 'img') { + target.classList.toggle(UNCONFINED_CLASS); + } + } + + /** + * Remove selections from inactive cells to avoid + * spurious cursors. + */ + private _trimSelections(): void { + for (let i = 0; i < this.widgets.length; i++) { + if (i !== this._activeCellIndex) { + const cell = this.widgets[i]; + if (!cell.model.isDisposed && cell.editor) { + cell.model.selections.delete(cell.editor.uuid); + } + } + } + } + + private _activeCellIndex = -1; + private _activeCell: Cell | null = null; + private _mode: NotebookMode = 'command'; + private _drag: Drag | null = null; + private _dragData: { + pressX: number; + pressY: number; + index: number; + } | null = null; + private _selectData: { + startedOnActiveCell: boolean; + startingCellIndex: number; + } | null = null; + private _mouseMode: 'select' | 'couldDrag' | null = null; + private _activeCellChanged = new Signal(this); + private _stateChanged = new Signal>(this); + private _selectionChanged = new Signal(this); + + // Attributes for optimized cell refresh: + private _cellLayoutStateCache?: { width: number }; + private _checkCacheOnNextResize = false; + + private _lastClipboardInteraction: 'copy' | 'cut' | 'paste' | null = null; + private _updateSelectedCells(): void { + this._selectedCells = this.widgets.filter(cell => + this.isSelectedOrActive(cell) + ); + if (this.kernelHistory) { + this.kernelHistory.reset(); + } + } + private _selectedCells: Cell[] = []; +} + +/** + * The namespace for the `Notebook` class statics. + */ +export namespace Notebook { + /** + * An options object for initializing a notebook widget. + */ + export interface IOptions extends StaticNotebook.IOptions {} + + /** + * The content factory for the notebook widget. + */ + export interface IContentFactory extends StaticNotebook.IContentFactory {} + + /** + * The default implementation of a notebook content factory.. + * + * #### Notes + * Override methods on this class to customize the default notebook factory + * methods that create notebook content. + */ + export class ContentFactory extends StaticNotebook.ContentFactory {} + + /** + * A namespace for the notebook content factory. + */ + export namespace ContentFactory { + /** + * An options object for initializing a notebook content factory. + */ + export interface IOptions extends StaticNotebook.ContentFactory.IOptions {} + } +} + +/** + * A namespace for private data. + */ +namespace Private { + /** + * An attached property for the selected state of a cell. + */ + export const selectedProperty = new AttachedProperty({ + name: 'selected', + create: () => false + }); + + /** + * A custom panel layout for the notebook. + */ + export class NotebookPanelLayout extends PanelLayout { + /** + * A message handler invoked on an `'update-request'` message. + * + * #### Notes + * This is a reimplementation of the base class method, + * and is a no-op. + */ + protected onUpdateRequest(msg: Message): void { + // This is a no-op. + } + } + + /** + * Create a cell drag image. + */ + export function createDragImage( + count: number, + promptNumber: string, + cellContent: string + ): HTMLElement { + if (count > 1) { + if (promptNumber !== '') { + return VirtualDOM.realize( + h.div( + h.div( + { className: DRAG_IMAGE_CLASS }, + h.span( + { className: CELL_DRAG_PROMPT_CLASS }, + '[' + promptNumber + ']:' + ), + h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent) + ), + h.div({ className: CELL_DRAG_MULTIPLE_BACK }, '') + ) + ); + } else { + return VirtualDOM.realize( + h.div( + h.div( + { className: DRAG_IMAGE_CLASS }, + h.span({ className: CELL_DRAG_PROMPT_CLASS }), + h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent) + ), + h.div({ className: CELL_DRAG_MULTIPLE_BACK }, '') + ) + ); + } + } else { + if (promptNumber !== '') { + return VirtualDOM.realize( + h.div( + h.div( + { className: `${DRAG_IMAGE_CLASS} ${SINGLE_DRAG_IMAGE_CLASS}` }, + h.span( + { className: CELL_DRAG_PROMPT_CLASS }, + '[' + promptNumber + ']:' + ), + h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent) + ) + ) + ); + } else { + return VirtualDOM.realize( + h.div( + h.div( + { className: `${DRAG_IMAGE_CLASS} ${SINGLE_DRAG_IMAGE_CLASS}` }, + h.span({ className: CELL_DRAG_PROMPT_CLASS }), + h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent) + ) + ) + ); + } + } + } + + /** + * Information about resolved scroll target defined by URL fragment. + */ + export interface IScrollTarget { + /** + * Target cell. + */ + cell: Cell; + /** + * Element to scroll to within the cell. + */ + element?: HTMLElement; + } + + /** + * Parsed fragment identifier data. + */ + export interface IFragmentData { + /** + * The kind of notebook element targeted by the fragment identifier. + */ + kind: 'heading' | 'cell-id'; + /* + * The value of the fragment query. + */ + value: string; + } +} diff --git a/.yalc/@jupyterlab/notebook/src/widgetfactory.ts b/.yalc/@jupyterlab/notebook/src/widgetfactory.ts new file mode 100644 index 0000000000..09411e40fa --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/widgetfactory.ts @@ -0,0 +1,173 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { IEditorMimeTypeService } from '@jupyterlab/codeeditor'; +import { ABCWidgetFactory, DocumentRegistry } from '@jupyterlab/docregistry'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import { ITranslator } from '@jupyterlab/translation'; +import { INotebookModel } from './model'; +import { NotebookPanel } from './panel'; +import { StaticNotebook } from './widget'; +import { NotebookHistory } from './history'; + +/** + * A widget factory for notebook panels. + */ +export class NotebookWidgetFactory extends ABCWidgetFactory< + NotebookPanel, + INotebookModel +> { + /** + * Construct a new notebook widget factory. + * + * @param options - The options used to construct the factory. + */ + constructor(options: NotebookWidgetFactory.IOptions) { + super(options); + this.rendermime = options.rendermime; + this.contentFactory = options.contentFactory; + this.mimeTypeService = options.mimeTypeService; + this._editorConfig = + options.editorConfig || StaticNotebook.defaultEditorConfig; + this._notebookConfig = + options.notebookConfig || StaticNotebook.defaultNotebookConfig; + } + + /* + * The rendermime instance. + */ + readonly rendermime: IRenderMimeRegistry; + + /** + * The content factory used by the widget factory. + */ + readonly contentFactory: NotebookPanel.IContentFactory; + + /** + * The service used to look up mime types. + */ + readonly mimeTypeService: IEditorMimeTypeService; + + /** + * A configuration object for cell editor settings. + */ + get editorConfig(): StaticNotebook.IEditorConfig { + return this._editorConfig; + } + set editorConfig(value: StaticNotebook.IEditorConfig) { + this._editorConfig = value; + } + + /** + * A configuration object for notebook settings. + */ + get notebookConfig(): StaticNotebook.INotebookConfig { + return this._notebookConfig; + } + set notebookConfig(value: StaticNotebook.INotebookConfig) { + this._notebookConfig = value; + } + + /** + * Create a new widget. + * + * #### Notes + * The factory will start the appropriate kernel. + */ + protected createNewWidget( + context: DocumentRegistry.IContext, + source?: NotebookPanel + ): NotebookPanel { + const translator = (context as any).translator; + const kernelHistory = new NotebookHistory({ + sessionContext: context.sessionContext, + translator: translator + }); + const nbOptions = { + rendermime: source + ? source.content.rendermime + : this.rendermime.clone({ resolver: context.urlResolver }), + contentFactory: this.contentFactory, + mimeTypeService: this.mimeTypeService, + editorConfig: source ? source.content.editorConfig : this._editorConfig, + notebookConfig: source + ? source.content.notebookConfig + : this._notebookConfig, + translator, + kernelHistory + }; + const content = this.contentFactory.createNotebook(nbOptions); + + return new NotebookPanel({ context, content }); + } + + private _editorConfig: StaticNotebook.IEditorConfig; + private _notebookConfig: StaticNotebook.INotebookConfig; +} + +/** + * The namespace for `NotebookWidgetFactory` statics. + */ +export namespace NotebookWidgetFactory { + /** + * The options used to construct a `NotebookWidgetFactory`. + */ + export interface IOptions + extends DocumentRegistry.IWidgetFactoryOptions { + /* + * A rendermime instance. + */ + rendermime: IRenderMimeRegistry; + + /** + * A notebook panel content factory. + */ + contentFactory: NotebookPanel.IContentFactory; + + /** + * The service used to look up mime types. + */ + mimeTypeService: IEditorMimeTypeService; + + /** + * The notebook cell editor configuration. + */ + editorConfig?: StaticNotebook.IEditorConfig; + + /** + * The notebook configuration. + */ + notebookConfig?: StaticNotebook.INotebookConfig; + + /** + * The application language translator. + */ + translator?: ITranslator; + } + + /** + * The interface for a notebook widget factory. + */ + export interface IFactory + extends DocumentRegistry.IWidgetFactory { + /** + * Whether to automatically start the preferred kernel. + */ + autoStartDefault: boolean; + + /** + * A configuration object for cell editor settings. + */ + editorConfig: StaticNotebook.IEditorConfig; + + /** + * A configuration object for notebook settings. + */ + notebookConfig: StaticNotebook.INotebookConfig; + + /** + * Whether the kernel should be shutdown when the widget is closed. + */ + shutdownOnClose: boolean; + } +} diff --git a/.yalc/@jupyterlab/notebook/src/windowing.ts b/.yalc/@jupyterlab/notebook/src/windowing.ts new file mode 100644 index 0000000000..c279ac9f10 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/src/windowing.ts @@ -0,0 +1,640 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Cell, CodeCell, CodeCellModel } from '@jupyterlab/cells'; +import { + WindowedLayout, + WindowedList, + WindowedListModel +} from '@jupyterlab/ui-components'; +import { Message, MessageLoop } from '@lumino/messaging'; +import { Debouncer, Throttler } from '@lumino/polling'; +import { Widget } from '@lumino/widgets'; +import { DROP_SOURCE_CLASS, DROP_TARGET_CLASS } from './constants'; + +/** + * Check whether the element is in a scrolling notebook. + * Traverses open shadow DOM roots if needed. + */ +function isInScrollingNotebook(element: Element | null): boolean { + if (!element) { + return false; + } + const notebook = element.closest('.jp-WindowedPanel-viewport') as + | HTMLElement + | undefined; + if (notebook && notebook.dataset.isScrolling == 'true') { + return true; + } + const root = element.getRootNode(); + return !!( + root && + root instanceof ShadowRoot && + isInScrollingNotebook(root.host) + ); +} + +/** + * Subclass IntersectionObserver to allow suspending callbacks when notebook is scrolling. + */ +window.IntersectionObserver = class extends window.IntersectionObserver { + constructor( + protected callback: IntersectionObserverCallback, + options: IntersectionObserverInit + ) { + super(entries => { + this._delayCallbackInScrollingNotebook(entries); + }, options); + this._throttler = new Throttler( + entries => { + // keep delaying until no longer in scrolling notebook + this._delayCallbackInScrollingNotebook(entries); + }, + { limit: 1000, edge: 'trailing' } + ); + } + + private _delayCallbackInScrollingNotebook = ( + entries: IntersectionObserverEntry[] + ) => { + const entriesInScrollingNotebook = []; + const nonOutputEntries = []; + for (const entry of entries) { + if (isInScrollingNotebook(entry.target)) { + entriesInScrollingNotebook.push(entry); + } else { + nonOutputEntries.push(entry); + } + } + if (nonOutputEntries.length) { + this.callback(nonOutputEntries, this); + } + if (entriesInScrollingNotebook.length) { + void this._throttler.invoke(entriesInScrollingNotebook); + } + }; + private _throttler: Throttler; +}; + +/** + * Subclass ResizeObserver to allow suspending callbacks when notebook is scrolling. + */ +window.ResizeObserver = class extends window.ResizeObserver { + constructor(protected callback: ResizeObserverCallback) { + super(entries => { + this._delayCallbackInScrollingNotebook(entries); + }); + this._throttler = new Throttler( + entries => { + // keep delaying until no longer in scrolling notebook + this._delayCallbackInScrollingNotebook(entries); + }, + { limit: 1000, edge: 'trailing' } + ); + } + + private _delayCallbackInScrollingNotebook = ( + entries: ResizeObserverEntry[] + ) => { + const entriesInScrollingNotebook = []; + const nonOutputEntries = []; + for (const entry of entries) { + if (isInScrollingNotebook(entry.target)) { + entriesInScrollingNotebook.push(entry); + } else { + nonOutputEntries.push(entry); + } + } + if (nonOutputEntries.length) { + this.callback(nonOutputEntries, this); + } + if (entriesInScrollingNotebook.length) { + void this._throttler.invoke(entriesInScrollingNotebook); + } + }; + private _throttler: Throttler; +}; + +/** + * Notebook view model for the windowed list. + */ +export class NotebookViewModel extends WindowedListModel { + /** + * Default cell height + */ + static DEFAULT_CELL_SIZE = 39; + /** + * Default editor line height + */ + static DEFAULT_EDITOR_LINE_HEIGHT = 17; + /** + * Default cell margin (top + bottom) + */ + static DEFAULT_CELL_MARGIN = 22; + + /** + * Construct a notebook windowed list model. + */ + constructor( + protected cells: Cell[], + options?: WindowedList.IModelOptions + ) { + super(options); + // Set default cell size + this._estimatedWidgetSize = NotebookViewModel.DEFAULT_CELL_SIZE; + } + + /** + * Cell size estimator + * + * @param index Cell index + * @returns Cell height in pixels + */ + estimateWidgetSize = (index: number): number => { + const cell = this.cells[index]; + if (!cell) { + // This should not happen, but if it does, + // do not throw if cell was deleted in the meantime + console.warn( + `estimateWidgetSize requested for cell ${index} in notebook with only ${this.cells.length} cells` + ); + return 0; + } + const model = cell.model; + const height = this.cellsEstimatedHeight.get(model.id); + if (typeof height === 'number') { + return height; + } + + const nLines = model.sharedModel.getSource().split('\n').length; + let outputsLines = 0; + if (model instanceof CodeCellModel && !model.isDisposed) { + for (let outputIdx = 0; outputIdx < model.outputs.length; outputIdx++) { + const output = model.outputs.get(outputIdx); + const data = output.data['text/plain']; + if (typeof data === 'string') { + outputsLines += data.split('\n').length; + } else if (Array.isArray(data)) { + outputsLines += data.join('').split('\n').length; + } + } + } + return ( + NotebookViewModel.DEFAULT_EDITOR_LINE_HEIGHT * (nLines + outputsLines) + + NotebookViewModel.DEFAULT_CELL_MARGIN + ); + }; + + /** + * Set an estimated height for a cell + * + * @param cellId Cell ID + * @param size Cell height + */ + setEstimatedWidgetSize(cellId: string, size: number | null): void { + if (size === null) { + if (this.cellsEstimatedHeight.has(cellId)) { + this.cellsEstimatedHeight.delete(cellId); + } + } else { + this.cellsEstimatedHeight.set(cellId, size); + this._emitEstimatedHeightChanged.invoke().catch(error => { + console.error( + 'Fail to trigger an update following a estimated height update.', + error + ); + }); + } + } + + /** + * Render the cell at index. + * + * @param index Cell index + * @returns Cell widget + */ + widgetRenderer = (index: number): Widget => { + return this.cells[index]; + }; + + /** + * Threshold used to decide if the cell should be scrolled to in the `smart` mode. + * Defaults to scrolling when less than a full line of the cell is visible. + */ + readonly scrollDownThreshold = + NotebookViewModel.DEFAULT_CELL_MARGIN / 2 + + NotebookViewModel.DEFAULT_EDITOR_LINE_HEIGHT; + + /** + * Threshold used to decide if the cell should be scrolled to in the `smart` mode. + * Defaults to scrolling when the cell margin or more is invisible. + */ + readonly scrollUpThreshold = NotebookViewModel.DEFAULT_CELL_MARGIN / 2; + + /** + * Mapping between the cell ids and the cell estimated heights + * + * This height is not refreshed with the changes to the document. + * It is only used to measure cells outside the viewport on CPU + * idle cycle to improve UX scrolling. + */ + protected cellsEstimatedHeight = new Map(); + + private _emitEstimatedHeightChanged = new Debouncer(() => { + this._stateChanged.emit({ + name: 'estimatedWidgetSize', + newValue: null, + oldValue: null + }); + }); +} + +/** + * Windowed list layout for the notebook. + */ +export class NotebookWindowedLayout extends WindowedLayout { + private _header: Widget | null = null; + private _footer: Widget | null = null; + + /** + * Notebook's header + */ + get header(): Widget | null { + return this._header; + } + set header(header: Widget | null) { + if (this._header && this._header.isAttached) { + Widget.detach(this._header); + } + this._header = header; + if (this._header && this.parent?.isAttached) { + Widget.attach(this._header, this.parent!.node); + } + } + + /** + * Notebook widget's footer + */ + get footer(): Widget | null { + return this._footer; + } + set footer(footer: Widget | null) { + if (this._footer && this._footer.isAttached) { + Widget.detach(this._footer); + } + this._footer = footer; + if (this._footer && this.parent?.isAttached) { + Widget.attach(this._footer, this.parent!.outerNode); + } + } + + /** + * Notebook's active cell + */ + get activeCell(): Widget | null { + return this._activeCell; + } + set activeCell(widget: Widget | null) { + this._activeCell = widget; + } + private _activeCell: Widget | null; + + /** + * Dispose the layout + * */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._header?.dispose(); + this._footer?.dispose(); + super.dispose(); + } + + /** + * * A message handler invoked on a `'child-removed'` message. + * * + * @param widget - The widget to remove from the layout. + * + * #### Notes + * A widget is automatically removed from the layout when its `parent` + * is set to `null`. This method should only be invoked directly when + * removing a widget from a layout which has yet to be installed on a + * parent widget. + * + * This method does *not* modify the widget's `parent`. + */ + removeWidget(widget: Widget): void { + const index = this.widgets.indexOf(widget); + // We need to deal with code cell widget not in viewport (aka not in this.widgets) but still + // partly attached + if (index >= 0) { + this.removeWidgetAt(index); + } // If the layout is parented, detach the widget from the DOM. + else if (widget === this._willBeRemoved && this.parent) { + this.detachWidget(index, widget); + } + } + + /** + * Attach a widget to the parent's DOM node. + * + * @param index - The current index of the widget in the layout. + * + * @param widget - The widget to attach to the parent. + * + * #### Notes + * This method is called automatically by the panel layout at the + * appropriate time. It should not be called directly by user code. + * + * The default implementation adds the widgets's node to the parent's + * node at the proper location, and sends the appropriate attach + * messages to the widget if the parent is attached to the DOM. + * + * Subclasses may reimplement this method to control how the widget's + * node is added to the parent's node. + */ + protected attachWidget(index: number, widget: Widget): void { + // Status may change in onBeforeAttach + const wasPlaceholder = (widget as Cell).isPlaceholder(); + // Initialized sub-widgets or attached them for CodeCell + // Because this reattaches all sub-widget to the DOM which leads + // to a loss of focus, we do not call it for soft-hidden cells. + const isSoftHidden = this._isSoftHidden(widget); + if (this.parent!.isAttached && !isSoftHidden) { + MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach); + } + if (isSoftHidden) { + // Restore visibility for active, or previously active cell + this._toggleSoftVisibility(widget, true); + } + if ( + !wasPlaceholder && + widget instanceof CodeCell && + widget.node.parentElement + ) { + // We don't remove code cells to preserve outputs internal state + widget.node.style.display = ''; + + // Reset cache + this._topHiddenCodeCells = -1; + } else if (!isSoftHidden) { + // Look up the next sibling reference node. + const siblingIndex = this._findNearestChildBinarySearch( + this.parent!.viewportNode.childElementCount - 1, + 0, + parseInt(widget.dataset.windowedListIndex!, 10) + 1 + ); + let ref = this.parent!.viewportNode.children[siblingIndex]; + + // Insert the widget's node before the sibling. + this.parent!.viewportNode.insertBefore(widget.node, ref); + + // Send an `'after-attach'` message if the parent is attached. + // Event listeners will be added here + // Some widgets are updating/resetting when attached, so + // we should not recall this each time a cell move into the + // viewport. + if (this.parent!.isAttached) { + MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach); + } + } + + (widget as Cell).inViewport = true; + } + + /** + * Detach a widget from the parent's DOM node. + * + * @param index - The previous index of the widget in the layout. + * + * @param widget - The widget to detach from the parent. + * + * #### Notes + * This method is called automatically by the panel layout at the + * appropriate time. It should not be called directly by user code. + * + * The default implementation removes the widget's node from the + * parent's node, and sends the appropriate detach messages to the + * widget if the parent is attached to the DOM. + * + * Subclasses may reimplement this method to control how the widget's + * node is removed from the parent's node. + */ + protected detachWidget(index: number, widget: Widget): void { + (widget as Cell).inViewport = false; + + // Note: `index` is relative to the displayed cells, not all cells, + // hence we compare with the widget itself. + if (widget === this.activeCell && widget !== this._willBeRemoved) { + // Do not change display of the active cell to allow user to continue providing input + // into the code mirror editor when out of view. We still hide the cell so to prevent + // minor visual glitches when scrolling. + this._toggleSoftVisibility(widget, false); + // Return before sending "AfterDetach" message to CodeCell + // to prevent removing contents of the active cell. + return; + } + + // Do not apply `display: none` on cells which contain: + // - `` as multiple browsers (Chrome and Firefox) + // are bugged and do not respect the spec here, see + // https://github.com/jupyterlab/jupyterlab/issues/16952 + // https://issues.chromium.org/issues/40324398 + // https://bugzilla.mozilla.org/show_bug.cgi?id=376027 + // - elements containing `.myst` class as jupyterlab-myst + // re-renders cause height jitter and scrolling issues; + // in future this could be addressed by the proposal + // to allow renderers to specify hiding mode, see + // https://github.com/jupyterlab/jupyterlab/issues/17331 + const requiresSoftHiding = widget.node.querySelector('defs,.myst'); + + if (requiresSoftHiding) { + this._toggleSoftVisibility(widget, false); + return; + } + // TODO we could improve this further by discarding also the code cell without outputs + if ( + // We detach the code cell currently dragged otherwise it won't be attached at the correct position + widget instanceof CodeCell && + !widget.node.classList.contains(DROP_SOURCE_CLASS) && + widget !== this._willBeRemoved + ) { + // We don't remove code cells to preserve outputs internal state + // Transform does not work because the widget height is kept (at least in FF) + widget.node.style.display = 'none'; + + // Reset cache + this._topHiddenCodeCells = -1; + } else { + // Send a `'before-detach'` message if the parent is attached. + // This should not be called every time a cell leaves the viewport + // as it will remove listeners that won't be added back as afterAttach + // is shunted to avoid unwanted update/reset. + if (this.parent!.isAttached) { + // Event listeners will be removed here + MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach); + } + // Remove the widget's node from the parent. + this.parent!.viewportNode.removeChild(widget.node); + + // Ensure to clean up drop target class if the widget move out of the viewport + widget.node.classList.remove(DROP_TARGET_CLASS); + } + + if (this.parent!.isAttached) { + // Detach sub widget of CodeCell except the OutputAreaWrapper + MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach); + } + } + + /** + * Move a widget in the parent's DOM node. + * + * @param fromIndex - The previous index of the widget in the layout. + * + * @param toIndex - The current index of the widget in the layout. + * + * @param widget - The widget to move in the parent. + * + * #### Notes + * This method is called automatically by the panel layout at the + * appropriate time. It should not be called directly by user code. + * + * The default implementation moves the widget's node to the proper + * location in the parent's node and sends the appropriate attach and + * detach messages to the widget if the parent is attached to the DOM. + * + * Subclasses may reimplement this method to control how the widget's + * node is moved in the parent's node. + */ + protected moveWidget( + fromIndex: number, + toIndex: number, + widget: Widget + ): void { + // Optimize move without de-/attaching as motion appends with parent attached + // Case fromIndex === toIndex, already checked in PanelLayout.insertWidget + if (this._topHiddenCodeCells < 0) { + this._topHiddenCodeCells = 0; + for ( + let idx = 0; + idx < this.parent!.viewportNode.children.length; + idx++ + ) { + const n = this.parent!.viewportNode.children[idx]; + if ((n as HTMLElement).style.display == 'none') { + this._topHiddenCodeCells++; + } else { + break; + } + } + } + + const ref = + this.parent!.viewportNode.children[toIndex + this._topHiddenCodeCells]; + if (fromIndex < toIndex) { + ref.insertAdjacentElement('afterend', widget.node); + } else { + ref.insertAdjacentElement('beforebegin', widget.node); + } + } + + protected onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + if (this._header && !this._header.isAttached) { + Widget.attach( + this._header, + this.parent!.node, + this.parent!.node.firstElementChild as HTMLElement | null + ); + } + if (this._footer && !this._footer.isAttached) { + Widget.attach(this._footer, this.parent!.outerNode); + } + } + + protected onBeforeDetach(msg: Message): void { + if (this._header?.isAttached) { + Widget.detach(this._header); + } + if (this._footer?.isAttached) { + Widget.detach(this._footer); + } + super.onBeforeDetach(msg); + } + + /** + * A message handler invoked on a `'child-removed'` message. + * + * @param msg Message + */ + protected onChildRemoved(msg: Widget.ChildMessage): void { + this._willBeRemoved = msg.child; + super.onChildRemoved(msg); + this._willBeRemoved = null; + } + + /** + * Toggle "soft" visibility of the widget. + * + * #### Notes + * To ensure that user events reach the CodeMirror editor, this method + * does not toggle `display` nor `visibility` which have side effects, + * but instead hides it in the compositor and ensures that the bounding + * box is has an area equal to zero. + * To ensure we do not trigger style recalculation, we set the styles + * directly on the node instead of using a class. + */ + private _toggleSoftVisibility(widget: Widget, show: boolean): void { + if (show) { + widget.node.style.opacity = ''; + widget.node.style.height = ''; + widget.node.style.padding = ''; + } else { + widget.node.style.opacity = '0'; + // Both padding and height need to be set to zero + // to ensure bounding box collapses to invisible. + widget.node.style.height = '0'; + widget.node.style.padding = '0'; + } + } + + private _isSoftHidden(widget: Widget): boolean { + return widget.node.style.opacity === '0'; + } + + private _findNearestChildBinarySearch( + high: number, + low: number, + index: number + ): number { + while (low <= high) { + const middle = low + Math.floor((high - low) / 2); + const currentIndex = parseInt( + (this.parent!.viewportNode.children[middle] as HTMLElement).dataset + .windowedListIndex!, + 10 + ); + + if (currentIndex === index) { + return middle; + } else if (currentIndex < index) { + low = middle + 1; + } else if (currentIndex > index) { + high = middle - 1; + } + } + + if (low > 0) { + return low; + } else { + return 0; + } + } + + private _willBeRemoved: Widget | null = null; + private _topHiddenCodeCells: number = -1; +} diff --git a/.yalc/@jupyterlab/notebook/style/base.css b/.yalc/@jupyterlab/notebook/style/base.css new file mode 100644 index 0000000000..c7b2375419 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/style/base.css @@ -0,0 +1,555 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +/*----------------------------------------------------------------------------- +| Imports +|----------------------------------------------------------------------------*/ + +@import './toolbar.css'; +@import './executionindicator.css'; +@import './toc.css'; +@import './notebookfooter.css'; + +/*----------------------------------------------------------------------------- +| CSS variables +|----------------------------------------------------------------------------*/ + +:root { + --jp-side-by-side-output-size: 1fr; + --jp-side-by-side-resized-cell: var(--jp-side-by-side-output-size); + --jp-private-notebook-dragImage-width: 304px; + --jp-private-notebook-dragImage-height: 36px; + --jp-private-notebook-selected-color: var(--md-blue-400, #42a5f5); + --jp-private-notebook-active-color: var(--md-green-400, #66bb6a); +} + +/*----------------------------------------------------------------------------- +| Notebook +|----------------------------------------------------------------------------*/ + +/* stylelint-disable selector-max-class */ + +.jp-NotebookPanel { + display: block; + height: 100%; +} + +.jp-NotebookPanel.jp-Document { + min-width: 240px; + min-height: 120px; +} + +.jp-Notebook { + outline: none; + background: var(--jp-layout-color0); +} + +.jp-Notebook .jp-WindowedPanel-viewport { + padding: var(--jp-notebook-padding); +} + +.jp-Notebook.jp-mod-scrollPastEnd > .jp-WindowedPanel-outer::after { + display: block; + content: ''; + min-height: var(--jp-notebook-scroll-padding); +} + +.jp-MainAreaWidget-ContainStrict .jp-Notebook * { + contain: strict; +} + +.jp-Notebook .jp-Cell { + overflow: visible; +} + +.jp-Notebook .jp-Cell .jp-InputPrompt { + cursor: move; + float: left; +} + +/*----------------------------------------------------------------------------- +| Notebook state related styling +| +| The notebook and cells each have states, here are the possibilities: +| +| - Notebook +| - Command +| - Edit +| - Cell +| - None +| - Active (only one can be active) +| - Selected (the cells actions are applied to) +| - Multiselected (when multiple selected, the cursor) +| - No outputs +|----------------------------------------------------------------------------*/ + +/* Command or edit modes */ + +.jp-Notebook .jp-Cell:not(.jp-mod-active) .jp-InputPrompt { + opacity: var(--jp-cell-prompt-not-active-opacity); + color: var(--jp-cell-prompt-not-active-font-color); +} + +.jp-Notebook .jp-Cell:not(.jp-mod-active) .jp-OutputPrompt { + opacity: var(--jp-cell-prompt-not-active-opacity); + color: var(--jp-cell-prompt-not-active-font-color); +} + +/* cell is active */ +.jp-Notebook .jp-Cell.jp-mod-active .jp-Collapser { + background: var(--jp-brand-color1); +} + +/* cell is dirty */ +.jp-Notebook .jp-Cell.jp-mod-dirty .jp-InputPrompt { + color: var(--jp-warn-color1); +} + +.jp-Notebook .jp-Cell.jp-mod-dirty .jp-InputPrompt::before { + color: var(--jp-warn-color1); + content: '•'; +} + +.jp-Notebook .jp-Cell.jp-mod-active.jp-mod-dirty .jp-Collapser { + background: var(--jp-warn-color1); +} + +/* collapser is hovered */ +.jp-Notebook .jp-Cell .jp-Collapser:hover { + box-shadow: var(--jp-elevation-z2); + background: var(--jp-brand-color1); + opacity: var(--jp-cell-collapser-not-active-hover-opacity); +} + +/* cell is active and collapser is hovered */ +.jp-Notebook .jp-Cell.jp-mod-active .jp-Collapser:hover { + background: var(--jp-brand-color0); + opacity: 1; +} + +/* Command mode */ + +.jp-Notebook.jp-mod-commandMode .jp-Cell.jp-mod-selected { + background: var(--jp-notebook-multiselected-color); +} + +.jp-Notebook.jp-mod-commandMode + .jp-Cell.jp-mod-active.jp-mod-selected:not(.jp-mod-multiSelected) { + background: transparent; +} + +.jp-Notebook.jp-mod-commandMode .jp-Cell.jp-mod-active:focus-visible { + outline: none; + border: none; + border-radius: 2px; + box-shadow: 0 0 0 1px var(--jp-brand-color1); + z-index: 1; +} + +/* Edit mode */ + +.jp-Notebook.jp-mod-editMode .jp-Cell.jp-mod-active .jp-InputArea-editor { + border: var(--jp-border-width) solid var(--jp-cell-editor-active-border-color); + box-shadow: var(--jp-cell-editor-box-shadow); + background-color: var(--jp-cell-editor-active-background); +} + +/*----------------------------------------------------------------------------- +| Notebook drag and drop +|----------------------------------------------------------------------------*/ + +.jp-Notebook-cell.jp-mod-dropSource { + opacity: 0.5; +} + +.jp-Notebook-cell.jp-mod-dropTarget, +.jp-Notebook.jp-mod-commandMode + .jp-Notebook-cell.jp-mod-active.jp-mod-selected.jp-mod-dropTarget { + border-top-color: var(--jp-private-notebook-selected-color); + border-top-style: solid; + border-top-width: 2px; +} + +.jp-dragImage { + display: flex; + flex-direction: row; + width: var(--jp-private-notebook-dragImage-width); + height: var(--jp-private-notebook-dragImage-height); + border: var(--jp-border-width) solid var(--jp-cell-editor-border-color); + background: var(--jp-cell-editor-background); + overflow: visible; +} + +.jp-dragImage-singlePrompt { + box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.12); +} + +.jp-dragImage .jp-dragImage-content { + flex: 1 1 auto; + z-index: 2; + font-size: var(--jp-code-font-size); + font-family: var(--jp-code-font-family); + line-height: var(--jp-code-line-height); + padding: var(--jp-code-padding); + border: var(--jp-border-width) solid var(--jp-cell-editor-border-color); + background: var(--jp-cell-editor-background-color); + color: var(--jp-content-font-color3); + text-align: left; + margin: 4px 4px 4px 0; +} + +.jp-dragImage .jp-dragImage-prompt { + flex: 0 0 auto; + min-width: 36px; + color: var(--jp-cell-inprompt-font-color); + opacity: 0.5; + padding: var(--jp-code-padding); + padding-left: 12px; + font-family: var(--jp-cell-prompt-font-family); + letter-spacing: var(--jp-cell-prompt-letter-spacing); + line-height: 1.9; + font-size: var(--jp-code-font-size); + border: var(--jp-border-width) solid transparent; +} + +.jp-dragImage-multipleBack { + z-index: -1; + position: absolute; + height: 32px; + width: 300px; + top: 8px; + left: 8px; + background: var(--jp-layout-color2); + border-width: var(--jp-border-width); + border-style: solid; + border-color: color-mix( + in srgb, + var(--jp-input-border-color) 20%, + transparent + ); + box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.12); +} + +/*----------------------------------------------------------------------------- +| Cell toolbar +|----------------------------------------------------------------------------*/ + +.jp-NotebookTools { + display: block; + min-width: var(--jp-sidebar-min-width); + color: var(--jp-ui-font-color1); + background: var(--jp-layout-color1); + + /* This is needed so that all font sizing of children done in ems is + * relative to this base size */ + font-size: var(--jp-ui-font-size1); + overflow: auto; +} + +.jp-ActiveCellTool { + padding: 12px 0; + display: flex; +} + +.jp-ActiveCellTool-Content { + flex: 1 1 auto; +} + +.jp-ActiveCellTool .jp-ActiveCellTool-CellContent { + background: var(--jp-cell-editor-background); + border: var(--jp-border-width) solid var(--jp-cell-editor-border-color); + border-radius: 0; + min-height: 29px; +} + +.jp-ActiveCellTool .jp-InputPrompt { + min-width: calc(var(--jp-cell-prompt-width) * 0.75); +} + +.jp-ActiveCellTool-CellContent > pre { + padding: 5px 4px; + margin: 0; + white-space: normal; +} + +.jp-MetadataEditorTool { + flex-direction: column; + padding: 12px 0; +} + +.jp-RankedPanel > :not(:first-child) { + margin-top: 12px; +} + +.jp-KeySelector select.jp-mod-styled { + font-size: var(--jp-ui-font-size1); + color: var(--jp-ui-font-color0); + border: var(--jp-border-width) solid var(--jp-border-color1); +} + +.jp-KeySelector label, +.jp-MetadataEditorTool label, +.jp-NumberSetter label { + line-height: 1.4; +} + +.jp-NumberSetter input { + width: 100%; + margin-top: 4px; +} + +.jp-NotebookTools .jp-Collapse { + margin-top: 16px; +} + +/*----------------------------------------------------------------------------- +| Presentation Mode (.jp-mod-presentationMode) +|----------------------------------------------------------------------------*/ + +.jp-mod-presentationMode .jp-Notebook { + --jp-content-font-size1: var(--jp-content-presentation-font-size1); + --jp-code-font-size: var(--jp-code-presentation-font-size); +} + +.jp-mod-presentationMode .jp-Notebook .jp-Cell .jp-InputPrompt, +.jp-mod-presentationMode .jp-Notebook .jp-Cell .jp-OutputPrompt { + flex: 0 0 110px; +} + +/*----------------------------------------------------------------------------- +| Side-by-side Mode (.jp-mod-sideBySide) +|----------------------------------------------------------------------------*/ +.jp-mod-sideBySide.jp-Notebook .jp-Notebook-cell { + margin-top: 3em; + margin-bottom: 3em; + margin-left: 5%; + margin-right: 5%; +} + +.jp-mod-sideBySide.jp-Notebook .jp-CodeCell { + display: grid; + grid-template-columns: minmax(70px, 1fr) min-content minmax( + 70px, + var(--jp-side-by-side-output-size) + ); + grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-areas: + 'header header header' + 'input handle output' + 'footer footer footer'; +} + +.jp-mod-sideBySide.jp-Notebook .jp-CodeCell.jp-mod-resizedCell { + grid-template-columns: minmax(70px, 1fr) min-content minmax( + 70px, + var(--jp-side-by-side-resized-cell) + ); +} + +.jp-mod-sideBySide.jp-Notebook .jp-CodeCell .jp-CellHeader { + grid-area: header; +} + +.jp-mod-sideBySide.jp-Notebook .jp-CodeCell .jp-Cell-inputWrapper { + grid-area: input; +} + +.jp-mod-sideBySide.jp-Notebook .jp-CodeCell .jp-Cell-outputWrapper { + /* overwrite the default margin (no vertical separation needed in side by side move */ + margin-top: 0; + grid-area: output; +} + +.jp-mod-sideBySide.jp-Notebook .jp-CodeCell .jp-CellFooter { + grid-area: footer; +} + +.jp-mod-sideBySide.jp-Notebook .jp-CodeCell .jp-CellResizeHandle { + grid-area: handle; + user-select: none; + display: block; + height: 100%; + cursor: ew-resize; + padding: 0 var(--jp-cell-padding); +} + +.jp-mod-sideBySide.jp-Notebook .jp-CodeCell .jp-CellResizeHandle::after { + content: ''; + display: block; + background: var(--jp-border-color2); + height: 100%; + width: 5px; +} + +.jp-mod-sideBySide.jp-Notebook + .jp-CodeCell.jp-mod-resizedCell + .jp-CellResizeHandle::after { + background: var(--jp-border-color0); +} + +.jp-CellResizeHandle { + display: none; +} + +/*----------------------------------------------------------------------------- +| Placeholder +|----------------------------------------------------------------------------*/ + +.jp-Cell-Placeholder { + padding-left: 55px; +} + +.jp-Cell-Placeholder-wrapper { + background: #fff; + border: 1px solid; + border-color: #e5e6e9 #dfe0e4 #d0d1d5; + border-radius: 4px; + -webkit-border-radius: 4px; + margin: 10px 15px; +} + +.jp-Cell-Placeholder-wrapper-inner { + padding: 15px; + position: relative; +} + +.jp-Cell-Placeholder-wrapper-body { + background-repeat: repeat; + background-size: 50% auto; +} + +.jp-Cell-Placeholder-wrapper-body div { + background: #f6f7f8; + background-image: -webkit-linear-gradient( + left, + #f6f7f8 0%, + #edeef1 20%, + #f6f7f8 40%, + #f6f7f8 100% + ); + background-repeat: no-repeat; + background-size: 800px 104px; + height: 104px; + position: absolute; + right: 15px; + left: 15px; + top: 15px; +} + +div.jp-Cell-Placeholder-h1 { + top: 20px; + height: 20px; + left: 15px; + width: 150px; +} + +div.jp-Cell-Placeholder-h2 { + left: 15px; + top: 50px; + height: 10px; + width: 100px; +} + +div.jp-Cell-Placeholder-content-1, +div.jp-Cell-Placeholder-content-2, +div.jp-Cell-Placeholder-content-3 { + left: 15px; + right: 15px; + height: 10px; +} + +div.jp-Cell-Placeholder-content-1 { + top: 100px; +} + +div.jp-Cell-Placeholder-content-2 { + top: 120px; +} + +div.jp-Cell-Placeholder-content-3 { + top: 140px; +} + +/*----------------------------------------------------------------------------- +| Virtual scrollbar +|----------------------------------------------------------------------------*/ + +.jp-Notebook .jp-WindowedPanel-scrollbar-item[data-output='error'] { + background: var(--jp-error-color3); +} + +.jp-Notebook .jp-WindowedPanel-scrollbar-item[data-output='error']:hover { + background: var(--jp-error-color2); +} + +.jp-Notebook .jp-WindowedPanel-scrollbar-content > .jp-mod-dirty { + background: var(--jp-warn-color3); +} + +.jp-Notebook .jp-WindowedPanel-scrollbar-content > .jp-mod-dirty:hover { + background: var(--jp-warn-color2); +} + +.jp-Notebook .jp-WindowedPanel-scrollbar-content > .jp-mod-selected { + background: var(--jp-brand-color2); + color: var(--jp-ui-inverse-font-color2); +} + +.jp-Notebook .jp-WindowedPanel-scrollbar-content > .jp-mod-selected:hover { + background: var(--jp-brand-color1); +} + +.jp-Notebook .jp-WindowedPanel-scrollbar-content > .jp-mod-active { + background: var(--jp-brand-color1); + color: var(--jp-ui-inverse-font-color1); +} + +.jp-Notebook .jp-WindowedPanel-scrollbar-content > .jp-mod-active:hover { + background: var(--jp-brand-color0); +} + +.jp-Notebook .jp-WindowedPanel-scrollbar-item { + max-width: 60px; + display: flex; +} + +.jp-Notebook .jp-scrollbarItem-executionIndicator { + display: inline-block; + font-family: monospace; + font-size: 80%; + line-height: 100%; + margin-left: -1px; + padding-right: 1px; + text-wrap: nowrap; /* stylelint-disable-line csstree/validator */ +} + +.jp-Notebook .jp-scrollbarItem-source { + display: inline-block; + font-size: 3px; + vertical-align: middle; + max-height: 10em; + overflow: hidden; +} + +.jp-Notebook + .jp-WindowedPanel-scrollbar-item[data-type='code'] + > .jp-scrollbarItem-source { + white-space: pre; +} + +.jp-Notebook + .jp-WindowedPanel-scrollbar-item[data-type='markdown'] + > .jp-scrollbarItem-source { + white-space: pre-line; +} + +/*----------------------------------------------------------------------------- +| Printing +|----------------------------------------------------------------------------*/ +@media print { + .jp-Notebook .jp-Cell .jp-InputPrompt { + float: none; + } +} diff --git a/.yalc/@jupyterlab/notebook/style/executionindicator.css b/.yalc/@jupyterlab/notebook/style/executionindicator.css new file mode 100644 index 0000000000..d69a79d175 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/style/executionindicator.css @@ -0,0 +1,65 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +/*----------------------------------------------------------------------------- +| Variables +|----------------------------------------------------------------------------*/ + +/*----------------------------------------------------------------------------- + +/*----------------------------------------------------------------------------- +| Styles +|----------------------------------------------------------------------------*/ + +.jp-Notebook-ExecutionIndicator { + position: relative; + display: inline-block; + z-index: 9997; + padding-top: 1px; +} + +.jp-Notebook-ExecutionIndicator-tooltip { + visibility: hidden; + height: auto; + width: max-content; + width: -moz-max-content; + background-color: var(--jp-layout-color2); + color: var(--jp-ui-font-color1); + text-align: justify; + border-radius: 6px; + padding: 0 5px; + position: fixed; + display: table; +} + +.jp-Notebook-ExecutionIndicator-tooltip.up { + transform: translateX(-50%) translateY(-100%) translateY(-32px); +} + +.jp-Notebook-ExecutionIndicator-tooltip.down { + transform: translateX(calc(-100% + 16px)) translateY(5px); +} + +.jp-Notebook-ExecutionIndicator-tooltip.hidden { + display: none; +} + +.jp-Notebook-ExecutionIndicator:hover .jp-Notebook-ExecutionIndicator-tooltip { + visibility: visible; +} + +.jp-Notebook-ExecutionIndicator span { + font-size: var(--jp-ui-font-size1); + font-family: var(--jp-ui-font-family); + color: var(--jp-ui-font-color1); + line-height: 24px; + display: block; +} + +.jp-Notebook-ExecutionIndicator-progress-bar { + display: flex; + justify-content: center; + height: 100%; +} diff --git a/.yalc/@jupyterlab/notebook/style/index.css b/.yalc/@jupyterlab/notebook/style/index.css new file mode 100644 index 0000000000..a01029397d --- /dev/null +++ b/.yalc/@jupyterlab/notebook/style/index.css @@ -0,0 +1,20 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +/* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ +@import url('~@lumino/widgets/style/index.css'); +@import url('~@jupyterlab/ui-components/style/index.css'); +@import url('~@jupyterlab/statusbar/style/index.css'); +@import url('~@jupyterlab/apputils/style/index.css'); +@import url('~@jupyterlab/rendermime/style/index.css'); +@import url('~@lumino/dragdrop/style/index.css'); +@import url('~@jupyterlab/codeeditor/style/index.css'); +@import url('~@jupyterlab/documentsearch/style/index.css'); +@import url('~@jupyterlab/codemirror/style/index.css'); +@import url('~@jupyterlab/docregistry/style/index.css'); +@import url('~@jupyterlab/toc/style/index.css'); +@import url('~@jupyterlab/cells/style/index.css'); +@import url('~@jupyterlab/lsp/style/index.css'); +@import url('./base.css'); diff --git a/.yalc/@jupyterlab/notebook/style/index.js b/.yalc/@jupyterlab/notebook/style/index.js new file mode 100644 index 0000000000..2f1b88e985 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/style/index.js @@ -0,0 +1,21 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +/* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ +import '@lumino/widgets/style/index.js'; +import '@jupyterlab/ui-components/style/index.js'; +import '@jupyterlab/statusbar/style/index.js'; +import '@jupyterlab/apputils/style/index.js'; +import '@jupyterlab/rendermime/style/index.js'; +import '@lumino/dragdrop/style/index.js'; +import '@jupyterlab/codeeditor/style/index.js'; +import '@jupyterlab/documentsearch/style/index.js'; +import '@jupyterlab/codemirror/style/index.js'; +import '@jupyterlab/docregistry/style/index.js'; +import '@jupyterlab/toc/style/index.js'; +import '@jupyterlab/cells/style/index.js'; +import '@jupyterlab/lsp/style/index.js'; + +import './base.css'; diff --git a/.yalc/@jupyterlab/notebook/style/notebookfooter.css b/.yalc/@jupyterlab/notebook/style/notebookfooter.css new file mode 100644 index 0000000000..a3e086835e --- /dev/null +++ b/.yalc/@jupyterlab/notebook/style/notebookfooter.css @@ -0,0 +1,39 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +.jp-Notebook-footer { + height: 27px; + margin-left: calc( + var(--jp-cell-prompt-width) + var(--jp-cell-collapser-width) + + var(--jp-cell-padding) + var(--jp-notebook-padding) + ); + width: calc( + 100% - + ( + var(--jp-cell-prompt-width) + var(--jp-cell-collapser-width) + 2 * + var(--jp-cell-padding) + 2 * var(--jp-notebook-padding) + ) + ); + border: var(--jp-border-width) solid var(--jp-cell-editor-border-color); + color: var(--jp-ui-font-color3); + background: none; + cursor: pointer; +} + +.jp-Notebook-footer:focus { + border-color: var(--jp-cell-editor-active-border-color); +} + +/* For devices that support hovering, hide footer until hover */ +@media (hover: hover) { + .jp-Notebook-footer { + opacity: 0; + } + + .jp-Notebook-footer:focus, + .jp-Notebook-footer:hover { + opacity: 1; + } +} diff --git a/.yalc/@jupyterlab/notebook/style/toc.css b/.yalc/@jupyterlab/notebook/style/toc.css new file mode 100644 index 0000000000..37a23d5331 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/style/toc.css @@ -0,0 +1,43 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +/* + * Execution indicator + */ +.jp-tocItem-content::after { + content: ''; + + /* Must be identical to form a circle */ + width: 12px; + height: 12px; + background: none; + border: none; + position: absolute; + right: 0; +} + +.jp-tocItem-content[data-running='0']::after { + border-radius: 50%; + border: var(--jp-border-width) solid var(--jp-inverse-layout-color3); + background: none; +} + +.jp-tocItem-content[data-running='1']::after { + border-radius: 50%; + border: var(--jp-border-width) solid var(--jp-inverse-layout-color3); + background-color: var(--jp-inverse-layout-color3); +} + +.jp-tocItem-content[data-running='-0.5']::after { + /* \FE0E forces the preceding unicode to be rendered as text */ + content: '\26A0 \FE0E'; + color: var(--jp-error-color1); +} + +.jp-tocItem-content[data-running='0'], +.jp-tocItem-content[data-running='1'], +.jp-tocItem-content[data-running='-0.5'] { + margin-right: 12px; +} diff --git a/.yalc/@jupyterlab/notebook/style/toolbar.css b/.yalc/@jupyterlab/notebook/style/toolbar.css new file mode 100644 index 0000000000..6e31c9b21b --- /dev/null +++ b/.yalc/@jupyterlab/notebook/style/toolbar.css @@ -0,0 +1,29 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +/*----------------------------------------------------------------------------- +| Variables +|----------------------------------------------------------------------------*/ + +:root { + --jp-notebook-toolbar-padding: 0 5px 0 2px; +} + +/*----------------------------------------------------------------------------- + +/*----------------------------------------------------------------------------- +| Styles +|----------------------------------------------------------------------------*/ + +.jp-NotebookPanel-toolbar { + padding: var(--jp-notebook-toolbar-padding); + + /* disable paint containment from lumino 2.0 default strict CSS containment */ + contain: style size !important; +} + +.jp-Toolbar > .jp-Toolbar-responsive-opener { + margin-left: auto; +} diff --git a/.yalc/@jupyterlab/notebook/yalc.sig b/.yalc/@jupyterlab/notebook/yalc.sig new file mode 100644 index 0000000000..abd94a69b2 --- /dev/null +++ b/.yalc/@jupyterlab/notebook/yalc.sig @@ -0,0 +1 @@ +9fcf69f7c3db5e34220fca94eb3c4503 \ No newline at end of file diff --git a/package.json b/package.json index 4891e11e7b..9b4e7ae46d 100644 --- a/package.json +++ b/package.json @@ -66,5 +66,8 @@ "rimraf": "^3.0.2", "typescript": "~5.5.4" }, - "nx": {} + "nx": {}, + "dependencies": { + "@jupyterlab/notebook": "file:.yalc/@jupyterlab/notebook" + } } diff --git a/packages/application/test/shell.spec.d.ts b/packages/application/test/shell.spec.d.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/application/test/shell.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/application/test/shell.spec.js b/packages/application/test/shell.spec.js new file mode 100644 index 0000000000..590b1ff467 --- /dev/null +++ b/packages/application/test/shell.spec.js @@ -0,0 +1,158 @@ +"use strict"; +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +Object.defineProperty(exports, "__esModule", { value: true }); +const application_1 = require("@jupyter-notebook/application"); +const widgets_1 = require("@lumino/widgets"); +describe('Shell for notebooks', () => { + let shell; + beforeEach(() => { + shell = new application_1.NotebookShell(); + widgets_1.Widget.attach(shell, document.body); + }); + afterEach(() => { + shell.dispose(); + }); + describe('#constructor()', () => { + it('should create a LabShell instance', () => { + expect(shell).toBeInstanceOf(application_1.NotebookShell); + }); + it('should make some areas empty initially', () => { + ['main', 'left', 'right', 'menu'].forEach((area) => { + const widgets = Array.from(shell.widgets(area)); + expect(widgets.length).toEqual(0); + }); + }); + it('should have the skip link widget in the top area initially', () => { + const widgets = Array.from(shell.widgets('top')); + expect(widgets.length).toEqual(1); + }); + }); + describe('#widgets()', () => { + it('should add widgets to main area', () => { + const widget = new widgets_1.Widget(); + shell.add(widget, 'main'); + const widgets = Array.from(shell.widgets('main')); + expect(widgets).toEqual([widget]); + }); + it('should be empty and console.error if area does not exist', () => { + const spy = jest.spyOn(console, 'error'); + const jupyterFrontEndShell = shell; + expect(Array.from(jupyterFrontEndShell.widgets('fake'))).toHaveLength(0); + expect(spy).toHaveBeenCalled(); + }); + }); + describe('#currentWidget', () => { + it('should be the current widget in the shell main area', () => { + expect(shell.currentWidget).toBe(null); + const widget = new widgets_1.Widget(); + widget.node.tabIndex = -1; + widget.id = 'foo'; + expect(shell.currentWidget).toBe(null); + shell.add(widget, 'main'); + expect(shell.currentWidget).toBe(widget); + widget.parent = null; + expect(shell.currentWidget).toBe(null); + }); + }); + describe('#add(widget, "top")', () => { + it('should add a widget to the top area', () => { + const widget = new widgets_1.Widget(); + widget.id = 'foo'; + shell.add(widget, 'top'); + const widgets = Array.from(shell.widgets('top')); + expect(widgets.length).toBeGreaterThan(0); + }); + it('should accept options', () => { + const widget = new widgets_1.Widget(); + widget.id = 'foo'; + shell.add(widget, 'top', { rank: 10 }); + const widgets = Array.from(shell.widgets('top')); + expect(widgets.length).toBeGreaterThan(0); + }); + }); + describe('#add(widget, "main")', () => { + it('should add a widget to the main area', () => { + const widget = new widgets_1.Widget(); + widget.id = 'foo'; + shell.add(widget, 'main'); + const widgets = Array.from(shell.widgets('main')); + expect(widgets.length).toBeGreaterThan(0); + }); + }); + describe('#add(widget, "left")', () => { + it('should add a widget to the left area', () => { + const widget = new widgets_1.Widget(); + widget.id = 'foo'; + shell.add(widget, 'left'); + const widgets = Array.from(shell.widgets('left')); + expect(widgets.length).toBeGreaterThan(0); + }); + }); + describe('#add(widget, "right")', () => { + it('should add a widget to the right area', () => { + const widget = new widgets_1.Widget(); + widget.id = 'foo'; + shell.add(widget, 'right'); + const widgets = Array.from(shell.widgets('right')); + expect(widgets.length).toBeGreaterThan(0); + }); + }); +}); +describe('Shell for tree view', () => { + let shell; + beforeEach(() => { + shell = new application_1.NotebookShell(); + widgets_1.Widget.attach(shell, document.body); + }); + afterEach(() => { + shell.dispose(); + }); + describe('#constructor()', () => { + it('should create a LabShell instance', () => { + expect(shell).toBeInstanceOf(application_1.NotebookShell); + }); + it('should make some areas empty initially', () => { + ['main', 'left', 'right', 'menu'].forEach((area) => { + const widgets = Array.from(shell.widgets(area)); + expect(widgets.length).toEqual(0); + }); + }); + it('should have the skip link widget in the top area initially', () => { + const widgets = Array.from(shell.widgets('top')); + expect(widgets.length).toEqual(1); + }); + }); + describe('#widgets()', () => { + it('should add widgets to existing areas', () => { + const widget = new widgets_1.Widget(); + shell.add(widget, 'main'); + const widgets = Array.from(shell.widgets('main')); + expect(widgets).toEqual([widget]); + }); + it('should throw an exception if a fake area does not exist', () => { + const spy = jest.spyOn(console, 'error'); + const jupyterFrontEndShell = shell; + expect(Array.from(jupyterFrontEndShell.widgets('fake'))).toHaveLength(0); + expect(spy).toHaveBeenCalled(); + }); + }); + describe('#add(widget, "left")', () => { + it('should add a widget to the left area', () => { + const widget = new widgets_1.Widget(); + widget.id = 'foo'; + shell.add(widget, 'left'); + const widgets = Array.from(shell.widgets('left')); + expect(widgets.length).toBeGreaterThan(0); + }); + }); + describe('#add(widget, "right")', () => { + it('should add a widget to the right area', () => { + const widget = new widgets_1.Widget(); + widget.id = 'foo'; + shell.add(widget, 'right'); + const widgets = Array.from(shell.widgets('right')); + expect(widgets.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/ui-components/test/foo.spec.d.ts b/packages/ui-components/test/foo.spec.d.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/ui-components/test/foo.spec.js b/packages/ui-components/test/foo.spec.js new file mode 100644 index 0000000000..a89635b8f8 --- /dev/null +++ b/packages/ui-components/test/foo.spec.js @@ -0,0 +1,9 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +describe('foo', () => { + describe('bar', () => { + it('should pass', () => { + expect(true).toBe(true); + }); + }); +}); diff --git a/ui-tests/test/runcell.spec.ts b/ui-tests/test/runcell.spec.ts new file mode 100644 index 0000000000..49b0e66311 --- /dev/null +++ b/ui-tests/test/runcell.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; + +test('Prevent concurrent cell execution', async ({ page }) => { + await page.goto('http://localhost:8888/notebooks/untitled.ipynb'); + await page.locator('.jp-Cell-inputArea').nth(0).fill('i = 0'); + await page.locator('.jp-Cell-inputArea').nth(0).press('Control+Enter'); + await page.keyboard.press('b'); + await page.locator('.jp-Cell-inputArea').nth(1).fill(` + import time + time.sleep(5) + print("done %d" % i) + i = i + 1 + `); + await page.locator('.jp-Cell-inputArea').nth(1).press('Shift+Enter'); + await page.locator('.jp-Cell-inputArea').nth(1).press('Shift+Enter'); + await page.locator('.jp-Cell-inputArea').nth(1).press('Shift+Enter'); + await page.waitForSelector('.jp-Cell-outputArea'); + const outputs = await page.$$eval('.jp-Cell-outputArea', nodes => + nodes.map(n => n.textContent) + ); + expect(outputs.length).toBe(1); + expect(outputs[0]).toContain('done 0'); + await page.keyboard.press('b'); + await page.locator('.jp-Cell-inputArea').nth(2).fill('print(i)'); + await page.locator('.jp-Cell-inputArea').nth(2).press('Control+Enter'); + const finalOutput = await page.locator('.jp-Cell-outputArea').nth(2).textContent(); + expect(finalOutput).toContain('1'); +}); \ No newline at end of file diff --git a/yalc.lock b/yalc.lock new file mode 100644 index 0000000000..5a2150ab7d --- /dev/null +++ b/yalc.lock @@ -0,0 +1,9 @@ +{ + "version": "v1", + "packages": { + "@jupyterlab/notebook": { + "signature": "9fcf69f7c3db5e34220fca94eb3c4503", + "pure": true + } + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index fbb185ed54..efff213082 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2409,6 +2409,7 @@ __metadata: resolution: "@jupyter-notebook/root@workspace:." dependencies: "@jupyterlab/buildutils": ~4.4.1 + "@jupyterlab/notebook": "file:.yalc/@jupyterlab/notebook" "@typescript-eslint/eslint-plugin": ^5.55.0 "@typescript-eslint/parser": ^5.55.0 eslint: ^8.36.0 @@ -3853,6 +3854,44 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/notebook@file:.yalc/@jupyterlab/notebook::locator=%40jupyter-notebook%2Froot%40workspace%3A.": + version: 4.4.1 + resolution: "@jupyterlab/notebook@file:.yalc/@jupyterlab/notebook#.yalc/@jupyterlab/notebook::hash=d1761b&locator=%40jupyter-notebook%2Froot%40workspace%3A." + dependencies: + "@jupyter/ydoc": ^3.0.4 + "@jupyterlab/apputils": ^4.5.1 + "@jupyterlab/cells": ^4.4.1 + "@jupyterlab/codeeditor": ^4.4.1 + "@jupyterlab/codemirror": ^4.4.1 + "@jupyterlab/coreutils": ^6.4.1 + "@jupyterlab/docregistry": ^4.4.1 + "@jupyterlab/documentsearch": ^4.4.1 + "@jupyterlab/lsp": ^4.4.1 + "@jupyterlab/nbformat": ^4.4.1 + "@jupyterlab/observables": ^5.4.1 + "@jupyterlab/rendermime": ^4.4.1 + "@jupyterlab/services": ^7.4.1 + "@jupyterlab/settingregistry": ^4.4.1 + "@jupyterlab/statusbar": ^4.4.1 + "@jupyterlab/toc": ^6.4.1 + "@jupyterlab/translation": ^4.4.1 + "@jupyterlab/ui-components": ^4.4.1 + "@lumino/algorithm": ^2.0.3 + "@lumino/coreutils": ^2.2.1 + "@lumino/disposable": ^2.1.4 + "@lumino/domutils": ^2.0.3 + "@lumino/dragdrop": ^2.1.6 + "@lumino/messaging": ^2.0.3 + "@lumino/polling": ^2.1.4 + "@lumino/properties": ^2.0.3 + "@lumino/signaling": ^2.1.4 + "@lumino/virtualdom": ^2.0.3 + "@lumino/widgets": ^2.7.0 + react: ^18.2.0 + checksum: 11c78034b4f9707d816a0a8d34c9dfc616c07aa73da20eff9bc4419cb20e7067b9d4548f20964723420c523c1c109b08f72f39732873858724f964de2d09b89c + languageName: node + linkType: hard + "@jupyterlab/notebook@npm:^4.3.2, @jupyterlab/notebook@npm:^4.4.1, @jupyterlab/notebook@npm:~4.4.1": version: 4.4.1 resolution: "@jupyterlab/notebook@npm:4.4.1" From 0562335700c91017040d4bbf8a1a9cb79d222ec0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 27 Apr 2025 05:13:17 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .yalc/@jupyterlab/notebook/src/actions.tsx | 6 +++--- .yalc/@jupyterlab/notebook/yalc.sig | 2 +- ui-tests/test/runcell.spec.ts | 2 +- yalc.lock | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.yalc/@jupyterlab/notebook/src/actions.tsx b/.yalc/@jupyterlab/notebook/src/actions.tsx index e8119ad380..b12c078369 100644 --- a/.yalc/@jupyterlab/notebook/src/actions.tsx +++ b/.yalc/@jupyterlab/notebook/src/actions.tsx @@ -548,7 +548,7 @@ export namespace NotebookActions { if (!notebook.model || !notebook.activeCell) { return Promise.resolve(false); } - + const state = Private.getState(notebook); const promise = Private.runSelected( notebook, @@ -2532,7 +2532,7 @@ namespace Private { console.warn('Cannot execute cell: cell or cell.model is null or undefined'); return Promise.resolve(false); } - + // Check if the cell is a code cell and already running if (cell.model.type === 'code') { const metadata = cell.model.metadata as Record; @@ -2549,7 +2549,7 @@ namespace Private { // Proceed with execution } } - + try { if (!executor) { console.warn( diff --git a/.yalc/@jupyterlab/notebook/yalc.sig b/.yalc/@jupyterlab/notebook/yalc.sig index abd94a69b2..aa94243615 100644 --- a/.yalc/@jupyterlab/notebook/yalc.sig +++ b/.yalc/@jupyterlab/notebook/yalc.sig @@ -1 +1 @@ -9fcf69f7c3db5e34220fca94eb3c4503 \ No newline at end of file +9fcf69f7c3db5e34220fca94eb3c4503 diff --git a/ui-tests/test/runcell.spec.ts b/ui-tests/test/runcell.spec.ts index 49b0e66311..85260c79b4 100644 --- a/ui-tests/test/runcell.spec.ts +++ b/ui-tests/test/runcell.spec.ts @@ -25,4 +25,4 @@ test('Prevent concurrent cell execution', async ({ page }) => { await page.locator('.jp-Cell-inputArea').nth(2).press('Control+Enter'); const finalOutput = await page.locator('.jp-Cell-outputArea').nth(2).textContent(); expect(finalOutput).toContain('1'); -}); \ No newline at end of file +}); diff --git a/yalc.lock b/yalc.lock index 5a2150ab7d..401bf7f8d1 100644 --- a/yalc.lock +++ b/yalc.lock @@ -6,4 +6,4 @@ "pure": true } } -} \ No newline at end of file +}