Path: blob/master/src/packages/jupyter/redux/actions.ts
1447 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Jupyter actions -- these are the actions for the underlying document structure.7This can be used both on the frontend and the backend.8*/910// This was 10000 for a while and that caused regular noticeable problems:11// https://github.com/sagemathinc/cocalc/issues/459012const DEFAULT_MAX_OUTPUT_LENGTH = 250000;13//const DEFAULT_MAX_OUTPUT_LENGTH = 1000;1415// Maximum number of output messages total. If nmore, you have to click16// "Fetch additional output" to see them.17export const MAX_OUTPUT_MESSAGES = 500;18//export const MAX_OUTPUT_MESSAGES = 5;1920// Limit blob store to 100 MB. This means you can have at most this much worth21// of recents images displayed in notebooks. E.g, if you had a single22// notebook with more than this much in images, the oldest ones would23// start vanishing from output. Also, this impacts time travel.24// WARNING: It is *not* at all difficult to hit fairly large sizes, e.g., 50MB+25// when working with a notebook, by just drawing a bunch of large plots.26const MAX_BLOB_STORE_SIZE = 100 * 1e6;2728declare const localStorage: any;2930import * as immutable from "immutable";31import { Actions } from "@cocalc/util/redux/Actions";32import { three_way_merge } from "@cocalc/sync/editor/generic/util";33import { callback2, retry_until_success } from "@cocalc/util/async-utils";34import * as misc from "@cocalc/util/misc";35import { delay } from "awaiting";36import * as cell_utils from "@cocalc/jupyter/util/cell-utils";37import { JupyterStore, JupyterStoreState } from "@cocalc/jupyter/redux/store";38import { Cell, KernelInfo } from "@cocalc/jupyter/types";39import { IPynbImporter } from "@cocalc/jupyter/ipynb/import-from-ipynb";40import type { JupyterKernelInterface } from "@cocalc/jupyter/types/project-interface";41import {42char_idx_to_js_idx,43codemirror_to_jupyter_pos,44js_idx_to_char_idx,45} from "@cocalc/jupyter/util/misc";46import { SyncDB } from "@cocalc/sync/editor/db/sync";47import type { Client } from "@cocalc/sync/client/types";48import latexEnvs from "@cocalc/util/latex-envs";49import { jupyterApiClient } from "@cocalc/conat/service/jupyter";50import { type AKV, akv } from "@cocalc/conat/sync/akv";51import { reuseInFlight } from "@cocalc/util/reuse-in-flight";5253const { close, required, defaults } = misc;5455/*56The actions -- what you can do with a jupyter notebook, and also the57underlying synchronized state.58*/5960// no worries, they don't break react rendering even when they escape61const CellWriteProtectedException = new Error("CellWriteProtectedException");62const CellDeleteProtectedException = new Error("CellDeleteProtectedException");6364type State = "init" | "load" | "ready" | "closed";6566export abstract class JupyterActions extends Actions<JupyterStoreState> {67public is_project: boolean;68public is_compute_server?: boolean;69readonly path: string;70readonly project_id: string;71private _last_start?: number;72public jupyter_kernel?: JupyterKernelInterface;73private last_cursor_move_time: Date = new Date(0);74private _cursor_locs?: any;75private _introspect_request?: any;76protected set_save_status: any;77protected _client: Client;78protected _file_watcher: any;79protected _state: State;80protected restartKernelOnClose?: (...args: any[]) => void;81public asyncBlobStore: AKV;8283public _complete_request?: number;84public store: JupyterStore;85public syncdb: SyncDB;86private labels?: {87math: { [label: string]: { tag: string; id: string } };88fig: { [label: string]: { tag: string; id: string } };89};9091public _init(92project_id: string,93path: string,94syncdb: SyncDB,95store: any,96client: Client,97): void {98this._client = client;99const dbg = this.dbg("_init");100dbg("Initializing Jupyter Actions");101if (project_id == null || path == null) {102// typescript should ensure this, but just in case.103throw Error("type error -- project_id and path can't be null");104}105store.dbg = (f) => {106return client.dbg(`JupyterStore('${store.get("path")}').${f}`);107};108this._state = "init"; // 'init', 'load', 'ready', 'closed'109this.store = store;110// @ts-ignore111this.project_id = project_id;112// @ts-ignore113this.path = path;114store.syncdb = syncdb;115this.syncdb = syncdb;116// the project client is designated to manage execution/conflict, etc.117this.is_project = client.is_project();118if (this.is_project) {119this.syncdb.on("first-load", () => {120dbg("handling first load of syncdb in project");121// Clear settings the first time the syncdb is ever122// loaded, since it has settings like "ipynb last save"123// and trust, which shouldn't be initialized to124// what they were before. Not doing this caused125// https://github.com/sagemathinc/cocalc/issues/7074126this.syncdb.delete({ type: "settings" });127this.syncdb.commit();128});129}130this.is_compute_server = client.is_compute_server();131132let directory: any;133const split_path = misc.path_split(path);134if (split_path != null) {135directory = split_path.head;136}137138this.setState({139error: undefined,140has_unsaved_changes: false,141sel_ids: immutable.Set(), // immutable set of selected cells142md_edit_ids: immutable.Set(), // set of ids of markdown cells in edit mode143mode: "escape",144project_id,145directory,146path,147max_output_length: DEFAULT_MAX_OUTPUT_LENGTH,148});149150this.syncdb.on("change", this._syncdb_change);151152this.syncdb.on("close", this.close);153154this.asyncBlobStore = akv(this.blobStoreOptions());155156// Hook for additional initialization.157this.init2();158}159160protected blobStoreOptions = () => {161return {162name: `jupyter:${this.path}`,163project_id: this.project_id,164config: {165max_bytes: MAX_BLOB_STORE_SIZE,166},167} as const;168};169170// default is to do nothing, but e.g., frontend browser client171// does overload this to do a lot of additional init.172protected init2(): void {173// this can be overloaded in a derived class174}175176// Only use this on the frontend, of course.177protected getFrameActions() {178return this.redux.getEditorActions(this.project_id, this.path);179}180181sync_read_only = (): void => {182if (this._state == "closed") return;183const a = this.store.get("read_only");184const b = this.syncdb?.is_read_only();185if (a !== b) {186this.setState({ read_only: b });187this.set_cm_options();188}189};190191protected api = (opts: { timeout?: number } = {}) => {192return jupyterApiClient({193project_id: this.project_id,194path: this.path,195timeout: opts.timeout,196});197};198199protected dbg(f: string) {200if (this.is_closed()) {201// calling dbg after the actions are closed is possible; this.store would202// be undefined, and then this log message would crash, which sucks. It happened to me.203// See https://github.com/sagemathinc/cocalc/issues/6788204return (..._) => {};205}206return this._client.dbg(`JupyterActions("${this.path}").${f}`);207}208209protected close_client_only(): void {210// no-op: this can be defined in a derived class. E.g., in the frontend, it removes211// an account_change listener.212}213214public is_closed(): boolean {215return this._state === "closed" || this._state === undefined;216}217218public async close({ noSave }: { noSave?: boolean } = {}): Promise<void> {219if (this.is_closed()) {220return;221}222// ensure save to disk happens:223// - it will automatically happen for the sync-doc file, but224// we also need it for the ipynb file... as ipynb is unique225// in having two formats.226if (!noSave) {227await this.save();228}229if (this.is_closed()) {230return;231}232233if (this.syncdb != null) {234this.syncdb.close();235}236if (this._file_watcher != null) {237this._file_watcher.close();238}239if (this.is_project || this.is_compute_server) {240this.close_project_only();241} else {242this.close_client_only();243}244// We *must* destroy the action before calling close,245// since otherwise this.redux and this.name are gone,246// which makes destroying the actions properly impossible.247this.destroy();248this.store.destroy();249close(this);250this._state = "closed";251}252253public close_project_only() {254// real version is in derived class that project runs.255}256257set_error = (err: any): void => {258if (this._state === "closed") return;259if (err == null) {260this.setState({ error: undefined }); // delete from store261return;262}263if (typeof err != "string") {264err = `${err}`;265}266const cur = this.store.get("error");267// don't show the same error more than once268if ((cur?.indexOf(err) ?? -1) >= 0) {269return;270}271if (cur) {272err = err + "\n\n" + cur;273}274this.setState({ error: err });275};276277// Set the input of the given cell in the syncdb, which will also change the store.278// Might throw a CellWriteProtectedException279public set_cell_input(id: string, input: string, save = true): void {280if (!this.store) return;281if (this.store.getIn(["cells", id, "input"]) == input) {282// nothing changed. Note, I tested doing the above check using283// both this.syncdb and this.store, and this.store is orders of magnitude faster.284return;285}286if (this.check_edit_protection(id, "changing input")) {287// note -- we assume above that there was an actual change before checking288// for edit protection. Thus the above check is important.289return;290}291this._set(292{293type: "cell",294id,295input,296start: null,297end: null,298},299save,300);301}302303set_cell_output = (id: string, output: any, save = true) => {304this._set(305{306type: "cell",307id,308output,309},310save,311);312};313314setCellId = (id: string, newId: string, save = true) => {315let cell = this.store.getIn(["cells", id])?.toJS();316if (cell == null) {317return;318}319cell.id = newId;320this.syncdb.delete({ type: "cell", id });321this.syncdb.set(cell);322if (save) {323this.syncdb.commit();324}325};326327clear_selected_outputs = () => {328this.deprecated("clear_selected_outputs");329};330331// Clear output in the list of cell id's.332// NOTE: clearing output *is* allowed for non-editable cells, since the definition333// of editable is that the *input* is editable.334// See https://github.com/sagemathinc/cocalc/issues/4805335public clear_outputs(cell_ids: string[], save: boolean = true): void {336const cells = this.store.get("cells");337if (cells == null) return; // nothing to do338for (const id of cell_ids) {339const cell = cells.get(id);340if (cell == null) continue;341if (cell.get("output") != null || cell.get("exec_count")) {342this._set({ type: "cell", id, output: null, exec_count: null }, false);343}344}345if (save) {346this._sync();347}348}349350public clear_all_outputs(save: boolean = true): void {351this.clear_outputs(this.store.get_cell_list().toJS(), save);352}353354private show_not_xable_error(x: string, n: number, reason?: string): void {355if (n <= 0) return;356const verb: string = n === 1 ? "is" : "are";357const noun: string = misc.plural(n, "cell");358this.set_error(359`${n} ${noun} ${verb} protected from ${x}${360reason ? " when " + reason : ""361}.`,362);363}364365private show_not_editable_error(reason?: string): void {366this.show_not_xable_error("editing", 1, reason);367}368369private show_not_deletable_error(n: number = 1): void {370this.show_not_xable_error("deletion", n);371}372373public toggle_output(id: string, property: "collapsed" | "scrolled"): void {374this.toggle_outputs([id], property);375}376377public toggle_outputs(378cell_ids: string[],379property: "collapsed" | "scrolled",380): void {381const cells = this.store.get("cells");382if (cells == null) {383throw Error("cells not defined");384}385for (const id of cell_ids) {386const cell = cells.get(id);387if (cell == null) {388throw Error(`no cell with id ${id}`);389}390if (cell.get("cell_type", "code") == "code") {391this._set(392{393type: "cell",394id,395[property]: !cell.get(396property,397property == "scrolled" ? false : true, // default scrolled to false398),399},400false,401);402}403}404this._sync();405}406407public toggle_all_outputs(property: "collapsed" | "scrolled"): void {408this.toggle_outputs(this.store.get_cell_ids_list(), property);409}410411public set_cell_pos(id: string, pos: number, save: boolean = true): void {412this._set({ type: "cell", id, pos }, save);413}414415public moveCell(416oldIndex: number,417newIndex: number,418save: boolean = true,419): void {420if (oldIndex == newIndex) return; // nothing to do421// Move the cell that is currently at position oldIndex to422// be at position newIndex.423const cell_list = this.store.get_cell_list();424const newPos = cell_utils.moveCell({425oldIndex,426newIndex,427size: cell_list.size,428getPos: (index) =>429this.store.getIn(["cells", cell_list.get(index) ?? "", "pos"]) ?? 0,430});431this.set_cell_pos(cell_list.get(oldIndex) ?? "", newPos, save);432}433434public set_cell_type(435id: string,436cell_type: string = "code",437save: boolean = true,438): void {439if (this.check_edit_protection(id, "changing cell type")) return;440if (441cell_type !== "markdown" &&442cell_type !== "raw" &&443cell_type !== "code"444) {445throw Error(446`cell type (='${cell_type}') must be 'markdown', 'raw', or 'code'`,447);448}449const obj: any = {450type: "cell",451id,452cell_type,453};454if (cell_type !== "code") {455// delete output and exec time info when switching to non-code cell_type456obj.output = obj.start = obj.end = obj.collapsed = obj.scrolled = null;457}458this._set(obj, save);459}460461public set_selected_cell_type(cell_type: string): void {462this.deprecated("set_selected_cell_type", cell_type);463}464465set_md_cell_editing = (id: string): void => {466this.deprecated("set_md_cell_editing", id);467};468469set_md_cell_not_editing = (id: string): void => {470this.deprecated("set_md_cell_not_editing", id);471};472473// Set which cell is currently the cursor.474set_cur_id = (id: string): void => {475this.deprecated("set_cur_id", id);476};477478protected deprecated(f: string, ...args): void {479const s = "DEPRECATED JupyterActions(" + this.path + ")." + f;480console.warn(s, ...args);481}482483private set_cell_list(): void {484const cells = this.store.get("cells");485if (cells == null) {486return;487}488const cell_list = cell_utils.sorted_cell_list(cells);489if (!cell_list.equals(this.store.get_cell_list())) {490this.setState({ cell_list });491this.store.emit("cell-list-recompute");492}493}494495private syncdb_cell_change = (id: string, new_cell: any): boolean => {496const cells: immutable.Map<497string,498immutable.Map<string, any>499> = this.store.get("cells");500if (cells == null) {501throw Error("BUG -- cells must have been initialized!");502}503504let cell_list_needs_recompute = false;505//this.dbg("_syncdb_cell_change")("#{id} #{JSON.stringify(new_cell?.toJS())}")506let old_cell = cells.get(id);507if (new_cell == null) {508// delete cell509this.reset_more_output(id); // free up memory locally510if (old_cell != null) {511const cell_list = this.store.get_cell_list().filter((x) => x !== id);512this.setState({ cells: cells.delete(id), cell_list });513}514} else {515// change or add cell516old_cell = cells.get(id);517if (new_cell.equals(old_cell)) {518return false; // nothing to do519}520if (521old_cell != null &&522new_cell.get("start") > old_cell.get("start") &&523!this.is_project &&524!this.is_compute_server525) {526// cell re-evaluated so any more output is no longer valid -- clear frontend state527this.reset_more_output(id);528}529if (old_cell == null || old_cell.get("pos") !== new_cell.get("pos")) {530cell_list_needs_recompute = true;531}532// preserve cursor info if happen to have it, rather than just letting533// it get deleted whenever the cell changes.534if (old_cell?.has("cursors")) {535new_cell = new_cell.set("cursors", old_cell.get("cursors"));536}537this.setState({ cells: cells.set(id, new_cell) });538if (this.store.getIn(["edit_cell_metadata", "id"]) === id) {539this.edit_cell_metadata(id); // updates the state during active editing.540}541}542543this.onCellChange(id, new_cell, old_cell);544this.store.emit("cell_change", id, new_cell, old_cell);545546return cell_list_needs_recompute;547};548549_syncdb_change = (changes: any) => {550if (this.syncdb == null) return;551this.store.emit("syncdb-before-change");552this.__syncdb_change(changes);553this.store.emit("syncdb-after-change");554if (this.set_save_status != null) {555this.set_save_status();556}557};558559__syncdb_change = (changes: any): void => {560if (561this.syncdb == null ||562changes == null ||563(changes != null && changes.size == 0)564) {565return;566}567const doInit = this._state === "init";568let cell_list_needs_recompute = false;569570if (changes == "all" || this.store.get("cells") == null) {571// changes == 'all' is used by nbgrader to set the state...572// First time initialization, rather than some small573// update. We could use the same code, e.g.,574// calling syncdb_cell_change, but that SCALES HORRIBLY575// as the number of cells gets large!576577// this.syncdb.get() returns an immutable.List of all the records578// in the syncdb database. These look like, e.g.,579// {type: "settings", backend_state: "running", trust: true, kernel: "python3", …}580// {type: "cell", id: "22cc3e", pos: 0, input: "# small copy", state: "done"}581let cells: immutable.Map<string, Cell> = immutable.Map();582this.syncdb.get().forEach((record) => {583switch (record.get("type")) {584case "cell":585cells = cells.set(record.get("id"), record);586break;587case "settings":588if (record == null) {589return;590}591const orig_kernel = this.store.get("kernel");592const kernel = record.get("kernel");593const obj: any = {594trust: !!record.get("trust"), // case to boolean595backend_state: record.get("backend_state"),596last_backend_state: record.get("last_backend_state"),597kernel_state: record.get("kernel_state"),598metadata: record.get("metadata"), // extra custom user-specified metadata599max_output_length: bounded_integer(600record.get("max_output_length"),601100,602250000,603DEFAULT_MAX_OUTPUT_LENGTH,604),605};606if (kernel !== orig_kernel) {607obj.kernel = kernel;608obj.kernel_info = this.store.get_kernel_info(kernel);609obj.backend_kernel_info = undefined;610}611this.setState(obj);612if (613!this.is_project &&614!this.is_compute_server &&615orig_kernel !== kernel616) {617this.set_cm_options();618}619620break;621}622});623624this.setState({ cells, cell_list: cell_utils.sorted_cell_list(cells) });625cell_list_needs_recompute = false;626} else {627changes.forEach((key) => {628const type: string = key.get("type");629const record = this.syncdb.get_one(key);630switch (type) {631case "cell":632if (this.syncdb_cell_change(key.get("id"), record)) {633cell_list_needs_recompute = true;634}635break;636case "fatal":637const error = record != null ? record.get("error") : undefined;638this.setState({ fatal: error });639// This check can be deleted in a few weeks:640if (641error != null &&642error.indexOf("file is currently being read or written") !== -1643) {644// No longer relevant -- see https://github.com/sagemathinc/cocalc/issues/1742645this.syncdb.delete({ type: "fatal" });646this.syncdb.commit();647}648break;649650case "nbconvert":651if (this.is_project || this.is_compute_server) {652// before setting in store, let backend start reacting to change653this.handle_nbconvert_change(this.store.get("nbconvert"), record);654}655// Now set in our store.656this.setState({ nbconvert: record });657break;658659case "settings":660if (record == null) {661return;662}663const orig_kernel = this.store.get("kernel", null);664const kernel = record.get("kernel");665const obj: any = {666trust: !!record.get("trust"), // case to boolean667backend_state: record.get("backend_state"),668last_backend_state: record.get("last_backend_state"),669kernel_state: record.get("kernel_state"),670kernel_error: record.get("kernel_error"),671metadata: record.get("metadata"), // extra custom user-specified metadata672connection_file: record.get("connection_file") ?? "",673max_output_length: bounded_integer(674record.get("max_output_length"),675100,676250000,677DEFAULT_MAX_OUTPUT_LENGTH,678),679};680if (kernel !== orig_kernel) {681obj.kernel = kernel;682obj.kernel_info = this.store.get_kernel_info(kernel);683obj.backend_kernel_info = undefined;684}685const prev_backend_state = this.store.get("backend_state");686this.setState(obj);687if (!this.is_project && !this.is_compute_server) {688// if the kernel changes or it just started running – we set the codemirror options!689// otherwise, just when computing them without the backend information, only a crude690// heuristic sets the values and we end up with "C" formatting for custom python kernels.691// @see https://github.com/sagemathinc/cocalc/issues/5478692const started_running =693record.get("backend_state") === "running" &&694prev_backend_state !== "running";695if (orig_kernel !== kernel || started_running) {696this.set_cm_options();697}698}699break;700}701});702}703if (cell_list_needs_recompute) {704this.set_cell_list();705}706707this.__syncdb_change_post_hook(doInit);708};709710protected __syncdb_change_post_hook(_doInit: boolean) {711// no-op in base class -- does interesting and different712// things in project, browser, etc.713}714715protected onCellChange(_id: string, _new_cell: any, _old_cell: any) {716// no-op in base class. This is a hook though717// for potentially doing things when any cell changes.718}719720ensure_backend_kernel_setup() {721// nontrivial in the project, but not in client or here.722}723724protected _output_handler(_cell: any) {725throw Error("define in a derived class.");726}727728/*729WARNING: Changes via set that are made when the actions730are not 'ready' or the syncdb is not ready are ignored.731These might happen right now if the user were to try to do732some random thing at the exact moment they are closing the733notebook. See https://github.com/sagemathinc/cocalc/issues/4274734*/735_set = (obj: any, save: boolean = true) => {736if (737// _set is called during initialization, so don't738// require this._state to be 'ready'!739this._state === "closed" ||740this.store.get("read_only") ||741(this.syncdb != null && this.syncdb.get_state() != "ready")742) {743// no possible way to do anything.744return;745}746// check write protection regarding specific keys to be set747if (748obj.type === "cell" &&749obj.id != null &&750!this.store.is_cell_editable(obj.id)751) {752for (const protected_key of ["input", "cell_type", "attachments"]) {753if (misc.has_key(obj, protected_key)) {754throw CellWriteProtectedException;755}756}757}758//@dbg("_set")("obj=#{misc.to_json(obj)}")759this.syncdb.set(obj);760if (save) {761this.syncdb.commit();762}763// ensure that we update locally immediately for our own changes.764this._syncdb_change(765immutable.fromJS([misc.copy_with(obj, ["id", "type"])]),766);767};768769// might throw a CellDeleteProtectedException770_delete = (obj: any, save = true) => {771if (this._state === "closed" || this.store.get("read_only")) {772return;773}774// check: don't delete cells marked as deletable=false775if (obj.type === "cell" && obj.id != null) {776if (!this.store.is_cell_deletable(obj.id)) {777throw CellDeleteProtectedException;778}779}780this.syncdb.delete(obj);781if (save) {782this.syncdb.commit();783}784this._syncdb_change(immutable.fromJS([{ type: obj.type, id: obj.id }]));785};786787public _sync = () => {788if (this._state === "closed") {789return;790}791this.syncdb.commit();792};793794public save = async (): Promise<void> => {795if (this.store.get("read_only") || this.isDeleted()) {796// can't save when readonly or deleted797return;798}799// Save the .ipynb file to disk. Note that this800// *changes* the syncdb by updating the last save time.801try {802// Make sure syncdb content is all sent to the project.803// This does not actually save the syncdb file to disk.804// This "save" means save state to backend.805// We save two things -- first the syncdb state:806await this.syncdb.save();807if (this._state === "closed") return;808809// Second the .ipynb file state:810// Export the ipynb file to disk, being careful not to actually811// save it until the backend actually gets the given version and812// has processed it!813const version = this.syncdb.newestVersion();814try {815await this.api({ timeout: 5 * 60 * 1000 }).save_ipynb_file({ version });816} catch (err) {817console.log(`WARNING: ${err}`);818throw Error(819`There was a problem writing the ipynb file to disk -- ${err}`,820);821}822if (this._state === ("closed" as State)) return;823// Save our custom-format syncdb to disk.824await this.syncdb.save_to_disk();825} catch (err) {826if (this._state === ("closed" as State)) return;827if (err.toString().indexOf("no kernel with path") != -1) {828// This means that the kernel simply hasn't been initialized yet.829// User can try to save later, once it has.830return;831}832if (err.toString().indexOf("unknown endpoint") != -1) {833this.set_error(834"You MUST restart your project to run the latest Jupyter server! Click 'Restart Project' in your project's settings.",835);836return;837}838this.set_error(err.toString());839} finally {840if (this._state === "closed") return;841// And update the save status finally.842if (typeof this.set_save_status === "function") {843this.set_save_status();844}845}846};847848save_asap = async (): Promise<void> => {849if (this.syncdb != null) {850await this.syncdb.save();851}852};853854private id_is_available(id: string): boolean {855return this.store.getIn(["cells", id]) == null;856}857858protected new_id(is_available?: (string) => boolean): string {859while (true) {860const id = misc.uuid().slice(0, 6);861if (862(is_available != null && is_available(id)) ||863this.id_is_available(id)864) {865return id;866}867}868}869870insert_cell(delta: any): string {871this.deprecated("insert-cell", delta);872return "";873}874875insert_cell_at(876pos: number,877save: boolean = true,878id: string | undefined = undefined, // dangerous since could conflict (used by whiteboard)879): string {880if (this.store.get("read_only")) {881throw Error("document is read only");882}883const new_id = id ?? this.new_id();884this._set(885{886type: "cell",887id: new_id,888pos,889input: "",890},891save,892);893return new_id; // violates CQRS... (this *is* used elsewhere)894}895896// insert a cell adjacent to the cell with given id.897// -1 = above and +1 = below.898insert_cell_adjacent(899id: string,900delta: -1 | 1,901save: boolean = true,902): string {903const pos = cell_utils.new_cell_pos(904this.store.get("cells"),905this.store.get_cell_list(),906id,907delta,908);909return this.insert_cell_at(pos, save);910}911912delete_selected_cells = (sync = true): void => {913this.deprecated("delete_selected_cells", sync);914};915916delete_cells(cells: string[], sync: boolean = true): void {917let not_deletable: number = 0;918for (const id of cells) {919if (this.store.is_cell_deletable(id)) {920this._delete({ type: "cell", id }, false);921} else {922not_deletable += 1;923}924}925if (sync) {926this._sync();927}928if (not_deletable === 0) return;929930this.show_not_deletable_error(not_deletable);931}932933// Delete all blank code cells in the entire notebook.934delete_all_blank_code_cells(sync: boolean = true): void {935const cells: string[] = [];936for (const id of this.store.get_cell_list()) {937if (!this.store.is_cell_deletable(id)) {938continue;939}940const cell = this.store.getIn(["cells", id]);941if (cell == null) continue;942if (943cell.get("cell_type", "code") == "code" &&944cell.get("input", "").trim() == "" &&945cell.get("output", []).length == 0946) {947cells.push(id);948}949}950this.delete_cells(cells, sync);951}952953move_selected_cells = (delta: number) => {954this.deprecated("move_selected_cells", delta);955};956957undo = (): void => {958if (this.syncdb != null) {959this.syncdb.undo();960}961};962963redo = (): void => {964if (this.syncdb != null) {965this.syncdb.redo();966}967};968969in_undo_mode(): boolean {970return this.syncdb?.in_undo_mode() ?? false;971}972973public run_code_cell(974id: string,975save: boolean = true,976no_halt: boolean = false,977): void {978const cell = this.store.getIn(["cells", id]);979if (cell == null) {980// it is trivial to run a cell that does not exist -- nothing needs to be done.981return;982}983const kernel = this.store.get("kernel");984if (kernel == null || kernel === "") {985// just in case, we clear any "running" indicators986this._set({ type: "cell", id, state: "done" });987// don't attempt to run a code-cell if there is no kernel defined988this.set_error(989"No kernel set for running cells. Therefore it is not possible to run a code cell. You have to select a kernel!",990);991return;992}993994if (cell.get("state", "done") != "done") {995// already running -- stop it first somehow if you want to run it again...996return;997}998999// We mark the start timestamp uniquely, so that the backend can sort1000// multiple cells with a simultaneous time to start request.10011002let start: number = this._client.server_time().valueOf();1003if (this._last_start != null && start <= this._last_start) {1004start = this._last_start + 1;1005}1006this._last_start = start;1007this.set_jupyter_metadata(id, "outputs_hidden", undefined, false);10081009this._set(1010{1011type: "cell",1012id,1013state: "start",1014start,1015end: null,1016// time last evaluation took1017last:1018cell.get("start") != null && cell.get("end") != null1019? cell.get("end") - cell.get("start")1020: cell.get("last"),1021output: null,1022exec_count: null,1023collapsed: null,1024no_halt: no_halt ? no_halt : null,1025},1026save,1027);1028this.set_trust_notebook(true, save);1029}10301031clear_cell = (id: string, save = true) => {1032const cell = this.store.getIn(["cells", id]);10331034this._set(1035{1036type: "cell",1037id,1038state: null,1039start: null,1040end: null,1041last:1042cell?.get("start") != null && cell?.get("end") != null1043? cell?.get("end") - cell?.get("start")1044: (cell?.get("last") ?? null),1045output: null,1046exec_count: null,1047collapsed: null,1048},1049save,1050);1051};10521053run_selected_cells = (): void => {1054this.deprecated("run_selected_cells");1055};10561057public abstract run_cell(id: string, save?: boolean, no_halt?: boolean): void;10581059run_all_cells = (no_halt: boolean = false): void => {1060this.store.get_cell_list().forEach((id) => {1061this.run_cell(id, false, no_halt);1062});1063this.save_asap();1064};10651066clear_all_cell_run_state = (): void => {1067const { store } = this;1068if (!store) {1069return;1070}1071const cells = store.get("cells");1072for (const id of store.get_cell_list()) {1073const state = cells.getIn([id, "state"]);1074if (state && state != "done") {1075this._set(1076{1077type: "cell",1078id,1079state: "done",1080},1081false,1082);1083}1084}1085this.save_asap();1086};10871088// Run all cells strictly above the specified cell.1089run_all_above_cell(id: string): void {1090const i: number = this.store.get_cell_index(id);1091const v: string[] = this.store.get_cell_list().toJS();1092for (const id of v.slice(0, i)) {1093this.run_cell(id, false);1094}1095this.save_asap();1096}10971098// Run all cells below (and *including*) the specified cell.1099public run_all_below_cell(id: string): void {1100const i: number = this.store.get_cell_index(id);1101const v: string[] = this.store.get_cell_list().toJS();1102for (const id of v.slice(i)) {1103this.run_cell(id, false);1104}1105this.save_asap();1106}11071108public set_cursor_locs(locs: any[] = [], side_effect: boolean = false): void {1109this.last_cursor_move_time = new Date();1110if (this.syncdb == null) {1111// syncdb not always set -- https://github.com/sagemathinc/cocalc/issues/21071112return;1113}1114if (locs.length === 0) {1115// don't remove on blur -- cursor will fade out just fine1116return;1117}1118this._cursor_locs = locs; // remember our own cursors for splitting cell1119this.syncdb.set_cursor_locs(locs, side_effect);1120}11211122public split_cell(id: string, cursor: { line: number; ch: number }): void {1123if (this.check_edit_protection(id, "splitting cell")) {1124return;1125}1126// insert a new cell before the currently selected one1127const new_id: string = this.insert_cell_adjacent(id, -1, false);11281129// split the cell content at the cursor loc1130const cell = this.store.get("cells").get(id);1131if (cell == null) {1132throw Error(`no cell with id=${id}`);1133}1134const cell_type = cell.get("cell_type");1135if (cell_type !== "code") {1136this.set_cell_type(new_id, cell_type, false);1137}1138const input = cell.get("input");1139if (input == null) {1140this.syncdb.commit();1141return; // very easy case.1142}11431144const lines = input.split("\n");1145let v = lines.slice(0, cursor.line);1146const line: string | undefined = lines[cursor.line];1147if (line != null) {1148const left = line.slice(0, cursor.ch);1149if (left) {1150v.push(left);1151}1152}1153const top = v.join("\n");11541155v = lines.slice(cursor.line + 1);1156if (line != null) {1157const right = line.slice(cursor.ch);1158if (right) {1159v = [right].concat(v);1160}1161}1162const bottom = v.join("\n");1163this.set_cell_input(new_id, top, false);1164this.set_cell_input(id, bottom, true);1165}11661167// Copy content from the cell below the given cell into the currently1168// selected cell, then delete the cell below the given cell.1169public merge_cell_below_cell(cell_id: string, save: boolean = true): void {1170const next_id = this.store.get_cell_id(1, cell_id);1171if (next_id == null) {1172// no cell below given cell, so trivial.1173return;1174}1175for (const id of [cell_id, next_id]) {1176if (this.check_edit_protection(id, "merging cell")) return;1177}1178if (this.check_delete_protection(next_id)) return;11791180const cells = this.store.get("cells");1181if (cells == null) {1182return;1183}11841185const input: string =1186cells.getIn([cell_id, "input"], "") +1187"\n" +1188cells.getIn([next_id, "input"], "");11891190const output0 = cells.getIn([cell_id, "output"]) as any;1191const output1 = cells.getIn([next_id, "output"]) as any;1192let output: any = undefined;1193if (output0 == null) {1194output = output1;1195} else if (output1 == null) {1196output = output0;1197} else {1198// both output0 and output1 are defined; need to merge.1199// This is complicated since output is a map from string numbers.1200output = output0;1201let n = output0.size;1202for (let i = 0; i < output1.size; i++) {1203output = output.set(`${n}`, output1.get(`${i}`));1204n += 1;1205}1206}12071208this._delete({ type: "cell", id: next_id }, false);1209this._set(1210{1211type: "cell",1212id: cell_id,1213input,1214output: output != null ? output : null,1215start: null,1216end: null,1217},1218save,1219);1220}12211222// Merge the given cells into one cell, which replaces1223// the frist cell in cell_ids.1224// We also merge all output, instead of throwing away1225// all but first output (which jupyter does, and makes no sense).1226public merge_cells(cell_ids: string[]): void {1227const n = cell_ids.length;1228if (n <= 1) return; // trivial special case.1229for (let i = 0; i < n - 1; i++) {1230this.merge_cell_below_cell(cell_ids[0], i == n - 2);1231}1232}12331234// Copy the list of cells into our internal clipboard1235public copy_cells(cell_ids: string[]): void {1236const cells = this.store.get("cells");1237let global_clipboard = immutable.List();1238for (const id of cell_ids) {1239global_clipboard = global_clipboard.push(cells.get(id));1240}1241this.store.set_global_clipboard(global_clipboard);1242}12431244public studentProjectFunctionality() {1245return this.redux1246.getStore("projects")1247.get_student_project_functionality(this.project_id);1248}12491250public requireToggleReadonly(): void {1251if (this.studentProjectFunctionality().disableJupyterToggleReadonly) {1252throw Error("Toggling of write protection is disabled in this project.");1253}1254}12551256/* write protection disables any modifications, entering "edit"1257mode, and prohibits cell evaluations example: teacher handout1258notebook and student should not be able to modify an1259instruction cell in any way. */1260public toggle_write_protection_on_cells(1261cell_ids: string[],1262save: boolean = true,1263): void {1264this.requireToggleReadonly();1265this.toggle_metadata_boolean_on_cells(cell_ids, "editable", true, save);1266}12671268set_metadata_on_cells = (1269cell_ids: string[],1270key: string,1271value,1272save: boolean = true,1273) => {1274for (const id of cell_ids) {1275this.set_cell_metadata({1276id,1277metadata: { [key]: value },1278merge: true,1279save: false,1280bypass_edit_protection: true,1281});1282}1283if (save) {1284this.save_asap();1285}1286};12871288public write_protect_cells(1289cell_ids: string[],1290protect: boolean,1291save: boolean = true,1292) {1293this.set_metadata_on_cells(cell_ids, "editable", !protect, save);1294}12951296public delete_protect_cells(1297cell_ids: string[],1298protect: boolean,1299save: boolean = true,1300) {1301this.set_metadata_on_cells(cell_ids, "deletable", !protect, save);1302}13031304// this prevents any cell from being deleted, either directly, or indirectly via a "merge"1305// example: teacher handout notebook and student should not be able to modify an instruction cell in any way1306public toggle_delete_protection_on_cells(1307cell_ids: string[],1308save: boolean = true,1309): void {1310this.requireToggleReadonly();1311this.toggle_metadata_boolean_on_cells(cell_ids, "deletable", true, save);1312}13131314// This toggles the boolean value of given metadata field.1315// If not set, it is assumed to be true and toggled to false1316// For more than one cell, the first one is used to toggle1317// all cells to the inverted state1318private toggle_metadata_boolean_on_cells(1319cell_ids: string[],1320key: string,1321default_value: boolean, // default metadata value, if the metadata field is not set.1322save: boolean = true,1323): void {1324for (const id of cell_ids) {1325this.set_cell_metadata({1326id,1327metadata: {1328[key]: !this.store.getIn(1329["cells", id, "metadata", key],1330default_value,1331),1332},1333merge: true,1334save: false,1335bypass_edit_protection: true,1336});1337}1338if (save) {1339this.save_asap();1340}1341}13421343public toggle_jupyter_metadata_boolean(1344id: string,1345key: string,1346save: boolean = true,1347): void {1348const jupyter = this.store1349.getIn(["cells", id, "metadata", "jupyter"], immutable.Map())1350.toJS();1351jupyter[key] = !jupyter[key];1352this.set_cell_metadata({1353id,1354metadata: { jupyter },1355merge: true,1356save,1357});1358}13591360public set_jupyter_metadata(1361id: string,1362key: string,1363value: any,1364save: boolean = true,1365): void {1366const jupyter = this.store1367.getIn(["cells", id, "metadata", "jupyter"], immutable.Map())1368.toJS();1369if (value == null && jupyter[key] == null) return; // nothing to do.1370if (value != null) {1371jupyter[key] = value;1372} else {1373delete jupyter[key];1374}1375this.set_cell_metadata({1376id,1377metadata: { jupyter },1378merge: true,1379save,1380});1381}13821383// Paste cells from the internal clipboard; also1384// delta = 0 -- replace cell_ids cells1385// delta = 1 -- paste cells below last cell in cell_ids1386// delta = -1 -- paste cells above first cell in cell_ids.1387public paste_cells_at(cell_ids: string[], delta: 0 | 1 | -1 = 1): void {1388const clipboard = this.store.get_global_clipboard();1389if (clipboard == null || clipboard.size === 0) {1390return; // nothing to do1391}13921393if (cell_ids.length === 0) {1394// There are no cells currently selected. This can1395// happen in an edge case with slow network -- see1396// https://github.com/sagemathinc/cocalc/issues/38991397clipboard.forEach((cell, i) => {1398cell = cell.set("id", this.new_id()); // randomize the id of the cell1399cell = cell.set("pos", i);1400this._set(cell, false);1401});1402this.ensure_positions_are_unique();1403this._sync();1404return;1405}14061407let cell_before_pasted_id: string;1408const cells = this.store.get("cells");1409if (delta === -1 || delta === 0) {1410// one before first selected1411cell_before_pasted_id = this.store.get_cell_id(-1, cell_ids[0]) ?? "";1412} else if (delta === 1) {1413// last selected1414cell_before_pasted_id = cell_ids[cell_ids.length - 1];1415} else {1416// Typescript should prevent this, but just to be sure.1417throw Error(`delta (=${delta}) must be 0, -1, or 1`);1418}1419try {1420let after_pos: number, before_pos: number | undefined;1421if (delta === 0) {1422// replace, so delete cell_ids, unless just one, since1423// cursor cell_ids selection is confusing with Jupyter's model.1424if (cell_ids.length > 1) {1425this.delete_cells(cell_ids, false);1426}1427}1428// put the cells from the clipboard into the document, setting their positions1429if (cell_before_pasted_id == null) {1430// very top cell1431before_pos = undefined;1432after_pos = cells.getIn([cell_ids[0], "pos"]) as number;1433} else {1434before_pos = cells.getIn([cell_before_pasted_id, "pos"]) as1435| number1436| undefined;1437after_pos = cells.getIn([1438this.store.get_cell_id(+1, cell_before_pasted_id),1439"pos",1440]) as number;1441}1442const positions = cell_utils.positions_between(1443before_pos,1444after_pos,1445clipboard.size,1446);1447clipboard.forEach((cell, i) => {1448cell = cell.set("id", this.new_id()); // randomize the id of the cell1449cell = cell.set("pos", positions[i]);1450this._set(cell, false);1451});1452} finally {1453// very important that we save whatever is done above, so other viewers see it.1454this._sync();1455}1456}14571458// File --> Open: just show the file listing page.1459file_open = (): void => {1460if (this.redux == null) return;1461this.redux1462.getProjectActions(this.store.get("project_id"))1463.set_active_tab("files");1464};14651466// File --> New: like open, but also show the create panel1467file_new = (): void => {1468if (this.redux == null) return;1469const project_actions = this.redux.getProjectActions(1470this.store.get("project_id"),1471);1472project_actions.set_active_tab("new");1473};14741475private _get_cell_input = (id?: string | undefined): string => {1476this.deprecated("_get_cell_input", id);1477return "";1478};14791480// Version of the cell's input stored in store.1481// (A live codemirror editor could have a slightly1482// newer version, so this is only a fallback).1483get_cell_input(id: string): string {1484return this.store.getIn(["cells", id, "input"], "");1485}14861487// Attempt to fetch completions for give code and cursor_pos1488// If successful, the completions are put in store.get('completions') and looks like1489// this (as an immutable map):1490// cursor_end : 21491// cursor_start : 01492// matches : ['the', 'completions', ...]1493// status : "ok"1494// code : code1495// cursor_pos : cursor_pos1496//1497// If not successful, result is:1498// status : "error"1499// code : code1500// cursor_pos : cursor_pos1501// error : 'an error message'1502//1503// Only the most recent fetch has any impact, and calling1504// clear_complete() ensures any fetch made before that1505// is ignored.15061507// Returns true if a dialog with options appears, and false otherwise.1508public async complete(1509code: string,1510pos?: { line: number; ch: number } | number,1511id?: string,1512offset?: any,1513): Promise<boolean> {1514let cursor_pos;1515const req = (this._complete_request =1516(this._complete_request != null ? this._complete_request : 0) + 1);15171518this.setState({ complete: undefined });15191520// pos can be either a {line:?, ch:?} object as in codemirror,1521// or a number.1522if (pos == null || typeof pos == "number") {1523cursor_pos = pos;1524} else {1525cursor_pos = codemirror_to_jupyter_pos(code, pos);1526}1527cursor_pos = js_idx_to_char_idx(cursor_pos, code);15281529const start = new Date();1530let complete;1531try {1532complete = await this.api().complete({1533code,1534cursor_pos,1535});1536} catch (err) {1537if (this._complete_request > req) return false;1538this.setState({ complete: { error: err } });1539// no op for now...1540throw Error(`ignore -- ${err}`);1541//return false;1542}15431544if (this.last_cursor_move_time >= start) {1545// see https://github.com/sagemathinc/cocalc/issues/36111546throw Error("ignore");1547//return false;1548}1549if (this._complete_request > req) {1550// future completion or clear happened; so ignore this result.1551throw Error("ignore");1552//return false;1553}15541555if (complete.status !== "ok") {1556this.setState({1557complete: {1558error: complete.error ? complete.error : "completion failed",1559},1560});1561return false;1562}15631564if (complete.matches == 0) {1565return false;1566}15671568delete complete.status;1569complete.base = code;1570complete.code = code;1571complete.pos = char_idx_to_js_idx(cursor_pos, code);1572complete.cursor_start = char_idx_to_js_idx(complete.cursor_start, code);1573complete.cursor_end = char_idx_to_js_idx(complete.cursor_end, code);1574complete.id = id;1575// Set the result so the UI can then react to the change.1576if (offset != null) {1577complete.offset = offset;1578}1579// For some reason, sometimes complete.matches are not unique, which is annoying/confusing,1580// and breaks an assumption in our react code too.1581// I think the reason is e.g., a filename and a variable could be the same. We're not1582// worrying about that now.1583complete.matches = Array.from(new Set(complete.matches));1584// sort in a way that matches how JupyterLab sorts completions, which1585// is case insensitive with % magics at the bottom1586complete.matches.sort((x, y) => {1587const c = misc.cmp(getCompletionGroup(x), getCompletionGroup(y));1588if (c) {1589return c;1590}1591return misc.cmp(x.toLowerCase(), y.toLowerCase());1592});1593const i_complete = immutable.fromJS(complete);1594if (complete.matches && complete.matches.length === 1 && id != null) {1595// special case -- a unique completion and we know id of cell in which completing is given.1596this.select_complete(id, complete.matches[0], i_complete);1597return false;1598} else {1599this.setState({ complete: i_complete });1600return true;1601}1602}16031604clear_complete = (): void => {1605this._complete_request =1606(this._complete_request != null ? this._complete_request : 0) + 1;1607this.setState({ complete: undefined });1608};16091610public select_complete(1611id: string,1612item: string,1613complete?: immutable.Map<string, any>,1614): void {1615if (complete == null) {1616complete = this.store.get("complete");1617}1618this.clear_complete();1619if (complete == null) {1620return;1621}1622const input = complete.get("code");1623if (input != null && complete.get("error") == null) {1624const starting = input.slice(0, complete.get("cursor_start"));1625const ending = input.slice(complete.get("cursor_end"));1626const new_input = starting + item + ending;1627const base = complete.get("base");1628this.complete_cell(id, base, new_input);1629}1630}16311632complete_cell(id: string, base: string, new_input: string): void {1633this.merge_cell_input(id, base, new_input);1634}16351636merge_cell_input(1637id: string,1638base: string,1639input: string,1640save: boolean = true,1641): void {1642const remote = this.store.getIn(["cells", id, "input"]);1643if (remote == null || base == null || input == null) {1644return;1645}1646const new_input = three_way_merge({1647base,1648local: input,1649remote,1650});1651this.set_cell_input(id, new_input, save);1652}16531654is_introspecting(): boolean {1655const actions = this.getFrameActions() as any;1656return actions?.store?.get("introspect") != null;1657}16581659introspect_close = () => {1660if (this.is_introspecting()) {1661this.getFrameActions()?.setState({ introspect: undefined });1662}1663};16641665introspect_at_pos = async (1666code: string,1667level: 0 | 1 = 0,1668pos: { ch: number; line: number },1669): Promise<void> => {1670if (code === "") return; // no-op if there is no code (should never happen)1671await this.introspect(code, level, codemirror_to_jupyter_pos(code, pos));1672};16731674introspect = async (1675code: string,1676level: 0 | 1,1677cursor_pos?: number,1678): Promise<immutable.Map<string, any> | undefined> => {1679const req = (this._introspect_request =1680(this._introspect_request != null ? this._introspect_request : 0) + 1);16811682if (cursor_pos == null) {1683cursor_pos = code.length;1684}1685cursor_pos = js_idx_to_char_idx(cursor_pos, code);16861687let introspect;1688try {1689introspect = await this.api().introspect({1690code,1691cursor_pos,1692level,1693});1694if (introspect.status !== "ok") {1695introspect = { error: "completion failed" };1696}1697delete introspect.status;1698} catch (err) {1699introspect = { error: err };1700}1701if (this._introspect_request > req) return;1702const i = immutable.fromJS(introspect);1703this.getFrameActions()?.setState({1704introspect: i,1705});1706return i; // convenient / useful, e.g., for use by whiteboard.1707};17081709clear_introspect = (): void => {1710this._introspect_request =1711(this._introspect_request != null ? this._introspect_request : 0) + 1;1712this.getFrameActions()?.setState({ introspect: undefined });1713};17141715public async signal(signal = "SIGINT"): Promise<void> {1716const api = this.api({ timeout: 5000 });1717try {1718await api.signal(signal);1719} catch (err) {1720this.set_error(err);1721}1722}17231724// Kill the running kernel and does NOT start it up again.1725halt = reuseInFlight(async (): Promise<void> => {1726if (this.restartKernelOnClose != null && this.jupyter_kernel != null) {1727this.jupyter_kernel.removeListener("closed", this.restartKernelOnClose);1728delete this.restartKernelOnClose;1729}1730this.clear_all_cell_run_state();1731await this.signal("SIGKILL");1732// Wait a little, since SIGKILL has to really happen on backend,1733// and server has to respond and change state.1734const not_running = (s): boolean => {1735if (this._state === "closed") return true;1736const t = s.get_one({ type: "settings" });1737return t != null && t.get("backend_state") != "running";1738};1739try {1740await this.syncdb.wait(not_running, 30);1741// worked -- and also no need to show "kernel got killed" message since this was intentional.1742this.set_error("");1743} catch (err) {1744// failed1745this.set_error(err);1746}1747});17481749restart = reuseInFlight(async (): Promise<void> => {1750await this.halt();1751if (this._state === "closed") return;1752this.clear_all_cell_run_state();1753// Actually start it running again (rather than waiting for1754// user to do something), since this is called "restart".1755try {1756await this.set_backend_kernel_info(); // causes kernel to start1757} catch (err) {1758this.set_error(err);1759}1760});17611762public shutdown = reuseInFlight(async (): Promise<void> => {1763if (this._state === ("closed" as State)) {1764return;1765}1766await this.signal("SIGKILL");1767if (this._state === ("closed" as State)) {1768return;1769}1770this.clear_all_cell_run_state();1771await this.save_asap();1772});17731774set_backend_kernel_info = async (): Promise<void> => {1775if (this._state === "closed" || this.syncdb.is_read_only()) {1776return;1777}17781779if (this.is_project || this.is_compute_server) {1780const dbg = this.dbg(`set_backend_kernel_info ${misc.uuid()}`);1781if (1782this.jupyter_kernel == null ||1783this.jupyter_kernel.get_state() == "closed"1784) {1785dbg("no Jupyter kernel defined");1786return;1787}1788dbg("getting kernel_info...");1789let backend_kernel_info: KernelInfo;1790try {1791backend_kernel_info = immutable.fromJS(1792await this.jupyter_kernel.kernel_info(),1793);1794} catch (err) {1795dbg(`error = ${err}`);1796return;1797}1798this.setState({ backend_kernel_info });1799} else {1800await this._set_backend_kernel_info_client();1801}1802};18031804_set_backend_kernel_info_client = reuseInFlight(async (): Promise<void> => {1805await retry_until_success({1806max_time: 120000,1807start_delay: 1000,1808max_delay: 10000,1809f: this._fetch_backend_kernel_info_from_server,1810desc: "jupyter:_set_backend_kernel_info_client",1811});1812});18131814_fetch_backend_kernel_info_from_server = async (): Promise<void> => {1815const f = async () => {1816if (this._state === "closed") {1817return;1818}1819const data = await this.api().kernel_info();1820this.setState({1821backend_kernel_info: immutable.fromJS(data),1822// this is when the server for this doc started, not when kernel last started!1823start_time: data.start_time,1824});1825};1826try {1827await retry_until_success({1828max_time: 1000 * 60 * 30,1829start_delay: 500,1830max_delay: 3000,1831f,1832desc: "jupyter:_fetch_backend_kernel_info_from_server",1833});1834} catch (err) {1835this.set_error(err);1836}1837if (this.is_closed()) return;1838// Update the codemirror editor options.1839this.set_cm_options();1840};18411842// Do a file action, e.g., 'compress', 'delete', 'rename', 'duplicate', 'move',1843// 'copy', 'share', 'download', 'open_file', 'close_file', 'reopen_file'1844// Each just shows1845// the corresponding dialog in1846// the file manager, so gives a step to confirm, etc.1847// The path may optionally be *any* file in this project.1848public async file_action(action_name: string, path?: string): Promise<void> {1849if (this._state == "closed") return;1850const a = this.redux.getProjectActions(this.project_id);1851if (path == null) {1852path = this.store.get("path");1853if (path == null) {1854throw Error("path must be defined in the store to use default");1855}1856}1857if (action_name === "reopen_file") {1858a.close_file(path);1859// ensure the side effects from changing registered1860// editors in project_file.* finish happening1861await delay(0);1862a.open_file({ path });1863return;1864}1865if (action_name === "close_file") {1866await this.syncdb.save();1867a.close_file(path);1868return;1869}1870if (action_name === "open_file") {1871a.open_file({ path });1872return;1873}1874if (action_name == "download") {1875a.download_file({ path });1876return;1877}1878const { head, tail } = misc.path_split(path);1879a.open_directory(head);1880a.set_all_files_unchecked();1881a.set_file_checked(path, true);1882return a.set_file_action(action_name, () => tail);1883}18841885set_max_output_length = (n) => {1886return this._set({1887type: "settings",1888max_output_length: n,1889});1890};18911892fetch_more_output = async (id: string): Promise<void> => {1893const time = this._client.server_time().valueOf();1894try {1895const more_output = await this.api({ timeout: 60000 }).more_output(id);1896if (!this.store.getIn(["cells", id, "scrolled"])) {1897// make output area scrolled, since there is going to be a lot of output1898this.toggle_output(id, "scrolled");1899}1900this.set_more_output(id, { time, mesg_list: more_output });1901} catch (err) {1902this.set_error(err);1903}1904};19051906// NOTE: set_more_output on project-actions is different1907set_more_output = (id: string, more_output: any, _?: any): void => {1908if (this.store.getIn(["cells", id]) == null) {1909return;1910}1911const x = this.store.get("more_output", immutable.Map());1912this.setState({1913more_output: x.set(id, immutable.fromJS(more_output)),1914});1915};19161917reset_more_output = (id?: any): void => {1918let left: any;1919const more_output =1920(left = this.store.get("more_output")) != null ? left : immutable.Map();1921if (more_output.has(id)) {1922this.setState({ more_output: more_output.delete(id) });1923}1924};19251926protected set_cm_options(): void {1927// this only does something in browser-actions.1928}19291930set_trust_notebook = (trust: any, save: boolean = true) => {1931return this._set(1932{1933type: "settings",1934trust: !!trust,1935},1936save,1937); // case to bool1938};19391940scroll(pos): any {1941this.deprecated("scroll", pos);1942}19431944// submit input for a particular cell -- this is used by the1945// Input component output message type for interactive input.1946public async submit_input(id: string, value: string): Promise<void> {1947const output = this.store.getIn(["cells", id, "output"]);1948if (output == null) {1949return;1950}1951const n = `${output.size - 1}`;1952const mesg = output.get(n);1953if (mesg == null) {1954return;1955}19561957if (mesg.getIn(["opts", "password"])) {1958// handle password input separately by first submitting to the backend.1959try {1960await this.submit_password(id, value);1961} catch (err) {1962this.set_error(`Error setting backend key/value store (${err})`);1963return;1964}1965const m = value.length;1966value = "";1967for (let i = 0; i < m; i++) {1968value == "●";1969}1970this.set_cell_output(id, output.set(n, mesg.set("value", value)), false);1971this.save_asap();1972return;1973}19741975this.set_cell_output(id, output.set(n, mesg.set("value", value)), false);1976this.save_asap();1977}19781979submit_password = async (id: string, value: any): Promise<void> => {1980await this.set_in_backend_key_value_store(id, value);1981};19821983set_in_backend_key_value_store = async (1984key: any,1985value: any,1986): Promise<void> => {1987try {1988await this.api().store({ key, value });1989} catch (err) {1990this.set_error(err);1991}1992};19931994set_to_ipynb = async (1995ipynb: any,1996data_only: boolean = false,1997): Promise<void> => {1998/*1999* set_to_ipynb - set from ipynb object. This is2000* mainly meant to be run on the backend in the project,2001* but is also run on the frontend too, e.g.,2002* for client-side nbviewer (in which case it won't remove images, etc.).2003*2004* See the documentation for load_ipynb_file in project-actions.ts for2005* documentation about the data_only input variable.2006*/2007if (typeof ipynb != "object") {2008throw Error("ipynb must be an object");2009}20102011this._state = "load";20122013//dbg(misc.to_json(ipynb))20142015// We try to parse out the kernel so we can use process_output below.2016// (TODO: rewrite so process_output is not associated with a specific kernel)2017let kernel: string | undefined;2018const ipynb_metadata = ipynb.metadata;2019if (ipynb_metadata != null) {2020const kernelspec = ipynb_metadata.kernelspec;2021if (kernelspec != null) {2022kernel = kernelspec.name;2023}2024}2025//dbg("kernel in ipynb: name='#{kernel}'")20262027const existing_ids = this.store.get_cell_list().toJS();20282029let set, trust;2030if (data_only) {2031trust = undefined;2032set = function () {};2033} else {2034if (typeof this.reset_more_output === "function") {2035this.reset_more_output();2036// clear the more output handler (only on backend)2037}2038// We delete all of the cells.2039// We do NOT delete everything, namely the last_loaded and2040// the settings entry in the database, because that would2041// throw away important information, e.g., the current kernel2042// and its state. NOTe: Some of that extra info *should* be2043// moved to a different ephemeral table, but I haven't got2044// around to doing so.2045this.syncdb.delete({ type: "cell" });2046// preserve trust state across file updates/loads2047trust = this.store.get("trust");2048set = (obj) => {2049this.syncdb.set(obj);2050};2051}20522053// Change kernel to what is in the file if necessary:2054set({ type: "settings", kernel });2055this.ensure_backend_kernel_setup();20562057const importer = new IPynbImporter();20582059// NOTE: Below we re-use any existing ids to make the patch that defines changing2060// to the contents of ipynb more efficient. In case of a very slight change2061// on disk, this can be massively more efficient.20622063importer.import({2064ipynb,2065existing_ids,2066new_id: this.new_id.bind(this),2067output_handler:2068this.jupyter_kernel != null2069? this._output_handler.bind(this)2070: undefined, // undefined in client; defined in project2071});20722073if (data_only) {2074importer.close();2075return;2076}20772078// Set all the cells2079const object = importer.cells();2080for (const _ in object) {2081const cell = object[_];2082set(cell);2083}20842085// Set the settings2086set({ type: "settings", kernel: importer.kernel(), trust });20872088// Set extra user-defined metadata2089const metadata = importer.metadata();2090if (metadata != null) {2091set({ type: "settings", metadata });2092}20932094importer.close();20952096this.syncdb.commit();2097await this.syncdb.save();2098this.ensure_backend_kernel_setup();2099this._state = "ready";2100};21012102public set_cell_slide(id: string, value: any): void {2103if (!value) {2104value = null; // delete2105}2106if (this.check_edit_protection(id, "making a cell aslide")) {2107return;2108}2109this._set({2110type: "cell",2111id,2112slide: value,2113});2114}21152116public ensure_positions_are_unique(): void {2117if (this._state != "ready" || this.store == null) {2118// because of debouncing, this ensure_positions_are_unique can2119// be called after jupyter actions are closed.2120return;2121}2122const changes = cell_utils.ensure_positions_are_unique(2123this.store.get("cells"),2124);2125if (changes != null) {2126for (const id in changes) {2127const pos = changes[id];2128this.set_cell_pos(id, pos, false);2129}2130}2131this._sync();2132}21332134public set_default_kernel(kernel?: string): void {2135if (kernel == null || kernel === "") return;2136// doesn't make sense for project (right now at least)2137if (this.is_project || this.is_compute_server) return;2138const account_store = this.redux.getStore("account") as any;2139if (account_store == null) return;2140const cur: any = {};2141// if available, retain existing jupyter config2142const acc_jup = account_store.getIn(["editor_settings", "jupyter"]);2143if (acc_jup != null) {2144Object.assign(cur, acc_jup.toJS());2145}2146// set new kernel and save it2147cur.kernel = kernel;2148(this.redux.getTable("account") as any).set({2149editor_settings: { jupyter: cur },2150});2151}21522153edit_attachments = (id: string): void => {2154this.setState({ edit_attachments: id });2155};21562157_attachment_markdown = (name: any) => {2158return ``;2159// Don't use this because official Jupyter tooling can't deal with it. See2160// https://github.com/sagemathinc/cocalc/issues/50552161return `<img src="attachment:${name}" style="max-width:100%">`;2162};21632164insert_input_at_cursor = (id: string, s: string, save: boolean = true) => {2165// TODO: this maybe doesn't make sense anymore...2166// TODO: redo this -- note that the input below is wrong, since it is2167// from the store, not necessarily from what is live in the cell.21682169if (this.store.getIn(["cells", id]) == null) {2170return;2171}2172if (this.check_edit_protection(id, "inserting input")) {2173return;2174}2175let input = this.store.getIn(["cells", id, "input"], "");2176const cursor = this._cursor_locs != null ? this._cursor_locs[0] : undefined;2177if ((cursor != null ? cursor.id : undefined) === id) {2178const v = input.split("\n");2179const line = v[cursor.y];2180v[cursor.y] = line.slice(0, cursor.x) + s + line.slice(cursor.x);2181input = v.join("\n");2182} else {2183input += s;2184}2185return this._set({ type: "cell", id, input }, save);2186};21872188// Sets attachments[name] = val2189public set_cell_attachment(2190id: string,2191name: string,2192val: any,2193save: boolean = true,2194): void {2195const cell = this.store.getIn(["cells", id]);2196if (cell == null) {2197throw Error(`no cell ${id}`);2198}2199if (this.check_edit_protection(id, "setting an attachment")) return;2200const attachments = cell.get("attachments", immutable.Map()).toJS();2201attachments[name] = val;2202this._set(2203{2204type: "cell",2205id,2206attachments,2207},2208save,2209);2210}22112212public async add_attachment_to_cell(id: string, path: string): Promise<void> {2213if (this.check_edit_protection(id, "adding an attachment")) {2214return;2215}2216let name: string = encodeURIComponent(2217misc.path_split(path).tail.toLowerCase(),2218);2219name = name.replace(/\(/g, "%28").replace(/\)/g, "%29");2220this.set_cell_attachment(id, name, { type: "load", value: path });2221await callback2(this.store.wait, {2222until: () =>2223this.store.getIn(["cells", id, "attachments", name, "type"]) === "sha1",2224timeout: 0,2225});2226// This has to happen in the next render loop, since changing immediately2227// can update before the attachments props are updated.2228await delay(10);2229this.insert_input_at_cursor(id, this._attachment_markdown(name), true);2230}22312232delete_attachment_from_cell = (id: string, name: any) => {2233if (this.check_edit_protection(id, "deleting an attachment")) {2234return;2235}2236this.set_cell_attachment(id, name, null, false);2237this.set_cell_input(2238id,2239misc.replace_all(2240this._get_cell_input(id),2241this._attachment_markdown(name),2242"",2243),2244);2245};22462247add_tag(id: string, tag: string, save: boolean = true): void {2248if (this.check_edit_protection(id, "adding a tag")) {2249return;2250}2251return this._set(2252{2253type: "cell",2254id,2255tags: { [tag]: true },2256},2257save,2258);2259}22602261remove_tag(id: string, tag: string, save: boolean = true): void {2262if (this.check_edit_protection(id, "removing a tag")) {2263return;2264}2265return this._set(2266{2267type: "cell",2268id,2269tags: { [tag]: null },2270},2271save,2272);2273}22742275toggle_tag(id: string, tag: string, save: boolean = true): void {2276const cell = this.store.getIn(["cells", id]);2277if (cell == null) {2278throw Error(`no cell with id ${id}`);2279}2280const tags = cell.get("tags");2281if (tags == null || !tags.get(tag)) {2282this.add_tag(id, tag, save);2283} else {2284this.remove_tag(id, tag, save);2285}2286}22872288edit_cell_metadata = (id: string): void => {2289const metadata = this.store.getIn(2290["cells", id, "metadata"],2291immutable.Map(),2292);2293this.setState({ edit_cell_metadata: { id, metadata } });2294};22952296public set_global_metadata(metadata: object, save: boolean = true): void {2297const cur = this.syncdb.get_one({ type: "settings" })?.toJS()?.metadata;2298if (cur) {2299metadata = {2300...cur,2301...metadata,2302};2303}2304this.syncdb.set({ type: "settings", metadata });2305if (save) {2306this.syncdb.commit();2307}2308}23092310public set_cell_metadata(opts: {2311id: string;2312metadata?: object; // not given = delete it2313save?: boolean; // defaults to true if not given2314merge?: boolean; // defaults to false if not given, in which case sets metadata, rather than merge. If true, does a SHALLOW merge.2315bypass_edit_protection?: boolean;2316}): void {2317let { id, metadata, save, merge, bypass_edit_protection } = (opts =2318defaults(opts, {2319id: required,2320metadata: required,2321save: true,2322merge: false,2323bypass_edit_protection: false,2324}));23252326if (2327!bypass_edit_protection &&2328this.check_edit_protection(id, "editing cell metadata")2329) {2330return;2331}2332// Special case: delete metdata (unconditionally)2333if (metadata == null || misc.len(metadata) === 0) {2334this._set(2335{2336type: "cell",2337id,2338metadata: null,2339},2340save,2341);2342return;2343}23442345if (merge) {2346const current = this.store.getIn(2347["cells", id, "metadata"],2348immutable.Map(),2349);2350metadata = current.merge(immutable.fromJS(metadata)).toJS();2351}23522353// special fields2354// "collapsed", "scrolled", "slideshow", and "tags"2355if (metadata.tags != null) {2356for (const tag of metadata.tags) {2357this.add_tag(id, tag, false);2358}2359delete metadata.tags;2360}2361// important to not store redundant inconsistent fields:2362for (const field of ["collapsed", "scrolled", "slideshow"]) {2363if (metadata[field] != null) {2364delete metadata[field];2365}2366}23672368if (!merge) {2369// first delete -- we have to do this due to shortcomings in syncdb, but it2370// can have annoying side effects on the UI2371this._set(2372{2373type: "cell",2374id,2375metadata: null,2376},2377false,2378);2379}2380// now set2381this._set(2382{2383type: "cell",2384id,2385metadata,2386},2387save,2388);2389if (this.store.getIn(["edit_cell_metadata", "id"]) === id) {2390this.edit_cell_metadata(id); // updates the state while editing2391}2392}23932394set_raw_ipynb(): void {2395if (this._state != "ready") {2396// lies otherwise...2397return;2398}23992400this.setState({2401raw_ipynb: immutable.fromJS(this.store.get_ipynb()),2402});2403}24042405set_mode(mode: "escape" | "edit"): void {2406this.deprecated("set_mode", mode);2407}24082409public focus(wait?: boolean): void {2410this.deprecated("focus", wait);2411}24122413public blur(): void {2414this.deprecated("blur");2415}24162417public check_edit_protection(id: string, reason?: string): boolean {2418if (!this.store.is_cell_editable(id)) {2419this.show_not_editable_error(reason);2420return true;2421} else {2422return false;2423}2424}24252426public check_delete_protection(id: string): boolean {2427if (!this.store.is_cell_deletable(id)) {2428this.show_not_deletable_error();2429return true;2430} else {2431return false;2432}2433}24342435split_current_cell = () => {2436this.deprecated("split_current_cell");2437};24382439handle_nbconvert_change(_oldVal, _newVal): void {2440throw Error("define this in derived class");2441}24422443set_kernel_error = (err) => {2444this._set({2445type: "settings",2446kernel_error: `${err}`,2447});2448this.save_asap();2449};24502451// Returns true if the .ipynb file was explicitly deleted.2452// Returns false if it is NOT known to be explicitly deleted.2453// Returns undefined if not known or implemented.2454// NOTE: this is different than the file not being present on disk.2455protected isDeleted = () => {2456if (this.store == null || this._client == null) {2457return;2458}2459return this._client.is_deleted?.(this.store.get("path"), this.project_id);2460// [ ] TODO: we also need to do this on compute servers, but2461// they don't yet have the listings table.2462};24632464processRenderedMarkdown = ({ value, id }: { value: string; id: string }) => {2465value = latexEnvs(value);24662467const labelRegExp = /\s*\\label\{.*?\}\s*/g;2468const figLabelRegExp = /\s*\\figlabel\{.*?\}\s*/g;2469if (this.labels == null) {2470const labels = (this.labels = { math: {}, fig: {} });2471// do initial full document scan2472if (this.store == null) {2473return;2474}2475const cells = this.store.get("cells");2476if (cells == null) {2477return;2478}2479let mathN = 0;2480let figN = 0;2481for (const id of this.store.get_cell_ids_list()) {2482const cell = cells.get(id);2483if (cell?.get("cell_type") == "markdown") {2484const value = latexEnvs(cell.get("input") ?? "");2485value.replace(labelRegExp, (labelContent) => {2486const label = extractLabel(labelContent);2487mathN += 1;2488labels.math[label] = { tag: `${mathN}`, id };2489return "";2490});2491value.replace(figLabelRegExp, (labelContent) => {2492const label = extractLabel(labelContent);2493figN += 1;2494labels.fig[label] = { tag: `${figN}`, id };2495return "";2496});2497}2498}2499}2500const labels = this.labels;2501if (labels == null) {2502throw Error("bug");2503}2504value = value.replace(labelRegExp, (labelContent) => {2505const label = extractLabel(labelContent);2506if (labels.math[label] == null) {2507labels.math[label] = { tag: `${misc.len(labels.math) + 1}`, id };2508} else {2509// in case it moved to a different cell due to cut/paste2510labels.math[label].id = id;2511}2512return `\\tag{${labels.math[label].tag}}`;2513});2514value = value.replace(figLabelRegExp, (labelContent) => {2515const label = extractLabel(labelContent);2516if (labels.fig[label] == null) {2517labels.fig[label] = { tag: `${misc.len(labels.fig) + 1}`, id };2518} else {2519// in case it moved to a different cell due to cut/paste2520labels.fig[label].id = id;2521}2522return ` ${labels.fig[label].tag ?? "?"}`;2523});2524const refRegExp = /\\ref\{.*?\}/g;2525value = value.replace(refRegExp, (refContent) => {2526const label = extractLabel(refContent);2527if (labels.fig[label] == null && labels.math[label] == null) {2528// do not know the label2529return "?";2530}2531const { tag, id } = labels.fig[label] ?? labels.math[label];2532return `[${tag}](#id=${id})`;2533});25342535return value;2536};25372538// Update run progress, which is a number between 0 and 100,2539// giving the number of runnable cells that have been run since2540// the kernel was last set to the running state.2541// Currently only run in the browser, but could maybe be useful2542// elsewhere someday.2543updateRunProgress = () => {2544if (this.store == null) {2545return;2546}2547if (this.store.get("backend_state") != "running") {2548this.setState({ runProgress: 0 });2549return;2550}2551const cells = this.store.get("cells");2552if (cells == null) {2553return;2554}2555const last = this.store.get("last_backend_state");2556if (last == null) {2557// not supported yet, e.g., old backend, kernel never started2558return;2559}2560// count of number of cells that are runnable and2561// have start greater than last, and end set...2562// count a currently running cell as 0.5.2563let total = 0;2564let ran = 0;2565for (const [_, cell] of cells) {2566if (2567cell.get("cell_type", "code") != "code" ||2568!cell.get("input")?.trim()2569) {2570// not runnable2571continue;2572}2573total += 1;2574if ((cell.get("start") ?? 0) >= last) {2575if (cell.get("end")) {2576ran += 1;2577} else {2578ran += 0.5;2579}2580}2581}2582this.setState({ runProgress: total > 0 ? (100 * ran) / total : 100 });2583};2584}25852586function extractLabel(content: string): string {2587const i = content.indexOf("{");2588const j = content.lastIndexOf("}");2589return content.slice(i + 1, j);2590}25912592function bounded_integer(n: any, min: any, max: any, def: any) {2593if (typeof n !== "number") {2594n = parseInt(n);2595}2596if (isNaN(n)) {2597return def;2598}2599n = Math.round(n);2600if (n < min) {2601return min;2602}2603if (n > max) {2604return max;2605}2606return n;2607}26082609function getCompletionGroup(x: string): number {2610switch (x[0]) {2611case "_":2612return 1;2613case "%":2614return 2;2615default:2616return 0;2617}2618}261926202621