Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/jupyter/redux/actions.ts
Views: 687
/*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 = 1000000;1314declare const localStorage: any;1516import { reuseInFlight } from "@cocalc/util/reuse-in-flight";17import * as immutable from "immutable";18import { Actions } from "@cocalc/util/redux/Actions";19import { three_way_merge } from "@cocalc/sync/editor/generic/util";20import { callback2, retry_until_success } from "@cocalc/util/async-utils";21import * as misc from "@cocalc/util/misc";22import { callback, delay } from "awaiting";23import * as cell_utils from "@cocalc/jupyter/util/cell-utils";24import {25JupyterStore,26JupyterStoreState,27show_kernel_selector_reasons,28} from "@cocalc/jupyter/redux/store";29import { Cell, KernelInfo } from "@cocalc/jupyter/types";30import { get_kernels_by_name_or_language } from "@cocalc/jupyter/util/misc";31import { Kernel, Kernels } from "@cocalc/jupyter/util/misc";32import { IPynbImporter } from "@cocalc/jupyter/ipynb/import-from-ipynb";33import type { JupyterKernelInterface } from "@cocalc/jupyter/types/project-interface";34import {35char_idx_to_js_idx,36codemirror_to_jupyter_pos,37js_idx_to_char_idx,38} from "@cocalc/jupyter/util/misc";39import { SyncDB } from "@cocalc/sync/editor/db/sync";40import type { Client } from "@cocalc/sync/client/types";41import { once } from "@cocalc/util/async-utils";42import latexEnvs from "@cocalc/util/latex-envs";4344const { close, required, defaults } = misc;4546// local cache: map project_id (string) -> kernels (immutable)47let jupyter_kernels = immutable.Map<string, Kernels>();4849/*50The actions -- what you can do with a jupyter notebook, and also the51underlying synchronized state.52*/5354// no worries, they don't break react rendering even when they escape55const CellWriteProtectedException = new Error("CellWriteProtectedException");56const CellDeleteProtectedException = new Error("CellDeleteProtectedException");5758export abstract class JupyterActions extends Actions<JupyterStoreState> {59public is_project: boolean;60public is_compute_server?: boolean;61readonly path: string;62readonly project_id: string;63private _last_start?: number;64public jupyter_kernel?: JupyterKernelInterface;65private last_cursor_move_time: Date = new Date(0);66private _cursor_locs?: any;67private _introspect_request?: any;68protected set_save_status: any;69protected _client: Client;70protected _file_watcher: any;71protected _state: any;72protected restartKernelOnClose?: (...args: any[]) => void;7374public _complete_request?: number;75public store: JupyterStore;76public syncdb: SyncDB;77private labels?: {78math: { [label: string]: { tag: string; id: string } };79fig: { [label: string]: { tag: string; id: string } };80};8182public _init(83project_id: string,84path: string,85syncdb: SyncDB,86store: any,87client: Client,88): void {89this._client = client;90const dbg = this.dbg("_init");91dbg("Initializing Jupyter Actions");92if (project_id == null || path == null) {93// typescript should ensure this, but just in case.94throw Error("type error -- project_id and path can't be null");95}96store.dbg = (f) => {97return client.dbg(`JupyterStore('${store.get("path")}').${f}`);98};99this._state = "init"; // 'init', 'load', 'ready', 'closed'100this.store = store;101// @ts-ignore102this.project_id = project_id;103// @ts-ignore104this.path = path;105store.syncdb = syncdb;106this.syncdb = syncdb;107// the project client is designated to manage execution/conflict, etc.108this.is_project = client.is_project();109if (this.is_project) {110this.syncdb.on("first-load", () => {111dbg("handling first load of syncdb in project");112// Clear settings the first time the syncdb is ever113// loaded, since it has settings like "ipynb last save"114// and trust, which shouldn't be initialized to115// what they were before. Not doing this caused116// https://github.com/sagemathinc/cocalc/issues/7074117this.syncdb.delete({ type: "settings" });118this.syncdb.commit();119});120}121122this.is_compute_server = client.is_compute_server();123124let directory: any;125const split_path = misc.path_split(path);126if (split_path != null) {127directory = split_path.head;128}129130this.setState({131error: undefined,132has_unsaved_changes: false,133sel_ids: immutable.Set(), // immutable set of selected cells134md_edit_ids: immutable.Set(), // set of ids of markdown cells in edit mode135mode: "escape",136project_id,137directory,138path,139max_output_length: DEFAULT_MAX_OUTPUT_LENGTH,140});141142this.syncdb.on("change", this._syncdb_change);143144this.syncdb.on("close", this.close);145146if (!this.is_project) {147this.fetch_jupyter_kernels();148}149150// Hook for additional initialization.151this.init2();152}153154// default is to do nothing, but e.g., frontend browser client155// does overload this to do a lot of additional init.156protected init2(): void {157// this can be overloaded in a derived class158}159160// Only use this on the frontend, of course.161protected getFrameActions() {162return this.redux.getEditorActions(this.project_id, this.path);163}164165protected async set_kernel_after_load(): Promise<void> {166// Browser Client: Wait until the .ipynb file has actually been parsed into167// the (hidden, e.g. .a.ipynb.sage-jupyter2) syncdb file,168// then set the kernel, if necessary.169try {170await this.syncdb.wait((s) => !!s.get_one({ type: "file" }), 600);171} catch (err) {172if (this._state != "ready") {173// Probably user just closed the notebook before it finished174// loading, so we don't need to set the kernel.175return;176}177throw Error("error waiting for ipynb file to load");178}179this._syncdb_init_kernel();180}181182sync_read_only = (): void => {183if (this._state == "closed") return;184const a = this.store.get("read_only");185const b = this.syncdb?.is_read_only();186if (a !== b) {187this.setState({ read_only: b });188this.set_cm_options();189}190};191192private apiCallHandler: {193id: number; // this is a sequential id used for request/response pairing194// when get response from computer server, one of these callbacks gets called:195responseCallbacks: { [id: number]: (err: any, response?: any) => void };196} | null = null;197198private initApiCallHandler = () => {199this.apiCallHandler = { id: 0, responseCallbacks: {} };200const { responseCallbacks } = this.apiCallHandler;201this.syncdb.on("message", (data) => {202const cb = responseCallbacks[data.id];203if (cb != null) {204delete responseCallbacks[data.id];205if (data.response?.event == "error") {206cb(data.response.message ?? "error");207} else {208cb(undefined, data.response);209}210}211});212};213214protected async api_call(215endpoint: string,216query?: any,217timeout_ms?: number,218): Promise<any> {219if (this._state === "closed" || this.syncdb == null) {220throw Error("closed -- jupyter actions -- api_call");221}222if (this.syncdb.get_state() == "init") {223await once(this.syncdb, "ready");224}225if (this.apiCallHandler == null) {226this.initApiCallHandler();227}228if (this.apiCallHandler == null) {229throw Error("bug");230}231232this.apiCallHandler.id += 1;233const { id, responseCallbacks } = this.apiCallHandler;234await this.syncdb.sendMessageToProject({235event: "api-request",236id,237path: this.path,238endpoint,239query,240});241const waitForResponse = (cb) => {242if (timeout_ms) {243setTimeout(() => {244if (responseCallbacks[id] == null) return;245cb("timeout");246delete responseCallbacks[id];247}, timeout_ms);248}249responseCallbacks[id] = cb;250};251const resp = await callback(waitForResponse);252return resp;253}254255protected dbg = (f: string) => {256if (this.is_closed()) {257// calling dbg after the actions are closed is possible; this.store would258// be undefined, and then this log message would crash, which sucks. It happened to me.259// See https://github.com/sagemathinc/cocalc/issues/6788260return (..._) => {};261}262return this._client.dbg(`JupyterActions("${this.path}").${f}`);263};264265protected close_client_only(): void {266// no-op: this can be defined in a derived class. E.g., in the frontend, it removes267// an account_change listener.268}269270public is_closed(): boolean {271return this._state === "closed" || this._state === undefined;272}273274public async close({ noSave }: { noSave?: boolean } = {}): Promise<void> {275if (this.is_closed()) {276return;277}278// ensure save to disk happens:279// - it will automatically happen for the sync-doc file, but280// we also need it for the ipynb file... as ipynb is unique281// in having two formats.282if (!noSave) {283await this.save();284}285if (this.is_closed()) {286return;287}288289if (this.syncdb != null) {290this.syncdb.close();291}292if (this._file_watcher != null) {293this._file_watcher.close();294}295if (this.is_project || this.is_compute_server) {296this.close_project_only();297} else {298this.close_client_only();299}300// We *must* destroy the action before calling close,301// since otherwise this.redux and this.name are gone,302// which makes destroying the actions properly impossible.303this.destroy();304this.store.destroy();305close(this);306this._state = "closed";307}308309public close_project_only() {310// real version is in derived class that project runs.311}312313fetch_jupyter_kernels = async (): Promise<void> => {314let data;315const f = async () => {316data = await this.api_call("kernels", undefined, 5000);317if (this._state === "closed") {318return;319}320};321try {322await retry_until_success({323max_time: 1000 * 15, // up to 15 seconds324start_delay: 500,325max_delay: 5000,326f,327desc: "jupyter:fetch_jupyter_kernels",328});329} catch (err) {330this.set_error(err);331return;332}333if (this._state === "closed") {334return;335}336// we filter kernels that are disabled for the cocalc notebook – motivated by a broken GAP kernel337const kernels = immutable338.fromJS(data ?? [])339.filter((k) => !k.getIn(["metadata", "cocalc", "disabled"], false));340const key: string = await this.store.jupyter_kernel_key();341jupyter_kernels = jupyter_kernels.set(key, kernels); // global342this.setState({ kernels });343// We must also update the kernel info (e.g., display name), now that we344// know the kernels (e.g., maybe it changed or is now known but wasn't before).345const kernel_info = this.store.get_kernel_info(this.store.get("kernel"));346this.setState({ kernel_info });347await this.update_select_kernel_data(); // e.g. "kernel_selection" is drived from "kernels"348this.check_select_kernel();349};350351set_jupyter_kernels = async () => {352if (this.store == null) return;353const kernels = jupyter_kernels.get(await this.store.jupyter_kernel_key());354if (kernels != null) {355this.setState({ kernels });356} else {357await this.fetch_jupyter_kernels();358}359await this.update_select_kernel_data();360this.check_select_kernel();361};362363set_error = (err: any): void => {364if (this._state === "closed") return;365if (err == null) {366this.setState({ error: undefined }); // delete from store367return;368}369if (typeof err != "string") {370err = `${err}`;371}372const cur = this.store.get("error");373// don't show the same error more than once374if ((cur?.indexOf(err) ?? -1) >= 0) {375return;376}377if (cur) {378err = err + "\n\n" + cur;379}380this.setState({ error: err });381};382383// Set the input of the given cell in the syncdb, which will also change the store.384// Might throw a CellWriteProtectedException385public set_cell_input(id: string, input: string, save = true): void {386if (!this.store) return;387if (this.store.getIn(["cells", id, "input"]) == input) {388// nothing changed. Note, I tested doing the above check using389// both this.syncdb and this.store, and this.store is orders of magnitude faster.390return;391}392if (this.check_edit_protection(id, "changing input")) {393// note -- we assume above that there was an actual change before checking394// for edit protection. Thus the above check is important.395return;396}397this._set(398{399type: "cell",400id,401input,402start: null,403end: null,404},405save,406);407}408409set_cell_output = (id: string, output: any, save = true) => {410this._set(411{412type: "cell",413id,414output,415},416save,417);418};419420setCellId = (id: string, newId: string, save = true) => {421let cell = this.store.getIn(["cells", id])?.toJS();422if (cell == null) {423return;424}425cell.id = newId;426this.syncdb.delete({ type: "cell", id });427this.syncdb.set(cell);428if (save) {429this.syncdb.commit();430}431};432433clear_selected_outputs = () => {434this.deprecated("clear_selected_outputs");435};436437// Clear output in the list of cell id's.438// NOTE: clearing output *is* allowed for non-editable cells, since the definition439// of editable is that the *input* is editable.440// See https://github.com/sagemathinc/cocalc/issues/4805441public clear_outputs(cell_ids: string[], save: boolean = true): void {442const cells = this.store.get("cells");443if (cells == null) return; // nothing to do444for (const id of cell_ids) {445const cell = cells.get(id);446if (cell == null) continue;447if (cell.get("output") != null || cell.get("exec_count")) {448this._set({ type: "cell", id, output: null, exec_count: null }, false);449}450}451if (save) {452this._sync();453}454}455456public clear_all_outputs(save: boolean = true): void {457this.clear_outputs(this.store.get_cell_list().toJS(), save);458}459460private show_not_xable_error(x: string, n: number, reason?: string): void {461if (n <= 0) return;462const verb: string = n === 1 ? "is" : "are";463const noun: string = misc.plural(n, "cell");464this.set_error(465`${n} ${noun} ${verb} protected from ${x}${466reason ? " when " + reason : ""467}.`,468);469}470471private show_not_editable_error(reason?: string): void {472this.show_not_xable_error("editing", 1, reason);473}474475private show_not_deletable_error(n: number = 1): void {476this.show_not_xable_error("deletion", n);477}478479public toggle_output(id: string, property: "collapsed" | "scrolled"): void {480this.toggle_outputs([id], property);481}482483public toggle_outputs(484cell_ids: string[],485property: "collapsed" | "scrolled",486): void {487const cells = this.store.get("cells");488if (cells == null) {489throw Error("cells not defined");490}491for (const id of cell_ids) {492const cell = cells.get(id);493if (cell == null) {494throw Error(`no cell with id ${id}`);495}496if (cell.get("cell_type", "code") == "code") {497this._set(498{499type: "cell",500id,501[property]: !cell.get(502property,503property == "scrolled" ? false : true, // default scrolled to false504),505},506false,507);508}509}510this._sync();511}512513public toggle_all_outputs(property: "collapsed" | "scrolled"): void {514this.toggle_outputs(this.store.get_cell_ids_list(), property);515}516517public set_cell_pos(id: string, pos: number, save: boolean = true): void {518this._set({ type: "cell", id, pos }, save);519}520521public moveCell(522oldIndex: number,523newIndex: number,524save: boolean = true,525): void {526if (oldIndex == newIndex) return; // nothing to do527// Move the cell that is currently at position oldIndex to528// be at position newIndex.529const cell_list = this.store.get_cell_list();530const newPos = cell_utils.moveCell({531oldIndex,532newIndex,533size: cell_list.size,534getPos: (index) =>535this.store.getIn(["cells", cell_list.get(index) ?? "", "pos"]) ?? 0,536});537this.set_cell_pos(cell_list.get(oldIndex) ?? "", newPos, save);538}539540public set_cell_type(541id: string,542cell_type: string = "code",543save: boolean = true,544): void {545if (this.check_edit_protection(id, "changing cell type")) return;546if (547cell_type !== "markdown" &&548cell_type !== "raw" &&549cell_type !== "code"550) {551throw Error(552`cell type (='${cell_type}') must be 'markdown', 'raw', or 'code'`,553);554}555const obj: any = {556type: "cell",557id,558cell_type,559};560if (cell_type !== "code") {561// delete output and exec time info when switching to non-code cell_type562obj.output = obj.start = obj.end = obj.collapsed = obj.scrolled = null;563}564this._set(obj, save);565}566567public set_selected_cell_type(cell_type: string): void {568this.deprecated("set_selected_cell_type", cell_type);569}570571set_md_cell_editing = (id: string): void => {572this.deprecated("set_md_cell_editing", id);573};574575set_md_cell_not_editing = (id: string): void => {576this.deprecated("set_md_cell_not_editing", id);577};578579// Set which cell is currently the cursor.580set_cur_id = (id: string): void => {581this.deprecated("set_cur_id", id);582};583584protected deprecated(f: string, ...args): void {585const s = "DEPRECATED JupyterActions(" + this.path + ")." + f;586console.warn(s, ...args);587}588589private set_cell_list(): void {590const cells = this.store.get("cells");591if (cells == null) {592return;593}594const cell_list = cell_utils.sorted_cell_list(cells);595if (!cell_list.equals(this.store.get_cell_list())) {596this.setState({ cell_list });597this.store.emit("cell-list-recompute");598}599}600601private syncdb_cell_change(id: string, new_cell: any): boolean {602const cells: immutable.Map<603string,604immutable.Map<string, any>605> = this.store.get("cells");606if (cells == null) throw Error("BUG -- cells must have been initialized!");607let cell_list_needs_recompute = false;608//@dbg("_syncdb_cell_change")("#{id} #{JSON.stringify(new_cell?.toJS())}")609let old_cell = cells.get(id);610if (new_cell == null) {611// delete cell612this.reset_more_output(id); // free up memory locally613if (old_cell != null) {614const cell_list = this.store.get_cell_list().filter((x) => x !== id);615this.setState({ cells: cells.delete(id), cell_list });616}617} else {618// change or add cell619old_cell = cells.get(id);620if (new_cell.equals(old_cell)) {621return false; // nothing to do622}623if (old_cell != null && new_cell.get("start") !== old_cell.get("start")) {624// cell re-evaluated so any more output is no longer valid.625this.reset_more_output(id);626}627if (old_cell == null || old_cell.get("pos") !== new_cell.get("pos")) {628cell_list_needs_recompute = true;629}630// preserve cursor info if happen to have it, rather than just letting631// it get deleted whenever the cell changes.632if (old_cell?.has("cursors")) {633new_cell = new_cell.set("cursors", old_cell.get("cursors"));634}635this.setState({ cells: cells.set(id, new_cell) });636if (this.store.getIn(["edit_cell_metadata", "id"]) === id) {637this.edit_cell_metadata(id); // updates the state during active editing.638}639}640641this.onCellChange(id, new_cell, old_cell);642this.store.emit("cell_change", id, new_cell, old_cell);643644return cell_list_needs_recompute;645}646647_syncdb_change = (changes: any) => {648if (this.syncdb == null) return;649this.store.emit("syncdb-before-change");650this.__syncdb_change(changes);651this.store.emit("syncdb-after-change");652if (this.set_save_status != null) {653this.set_save_status();654}655};656657__syncdb_change = (changes: any): void => {658if (659this.syncdb == null ||660changes == null ||661(changes != null && changes.size == 0)662) {663return;664}665const doInit = this._state === "init";666let cell_list_needs_recompute = false;667668if (changes == "all" || this.store.get("cells") == null) {669// changes == 'all' is used by nbgrader to set the state...670// First time initialization, rather than some small671// update. We could use the same code, e.g.,672// calling syncdb_cell_change, but that SCALES HORRIBLY673// as the number of cells gets large!674675// this.syncdb.get() returns an immutable.List of all the records676// in the syncdb database. These look like, e.g.,677// {type: "settings", backend_state: "running", trust: true, kernel: "python3", …}678// {type: "cell", id: "22cc3e", pos: 0, input: "# small copy", state: "done"}679let cells: immutable.Map<string, Cell> = immutable.Map();680this.syncdb.get().forEach((record) => {681switch (record.get("type")) {682case "cell":683cells = cells.set(record.get("id"), record);684break;685case "settings":686if (record == null) {687return;688}689const orig_kernel = this.store.get("kernel");690const kernel = record.get("kernel");691const obj: any = {692trust: !!record.get("trust"), // case to boolean693backend_state: record.get("backend_state"),694last_backend_state: record.get("last_backend_state"),695kernel_state: record.get("kernel_state"),696metadata: record.get("metadata"), // extra custom user-specified metadata697max_output_length: bounded_integer(698record.get("max_output_length"),699100,700250000,701DEFAULT_MAX_OUTPUT_LENGTH,702),703};704if (kernel !== orig_kernel) {705obj.kernel = kernel;706obj.kernel_info = this.store.get_kernel_info(kernel);707obj.backend_kernel_info = undefined;708}709this.setState(obj);710if (711!this.is_project &&712!this.is_compute_server &&713orig_kernel !== kernel714) {715this.set_cm_options();716}717718break;719}720});721722this.setState({ cells, cell_list: cell_utils.sorted_cell_list(cells) });723cell_list_needs_recompute = false;724} else {725changes.forEach((key) => {726const type: string = key.get("type");727const record = this.syncdb.get_one(key);728switch (type) {729case "cell":730if (this.syncdb_cell_change(key.get("id"), record)) {731cell_list_needs_recompute = true;732}733break;734case "fatal":735const error = record != null ? record.get("error") : undefined;736this.setState({ fatal: error });737// This check can be deleted in a few weeks:738if (739error != null &&740error.indexOf("file is currently being read or written") !== -1741) {742// No longer relevant -- see https://github.com/sagemathinc/cocalc/issues/1742743this.syncdb.delete({ type: "fatal" });744this.syncdb.commit();745}746break;747748case "nbconvert":749if (this.is_project || this.is_compute_server) {750// before setting in store, let backend start reacting to change751this.handle_nbconvert_change(this.store.get("nbconvert"), record);752}753// Now set in our store.754this.setState({ nbconvert: record });755break;756757case "settings":758if (record == null) {759return;760}761const orig_kernel = this.store.get("kernel", null);762const kernel = record.get("kernel");763const obj: any = {764trust: !!record.get("trust"), // case to boolean765backend_state: record.get("backend_state"),766last_backend_state: record.get("last_backend_state"),767kernel_state: record.get("kernel_state"),768kernel_error: record.get("kernel_error"),769metadata: record.get("metadata"), // extra custom user-specified metadata770connection_file: record.get("connection_file") ?? "",771max_output_length: bounded_integer(772record.get("max_output_length"),773100,774250000,775DEFAULT_MAX_OUTPUT_LENGTH,776),777};778if (kernel !== orig_kernel) {779obj.kernel = kernel;780obj.kernel_info = this.store.get_kernel_info(kernel);781obj.backend_kernel_info = undefined;782}783const prev_backend_state = this.store.get("backend_state");784this.setState(obj);785if (!this.is_project && !this.is_compute_server) {786// if the kernel changes or it just started running – we set the codemirror options!787// otherwise, just when computing them without the backend information, only a crude788// heuristic sets the values and we end up with "C" formatting for custom python kernels.789// @see https://github.com/sagemathinc/cocalc/issues/5478790const started_running =791record.get("backend_state") === "running" &&792prev_backend_state !== "running";793if (orig_kernel !== kernel || started_running) {794this.set_cm_options();795}796}797break;798}799});800}801if (cell_list_needs_recompute) {802this.set_cell_list();803}804805this.__syncdb_change_post_hook(doInit);806};807808protected __syncdb_change_post_hook(_doInit: boolean) {809// no-op in base class -- does interesting and different810// things in project, browser, etc.811}812813protected onCellChange(_id: string, _new_cell: any, _old_cell: any) {814// no-op in base class. This is a hook though815// for potentially doing things when any cell changes.816}817818ensure_backend_kernel_setup() {819// nontrivial in the project, but not in client or here.820}821822protected _output_handler(_cell: any) {823throw Error("define in a derived class.");824}825826private _syncdb_init_kernel(): void {827// console.log("jupyter::_syncdb_init_kernel", this.store.get("kernel"));828if (this.store.get("kernel") == null) {829// Creating a new notebook with no kernel set830if (!this.is_project && !this.is_compute_server) {831// we either let the user select a kernel, or use a stored one832let using_default_kernel = false;833834const account_store = this.redux.getStore("account") as any;835const editor_settings = account_store.get("editor_settings") as any;836if (837editor_settings != null &&838!editor_settings.get("ask_jupyter_kernel")839) {840const default_kernel = editor_settings.getIn(["jupyter", "kernel"]);841// TODO: check if kernel is actually known842if (default_kernel != null) {843this.set_kernel(default_kernel);844using_default_kernel = true;845}846}847848if (!using_default_kernel) {849// otherwise we let the user choose a kernel850this.show_select_kernel("bad kernel");851}852// we also finalize the kernel selection check, because it doesn't switch to true853// if there is no kernel at all.854this.setState({ check_select_kernel_init: true });855}856} else {857// Opening an existing notebook858const default_kernel = this.store.get_default_kernel();859if (default_kernel == null && this.store.get("kernel")) {860// But user has no default kernel, since they never before explicitly set one.861// So we set it. This is so that a user's default862// kernel is that of the first ipynb they863// opened, which is very sensible in courses.864this.set_default_kernel(this.store.get("kernel"));865}866}867}868869/*870WARNING: Changes via set that are made when the actions871are not 'ready' or the syncdb is not ready are ignored.872These might happen right now if the user were to try to do873some random thing at the exact moment they are closing the874notebook. See https://github.com/sagemathinc/cocalc/issues/4274875*/876_set = (obj: any, save: boolean = true) => {877if (878this._state !== "ready" ||879this.store.get("read_only") ||880(this.syncdb != null && this.syncdb.get_state() != "ready")881) {882// no possible way to do anything.883return;884}885// check write protection regarding specific keys to be set886if (887obj.type === "cell" &&888obj.id != null &&889!this.store.is_cell_editable(obj.id)890) {891for (const protected_key of ["input", "cell_type", "attachments"]) {892if (misc.has_key(obj, protected_key)) {893throw CellWriteProtectedException;894}895}896}897//@dbg("_set")("obj=#{misc.to_json(obj)}")898this.syncdb.set(obj);899if (save) {900this.syncdb.commit();901}902// ensure that we update locally immediately for our own changes.903this._syncdb_change(904immutable.fromJS([misc.copy_with(obj, ["id", "type"])]),905);906};907908// might throw a CellDeleteProtectedException909_delete = (obj: any, save = true) => {910if (this._state === "closed" || this.store.get("read_only")) {911return;912}913// check: don't delete cells marked as deletable=false914if (obj.type === "cell" && obj.id != null) {915if (!this.store.is_cell_deletable(obj.id)) {916throw CellDeleteProtectedException;917}918}919this.syncdb.delete(obj);920if (save) {921this.syncdb.commit();922}923this._syncdb_change(immutable.fromJS([{ type: obj.type, id: obj.id }]));924};925926public _sync = () => {927if (this._state === "closed") {928return;929}930this.syncdb.commit();931};932933public save = async (): Promise<void> => {934if (this.store.get("read_only") || this.isDeleted()) {935// can't save when readonly or deleted936return;937}938if (this.store.get("mode") === "edit") {939this._get_cell_input();940}941// Save the .ipynb file to disk. Note that this942// *changes* the syncdb by updating the last save time.943try {944// Make sure syncdb content is all sent to the project.945// This does not actually save the syncdb file to disk.946// This "save" means save state to backend.947await this.syncdb.save();948if (this._state === "closed") return;949// Export the ipynb file to disk.950await this.api_call("save_ipynb_file", {});951if (this._state === "closed") return;952// Save our custom-format syncdb to disk.953await this.syncdb.save_to_disk();954} catch (err) {955if (this._state === "closed") return;956if (err.toString().indexOf("no kernel with path") != -1) {957// This means that the kernel simply hasn't been initialized yet.958// User can try to save later, once it has.959return;960}961if (err.toString().indexOf("unknown endpoint") != -1) {962this.set_error(963"You MUST restart your project to run the latest Jupyter server! Click 'Restart Project' in your project's settings.",964);965return;966}967this.set_error(err.toString());968} finally {969if (this._state === "closed") return;970// And update the save status finally.971if (typeof this.set_save_status === "function") {972this.set_save_status();973}974}975};976977save_asap = async (): Promise<void> => {978if (this.syncdb != null) {979await this.syncdb.save();980}981};982983private id_is_available(id: string): boolean {984return this.store.getIn(["cells", id]) == null;985}986987protected new_id(is_available?: (string) => boolean): string {988while (true) {989const id = misc.uuid().slice(0, 6);990if (991(is_available != null && is_available(id)) ||992this.id_is_available(id)993) {994return id;995}996}997}998999insert_cell(delta: any): string {1000this.deprecated("insert-cell", delta);1001return "";1002}10031004insert_cell_at(1005pos: number,1006save: boolean = true,1007id: string | undefined = undefined, // dangerous since could conflict (used by whiteboard)1008): string {1009if (this.store.get("read_only")) {1010throw Error("document is read only");1011}1012const new_id = id ?? this.new_id();1013this._set(1014{1015type: "cell",1016id: new_id,1017pos,1018input: "",1019},1020save,1021);1022return new_id; // violates CQRS... (this *is* used elsewhere)1023}10241025// insert a cell adjacent to the cell with given id.1026// -1 = above and +1 = below.1027insert_cell_adjacent(1028id: string,1029delta: -1 | 1,1030save: boolean = true,1031): string {1032const pos = cell_utils.new_cell_pos(1033this.store.get("cells"),1034this.store.get_cell_list(),1035id,1036delta,1037);1038return this.insert_cell_at(pos, save);1039}10401041delete_selected_cells = (sync = true): void => {1042this.deprecated("delete_selected_cells", sync);1043};10441045delete_cells(cells: string[], sync: boolean = true): void {1046let not_deletable: number = 0;1047for (const id of cells) {1048if (this.store.is_cell_deletable(id)) {1049this._delete({ type: "cell", id }, false);1050} else {1051not_deletable += 1;1052}1053}1054if (sync) {1055this._sync();1056}1057if (not_deletable === 0) return;10581059this.show_not_deletable_error(not_deletable);1060}10611062// Delete all blank code cells in the entire notebook.1063delete_all_blank_code_cells(sync: boolean = true): void {1064const cells: string[] = [];1065for (const id of this.store.get_cell_list()) {1066if (!this.store.is_cell_deletable(id)) {1067continue;1068}1069const cell = this.store.getIn(["cells", id]);1070if (cell == null) continue;1071if (1072cell.get("cell_type", "code") == "code" &&1073cell.get("input", "").trim() == "" &&1074cell.get("output", []).length == 01075) {1076cells.push(id);1077}1078}1079this.delete_cells(cells, sync);1080}10811082move_selected_cells = (delta: number) => {1083this.deprecated("move_selected_cells", delta);1084};10851086undo = (): void => {1087if (this.syncdb != null) {1088this.syncdb.undo();1089}1090};10911092redo = (): void => {1093if (this.syncdb != null) {1094this.syncdb.redo();1095}1096};10971098in_undo_mode(): boolean {1099return this.syncdb?.in_undo_mode() ?? false;1100}11011102public run_code_cell(1103id: string,1104save: boolean = true,1105no_halt: boolean = false,1106): void {1107const cell = this.store.getIn(["cells", id]);1108if (cell == null) {1109// it is trivial to run a cell that does not exist -- nothing needs to be done.1110return;1111}1112const kernel = this.store.get("kernel");1113if (kernel == null || kernel === "") {1114// just in case, we clear any "running" indicators1115this._set({ type: "cell", id, state: "done" });1116// don't attempt to run a code-cell if there is no kernel defined1117this.set_error(1118"No kernel set for running cells. Therefore it is not possible to run a code cell. You have to select a kernel!",1119);1120return;1121}11221123if (cell.get("state", "done") != "done") {1124// already running -- stop it first somehow if you want to run it again...1125return;1126}11271128// We mark the start timestamp uniquely, so that the backend can sort1129// multiple cells with a simultaneous time to start request.11301131let start: number = this._client.server_time().valueOf();1132if (this._last_start != null && start <= this._last_start) {1133start = this._last_start + 1;1134}1135this._last_start = start;1136this.set_jupyter_metadata(id, "outputs_hidden", undefined, false);11371138this._set(1139{1140type: "cell",1141id,1142state: "start",1143start,1144end: null,1145// time last evaluation took1146last:1147cell.get("start") != null && cell.get("end") != null1148? cell.get("end") - cell.get("start")1149: cell.get("last"),1150output: null,1151exec_count: null,1152collapsed: null,1153no_halt: no_halt ? no_halt : null,1154},1155save,1156);1157this.set_trust_notebook(true, save);1158}11591160clear_cell = (id: string, save = true) => {1161const cell = this.store.getIn(["cells", id]);11621163return this._set(1164{1165type: "cell",1166id,1167state: null,1168start: null,1169end: null,1170last:1171cell?.get("start") != null && cell?.get("end") != null1172? cell?.get("end") - cell?.get("start")1173: (cell?.get("last") ?? null),1174output: null,1175exec_count: null,1176collapsed: null,1177},1178save,1179);1180};11811182clear_cell_run_state = (id: string, save = true) => {1183return this._set(1184{1185type: "cell",1186id,1187state: "done",1188},1189save,1190);1191};11921193run_selected_cells = (): void => {1194this.deprecated("run_selected_cells");1195};11961197public abstract run_cell(id: string, save?: boolean, no_halt?: boolean): void;11981199run_all_cells = (no_halt: boolean = false): void => {1200this.store.get_cell_list().forEach((id) => {1201this.run_cell(id, false, no_halt);1202});1203this.save_asap();1204};12051206clear_all_cell_run_state = (): void => {1207if (!this.store) return;1208this.store.get_cell_list().forEach((id) => {1209this.clear_cell_run_state(id, false);1210});1211this.save_asap();1212};12131214// Run all cells strictly above the specified cell.1215run_all_above_cell(id: string): void {1216const i: number = this.store.get_cell_index(id);1217const v: string[] = this.store.get_cell_list().toJS();1218for (const id of v.slice(0, i)) {1219this.run_cell(id, false);1220}1221this.save_asap();1222}12231224// Run all cells below (and *including*) the specified cell.1225public run_all_below_cell(id: string): void {1226const i: number = this.store.get_cell_index(id);1227const v: string[] = this.store.get_cell_list().toJS();1228for (const id of v.slice(i)) {1229this.run_cell(id, false);1230}1231this.save_asap();1232}12331234public set_cursor_locs(locs: any[] = [], side_effect: boolean = false): void {1235this.last_cursor_move_time = new Date();1236if (this.syncdb == null) {1237// syncdb not always set -- https://github.com/sagemathinc/cocalc/issues/21071238return;1239}1240if (locs.length === 0) {1241// don't remove on blur -- cursor will fade out just fine1242return;1243}1244this._cursor_locs = locs; // remember our own cursors for splitting cell1245this.syncdb.set_cursor_locs(locs, side_effect);1246}12471248public split_cell(id: string, cursor: { line: number; ch: number }): void {1249if (this.check_edit_protection(id, "splitting cell")) {1250return;1251}1252// insert a new cell before the currently selected one1253const new_id: string = this.insert_cell_adjacent(id, -1, false);12541255// split the cell content at the cursor loc1256const cell = this.store.get("cells").get(id);1257if (cell == null) {1258throw Error(`no cell with id=${id}`);1259}1260const cell_type = cell.get("cell_type");1261if (cell_type !== "code") {1262this.set_cell_type(new_id, cell_type, false);1263}1264const input = cell.get("input");1265if (input == null) {1266this.syncdb.commit();1267return; // very easy case.1268}12691270const lines = input.split("\n");1271let v = lines.slice(0, cursor.line);1272const line: string | undefined = lines[cursor.line];1273if (line != null) {1274const left = line.slice(0, cursor.ch);1275if (left) {1276v.push(left);1277}1278}1279const top = v.join("\n");12801281v = lines.slice(cursor.line + 1);1282if (line != null) {1283const right = line.slice(cursor.ch);1284if (right) {1285v = [right].concat(v);1286}1287}1288const bottom = v.join("\n");1289this.set_cell_input(new_id, top, false);1290this.set_cell_input(id, bottom, true);1291}12921293// Copy content from the cell below the given cell into the currently1294// selected cell, then delete the cell below the given cell.1295public merge_cell_below_cell(cell_id: string, save: boolean = true): void {1296const next_id = this.store.get_cell_id(1, cell_id);1297if (next_id == null) {1298// no cell below given cell, so trivial.1299return;1300}1301for (const id of [cell_id, next_id]) {1302if (this.check_edit_protection(id, "merging cell")) return;1303}1304if (this.check_delete_protection(next_id)) return;13051306const cells = this.store.get("cells");1307if (cells == null) {1308return;1309}13101311const input: string =1312cells.getIn([cell_id, "input"], "") +1313"\n" +1314cells.getIn([next_id, "input"], "");13151316const output0 = cells.getIn([cell_id, "output"]) as any;1317const output1 = cells.getIn([next_id, "output"]) as any;1318let output: any = undefined;1319if (output0 == null) {1320output = output1;1321} else if (output1 == null) {1322output = output0;1323} else {1324// both output0 and output1 are defined; need to merge.1325// This is complicated since output is a map from string numbers.1326output = output0;1327let n = output0.size;1328for (let i = 0; i < output1.size; i++) {1329output = output.set(`${n}`, output1.get(`${i}`));1330n += 1;1331}1332}13331334this._delete({ type: "cell", id: next_id }, false);1335this._set(1336{1337type: "cell",1338id: cell_id,1339input,1340output: output != null ? output : null,1341start: null,1342end: null,1343},1344save,1345);1346}13471348// Merge the given cells into one cell, which replaces1349// the frist cell in cell_ids.1350// We also merge all output, instead of throwing away1351// all but first output (which jupyter does, and makes no sense).1352public merge_cells(cell_ids: string[]): void {1353const n = cell_ids.length;1354if (n <= 1) return; // trivial special case.1355for (let i = 0; i < n - 1; i++) {1356this.merge_cell_below_cell(cell_ids[0], i == n - 2);1357}1358}13591360// Copy the list of cells into our internal clipboard1361public copy_cells(cell_ids: string[]): void {1362const cells = this.store.get("cells");1363let global_clipboard = immutable.List();1364for (const id of cell_ids) {1365global_clipboard = global_clipboard.push(cells.get(id));1366}1367this.store.set_global_clipboard(global_clipboard);1368}13691370public studentProjectFunctionality() {1371return this.redux1372.getStore("projects")1373.get_student_project_functionality(this.project_id);1374}13751376public requireToggleReadonly(): void {1377if (this.studentProjectFunctionality().disableJupyterToggleReadonly) {1378throw Error("Toggling of write protection is disabled in this project.");1379}1380}13811382/* write protection disables any modifications, entering "edit"1383mode, and prohibits cell evaluations example: teacher handout1384notebook and student should not be able to modify an1385instruction cell in any way. */1386public toggle_write_protection_on_cells(1387cell_ids: string[],1388save: boolean = true,1389): void {1390this.requireToggleReadonly();1391this.toggle_metadata_boolean_on_cells(cell_ids, "editable", true, save);1392}13931394set_metadata_on_cells = (1395cell_ids: string[],1396key: string,1397value,1398save: boolean = true,1399) => {1400for (const id of cell_ids) {1401this.set_cell_metadata({1402id,1403metadata: { [key]: value },1404merge: true,1405save: false,1406bypass_edit_protection: true,1407});1408}1409if (save) {1410this.save_asap();1411}1412};14131414public write_protect_cells(1415cell_ids: string[],1416protect: boolean,1417save: boolean = true,1418) {1419this.set_metadata_on_cells(cell_ids, "editable", !protect, save);1420}14211422public delete_protect_cells(1423cell_ids: string[],1424protect: boolean,1425save: boolean = true,1426) {1427this.set_metadata_on_cells(cell_ids, "deletable", !protect, save);1428}14291430// this prevents any cell from being deleted, either directly, or indirectly via a "merge"1431// example: teacher handout notebook and student should not be able to modify an instruction cell in any way1432public toggle_delete_protection_on_cells(1433cell_ids: string[],1434save: boolean = true,1435): void {1436this.requireToggleReadonly();1437this.toggle_metadata_boolean_on_cells(cell_ids, "deletable", true, save);1438}14391440// This toggles the boolean value of given metadata field.1441// If not set, it is assumed to be true and toggled to false1442// For more than one cell, the first one is used to toggle1443// all cells to the inverted state1444private toggle_metadata_boolean_on_cells(1445cell_ids: string[],1446key: string,1447default_value: boolean, // default metadata value, if the metadata field is not set.1448save: boolean = true,1449): void {1450for (const id of cell_ids) {1451this.set_cell_metadata({1452id,1453metadata: {1454[key]: !this.store.getIn(1455["cells", id, "metadata", key],1456default_value,1457),1458},1459merge: true,1460save: false,1461bypass_edit_protection: true,1462});1463}1464if (save) {1465this.save_asap();1466}1467}14681469public toggle_jupyter_metadata_boolean(1470id: string,1471key: string,1472save: boolean = true,1473): void {1474const jupyter = this.store1475.getIn(["cells", id, "metadata", "jupyter"], immutable.Map())1476.toJS();1477jupyter[key] = !jupyter[key];1478this.set_cell_metadata({1479id,1480metadata: { jupyter },1481merge: true,1482save,1483});1484}14851486public set_jupyter_metadata(1487id: string,1488key: string,1489value: any,1490save: boolean = true,1491): void {1492const jupyter = this.store1493.getIn(["cells", id, "metadata", "jupyter"], immutable.Map())1494.toJS();1495if (value == null && jupyter[key] == null) return; // nothing to do.1496if (value != null) {1497jupyter[key] = value;1498} else {1499delete jupyter[key];1500}1501this.set_cell_metadata({1502id,1503metadata: { jupyter },1504merge: true,1505save,1506});1507}15081509// Paste cells from the internal clipboard; also1510// delta = 0 -- replace cell_ids cells1511// delta = 1 -- paste cells below last cell in cell_ids1512// delta = -1 -- paste cells above first cell in cell_ids.1513public paste_cells_at(cell_ids: string[], delta: 0 | 1 | -1 = 1): void {1514const clipboard = this.store.get_global_clipboard();1515if (clipboard == null || clipboard.size === 0) {1516return; // nothing to do1517}15181519if (cell_ids.length === 0) {1520// There are no cells currently selected. This can1521// happen in an edge case with slow network -- see1522// https://github.com/sagemathinc/cocalc/issues/38991523clipboard.forEach((cell, i) => {1524cell = cell.set("id", this.new_id()); // randomize the id of the cell1525cell = cell.set("pos", i);1526this._set(cell, false);1527});1528this.ensure_positions_are_unique();1529this._sync();1530return;1531}15321533let cell_before_pasted_id: string;1534const cells = this.store.get("cells");1535if (delta === -1 || delta === 0) {1536// one before first selected1537cell_before_pasted_id = this.store.get_cell_id(-1, cell_ids[0]) ?? "";1538} else if (delta === 1) {1539// last selected1540cell_before_pasted_id = cell_ids[cell_ids.length - 1];1541} else {1542// Typescript should prevent this, but just to be sure.1543throw Error(`delta (=${delta}) must be 0, -1, or 1`);1544}1545try {1546let after_pos: number, before_pos: number | undefined;1547if (delta === 0) {1548// replace, so delete cell_ids, unless just one, since1549// cursor cell_ids selection is confusing with Jupyter's model.1550if (cell_ids.length > 1) {1551this.delete_cells(cell_ids, false);1552}1553}1554// put the cells from the clipboard into the document, setting their positions1555if (cell_before_pasted_id == null) {1556// very top cell1557before_pos = undefined;1558after_pos = cells.getIn([cell_ids[0], "pos"]) as number;1559} else {1560before_pos = cells.getIn([cell_before_pasted_id, "pos"]) as1561| number1562| undefined;1563after_pos = cells.getIn([1564this.store.get_cell_id(+1, cell_before_pasted_id),1565"pos",1566]) as number;1567}1568const positions = cell_utils.positions_between(1569before_pos,1570after_pos,1571clipboard.size,1572);1573clipboard.forEach((cell, i) => {1574cell = cell.set("id", this.new_id()); // randomize the id of the cell1575cell = cell.set("pos", positions[i]);1576this._set(cell, false);1577});1578} finally {1579// very important that we save whatever is done above, so other viewers see it.1580this._sync();1581}1582}15831584// File --> Open: just show the file listing page.1585file_open = (): void => {1586if (this.redux == null) return;1587this.redux1588.getProjectActions(this.store.get("project_id"))1589.set_active_tab("files");1590};15911592// File --> New: like open, but also show the create panel1593file_new = (): void => {1594if (this.redux == null) return;1595const project_actions = this.redux.getProjectActions(1596this.store.get("project_id"),1597);1598project_actions.set_active_tab("new");1599};16001601private _get_cell_input = (id?: string | undefined): string => {1602this.deprecated("_get_cell_input", id);1603return "";1604};16051606// Version of the cell's input stored in store.1607// (A live codemirror editor could have a slightly1608// newer version, so this is only a fallback).1609get_cell_input(id: string): string {1610return this.store.getIn(["cells", id, "input"], "");1611}16121613set_kernel = (kernel: string | null) => {1614if (this.syncdb.get_state() != "ready") {1615console.warn("Jupyter syncdb not yet ready -- not setting kernel");1616return;1617}1618if (this.store.get("kernel") !== kernel) {1619this._set({1620type: "settings",1621kernel,1622});1623// clear error when changing the kernel1624this.set_error(null);1625}1626if (this.store.get("show_kernel_selector") || kernel === "") {1627this.hide_select_kernel();1628}1629if (kernel === "") {1630this.halt(); // user "detaches" kernel from notebook, we stop the kernel1631}1632};16331634public show_history_viewer(): void {1635const project_actions = this.redux.getProjectActions(this.project_id);1636if (project_actions == null) return;1637project_actions.open_file({1638path: misc.history_path(this.path),1639foreground: true,1640});1641}16421643// Attempt to fetch completions for give code and cursor_pos1644// If successful, the completions are put in store.get('completions') and looks like1645// this (as an immutable map):1646// cursor_end : 21647// cursor_start : 01648// matches : ['the', 'completions', ...]1649// status : "ok"1650// code : code1651// cursor_pos : cursor_pos1652//1653// If not successful, result is:1654// status : "error"1655// code : code1656// cursor_pos : cursor_pos1657// error : 'an error message'1658//1659// Only the most recent fetch has any impact, and calling1660// clear_complete() ensures any fetch made before that1661// is ignored.16621663// Returns true if a dialog with options appears, and false otherwise.1664public async complete(1665code: string,1666pos?: { line: number; ch: number } | number,1667id?: string,1668offset?: any,1669): Promise<boolean> {1670let cursor_pos;1671const req = (this._complete_request =1672(this._complete_request != null ? this._complete_request : 0) + 1);16731674this.setState({ complete: undefined });16751676// pos can be either a {line:?, ch:?} object as in codemirror,1677// or a number.1678if (pos == null || typeof pos == "number") {1679cursor_pos = pos;1680} else {1681cursor_pos = codemirror_to_jupyter_pos(code, pos);1682}1683cursor_pos = js_idx_to_char_idx(cursor_pos, code);16841685const start = new Date();1686let complete;1687try {1688complete = await this.api_call("complete", {1689code,1690cursor_pos,1691});1692} catch (err) {1693if (this._complete_request > req) return false;1694this.setState({ complete: { error: err } });1695// no op for now...1696throw Error(`ignore -- ${err}`);1697//return false;1698}16991700if (this.last_cursor_move_time >= start) {1701// see https://github.com/sagemathinc/cocalc/issues/36111702throw Error("ignore");1703//return false;1704}1705if (this._complete_request > req) {1706// future completion or clear happened; so ignore this result.1707throw Error("ignore");1708//return false;1709}17101711if (complete.status !== "ok") {1712this.setState({1713complete: {1714error: complete.error ? complete.error : "completion failed",1715},1716});1717return false;1718}17191720if (complete.matches == 0) {1721return false;1722}17231724delete complete.status;1725complete.base = code;1726complete.code = code;1727complete.pos = char_idx_to_js_idx(cursor_pos, code);1728complete.cursor_start = char_idx_to_js_idx(complete.cursor_start, code);1729complete.cursor_end = char_idx_to_js_idx(complete.cursor_end, code);1730complete.id = id;1731// Set the result so the UI can then react to the change.1732if (offset != null) {1733complete.offset = offset;1734}1735// For some reason, sometimes complete.matches are not unique, which is annoying/confusing,1736// and breaks an assumption in our react code too.1737// I think the reason is e.g., a filename and a variable could be the same. We're not1738// worrying about that now.1739complete.matches = Array.from(new Set(complete.matches));1740// sort in a way that matches how JupyterLab sorts completions, which1741// is case insensitive with % magics at the bottom1742complete.matches.sort((x, y) => {1743const c = misc.cmp(getCompletionGroup(x), getCompletionGroup(y));1744if (c) {1745return c;1746}1747return misc.cmp(x.toLowerCase(), y.toLowerCase());1748});1749const i_complete = immutable.fromJS(complete);1750if (complete.matches && complete.matches.length === 1 && id != null) {1751// special case -- a unique completion and we know id of cell in which completing is given.1752this.select_complete(id, complete.matches[0], i_complete);1753return false;1754} else {1755this.setState({ complete: i_complete });1756return true;1757}1758}17591760clear_complete = (): void => {1761this._complete_request =1762(this._complete_request != null ? this._complete_request : 0) + 1;1763this.setState({ complete: undefined });1764};17651766public select_complete(1767id: string,1768item: string,1769complete?: immutable.Map<string, any>,1770): void {1771if (complete == null) {1772complete = this.store.get("complete");1773}1774this.clear_complete();1775if (complete == null) {1776return;1777}1778const input = complete.get("code");1779if (input != null && complete.get("error") == null) {1780const starting = input.slice(0, complete.get("cursor_start"));1781const ending = input.slice(complete.get("cursor_end"));1782const new_input = starting + item + ending;1783const base = complete.get("base");1784this.complete_cell(id, base, new_input);1785}1786}17871788complete_cell(id: string, base: string, new_input: string): void {1789this.merge_cell_input(id, base, new_input);1790}17911792merge_cell_input(1793id: string,1794base: string,1795input: string,1796save: boolean = true,1797): void {1798const remote = this.store.getIn(["cells", id, "input"]);1799if (remote == null || base == null || input == null) {1800return;1801}1802const new_input = three_way_merge({1803base,1804local: input,1805remote,1806});1807this.set_cell_input(id, new_input, save);1808}18091810is_introspecting(): boolean {1811const actions = this.getFrameActions() as any;1812return actions?.store?.get("introspect") != null;1813}18141815introspect_close = () => {1816if (this.is_introspecting()) {1817this.getFrameActions()?.setState({ introspect: undefined });1818}1819};18201821introspect_at_pos = async (1822code: string,1823level: 0 | 1 = 0,1824pos: { ch: number; line: number },1825): Promise<void> => {1826if (code === "") return; // no-op if there is no code (should never happen)1827await this.introspect(code, level, codemirror_to_jupyter_pos(code, pos));1828};18291830introspect = async (1831code: string,1832level: 0 | 1,1833cursor_pos?: number,1834): Promise<immutable.Map<string, any> | undefined> => {1835const req = (this._introspect_request =1836(this._introspect_request != null ? this._introspect_request : 0) + 1);18371838if (cursor_pos == null) {1839cursor_pos = code.length;1840}1841cursor_pos = js_idx_to_char_idx(cursor_pos, code);18421843let introspect;1844try {1845introspect = await this.api_call("introspect", {1846code,1847cursor_pos,1848level,1849});1850if (introspect.status !== "ok") {1851introspect = { error: "completion failed" };1852}1853delete introspect.status;1854} catch (err) {1855introspect = { error: err };1856}1857if (this._introspect_request > req) return;1858const i = immutable.fromJS(introspect);1859this.getFrameActions()?.setState({1860introspect: i,1861});1862return i; // convenient / useful, e.g., for use by whiteboard.1863};18641865clear_introspect = (): void => {1866this._introspect_request =1867(this._introspect_request != null ? this._introspect_request : 0) + 1;1868this.getFrameActions()?.setState({ introspect: undefined });1869};18701871public async signal(signal = "SIGINT"): Promise<void> {1872// TODO: more setStates, awaits, and UI to reflect this happening...1873try {1874await this.api_call("signal", { signal }, 5000);1875} catch (err) {1876this.set_error(err);1877}1878}18791880// Kill the running kernel and does NOT start it up again.1881halt = reuseInFlight(async (): Promise<void> => {1882if (this.restartKernelOnClose != null && this.jupyter_kernel != null) {1883this.jupyter_kernel.removeListener("closed", this.restartKernelOnClose);1884delete this.restartKernelOnClose;1885}1886this.clear_all_cell_run_state();1887await this.signal("SIGKILL");1888// Wait a little, since SIGKILL has to really happen on backend,1889// and server has to respond and change state.1890const not_running = (s): boolean => {1891if (this._state === "closed") return true;1892const t = s.get_one({ type: "settings" });1893return t != null && t.get("backend_state") != "running";1894};1895try {1896await this.syncdb.wait(not_running, 30);1897// worked -- and also no need to show "kernel got killed" message since this was intentional.1898this.set_error("");1899} catch (err) {1900// failed1901this.set_error(err);1902}1903});19041905restart = reuseInFlight(async (): Promise<void> => {1906await this.halt();1907if (this._state === "closed") return;1908this.clear_all_cell_run_state();1909// Actually start it running again (rather than waiting for1910// user to do something), since this is called "restart".1911try {1912await this.set_backend_kernel_info(); // causes kernel to start1913} catch (err) {1914this.set_error(err);1915}1916});19171918public shutdown = reuseInFlight(async (): Promise<void> => {1919if (this._state === "closed") return;1920await this.signal("SIGKILL");1921if (this._state === "closed") return;1922this.clear_all_cell_run_state();1923await this.save_asap();1924});19251926set_backend_kernel_info = async (): Promise<void> => {1927if (this._state === "closed" || this.syncdb.is_read_only()) {1928return;1929}19301931if (this.isCellRunner() && (this.is_project || this.is_compute_server)) {1932const dbg = this.dbg(`set_backend_kernel_info ${misc.uuid()}`);1933if (1934this.jupyter_kernel == null ||1935this.jupyter_kernel.get_state() == "closed"1936) {1937dbg("no Jupyter kernel defined");1938return;1939}1940dbg("getting kernel_info...");1941let backend_kernel_info: KernelInfo;1942try {1943backend_kernel_info = immutable.fromJS(1944await this.jupyter_kernel.kernel_info(),1945);1946} catch (err) {1947dbg(`error = ${err}`);1948return;1949}1950this.setState({ backend_kernel_info });1951} else {1952await this._set_backend_kernel_info_client();1953}1954};19551956_set_backend_kernel_info_client = reuseInFlight(async (): Promise<void> => {1957await retry_until_success({1958max_time: 120000,1959start_delay: 1000,1960max_delay: 10000,1961f: this._fetch_backend_kernel_info_from_server,1962desc: "jupyter:_set_backend_kernel_info_client",1963});1964});19651966_fetch_backend_kernel_info_from_server = async (): Promise<void> => {1967const f = async () => {1968if (this._state === "closed") {1969return;1970}1971const data = await this.api_call("kernel_info", {});1972this.setState({1973backend_kernel_info: data,1974// this is when the server for this doc started, not when kernel last started!1975start_time: data.start_time,1976});1977};1978try {1979await retry_until_success({1980max_time: 1000 * 60 * 30,1981start_delay: 500,1982max_delay: 3000,1983f,1984desc: "jupyter:_fetch_backend_kernel_info_from_server",1985});1986} catch (err) {1987this.set_error(err);1988}1989if (this.is_closed()) return;1990// Update the codemirror editor options.1991this.set_cm_options();1992};19931994// Do a file action, e.g., 'compress', 'delete', 'rename', 'duplicate', 'move',1995// 'copy', 'share', 'download', 'open_file', 'close_file', 'reopen_file'1996// Each just shows1997// the corresponding dialog in1998// the file manager, so gives a step to confirm, etc.1999// The path may optionally be *any* file in this project.2000public async file_action(action_name: string, path?: string): Promise<void> {2001if (this._state == "closed") return;2002const a = this.redux.getProjectActions(this.project_id);2003if (path == null) {2004path = this.store.get("path");2005if (path == null) {2006throw Error("path must be defined in the store to use default");2007}2008}2009if (action_name === "reopen_file") {2010a.close_file(path);2011// ensure the side effects from changing registered2012// editors in project_file.* finish happening2013await delay(0);2014a.open_file({ path });2015return;2016}2017if (action_name === "close_file") {2018await this.syncdb.save();2019a.close_file(path);2020return;2021}2022if (action_name === "open_file") {2023a.open_file({ path });2024return;2025}2026if (action_name == "download") {2027a.download_file({ path });2028return;2029}2030const { head, tail } = misc.path_split(path);2031a.open_directory(head);2032a.set_all_files_unchecked();2033a.set_file_checked(path, true);2034return a.set_file_action(action_name, () => tail);2035}20362037set_max_output_length = (n) => {2038return this._set({2039type: "settings",2040max_output_length: n,2041});2042};20432044async fetch_more_output(id: string): Promise<void> {2045const time = this._client.server_time().valueOf();2046try {2047const more_output = await this.api_call("more_output", { id: id }, 60000);2048if (!this.store.getIn(["cells", id, "scrolled"])) {2049// make output area scrolled, since there is going to be a lot of output2050this.toggle_output(id, "scrolled");2051}2052this.set_more_output(id, { time, mesg_list: more_output });2053} catch (err) {2054this.set_error(err);2055}2056}20572058// NOTE: set_more_output on project-actions is different2059set_more_output = (id: string, more_output: any, _?: any): void => {2060if (this.store.getIn(["cells", id]) == null) {2061return;2062}2063const x = this.store.get("more_output", immutable.Map());2064this.setState({2065more_output: x.set(id, immutable.fromJS(more_output)),2066});2067};20682069reset_more_output = (id?: any): void => {2070let left: any;2071const more_output =2072(left = this.store.get("more_output")) != null ? left : immutable.Map();2073if (more_output.has(id)) {2074this.setState({ more_output: more_output.delete(id) });2075}2076};20772078protected set_cm_options(): void {2079// this only does something in browser-actions.2080}20812082set_trust_notebook = (trust: any, save: boolean = true) => {2083return this._set(2084{2085type: "settings",2086trust: !!trust,2087},2088save,2089); // case to bool2090};20912092scroll(pos): any {2093this.deprecated("scroll", pos);2094}20952096// submit input for a particular cell -- this is used by the2097// Input component output message type for interactive input.2098public async submit_input(id: string, value: string): Promise<void> {2099const output = this.store.getIn(["cells", id, "output"]);2100if (output == null) {2101return;2102}2103const n = `${output.size - 1}`;2104const mesg = output.get(n);2105if (mesg == null) {2106return;2107}21082109if (mesg.getIn(["opts", "password"])) {2110// handle password input separately by first submitting to the backend.2111try {2112await this.submit_password(id, value);2113} catch (err) {2114this.set_error(`Error setting backend key/value store (${err})`);2115return;2116}2117const m = value.length;2118value = "";2119for (let i = 0; i < m; i++) {2120value == "●";2121}2122this.set_cell_output(id, output.set(n, mesg.set("value", value)), false);2123this.save_asap();2124return;2125}21262127this.set_cell_output(id, output.set(n, mesg.set("value", value)), false);2128this.save_asap();2129}21302131submit_password = async (id: string, value: any): Promise<void> => {2132await this.set_in_backend_key_value_store(id, value);2133};21342135set_in_backend_key_value_store = async (2136key: any,2137value: any,2138): Promise<void> => {2139try {2140await this.api_call("store", { key, value });2141} catch (err) {2142this.set_error(err);2143}2144};21452146public async set_to_ipynb(2147ipynb: any,2148data_only: boolean = false,2149): Promise<void> {2150/*2151* set_to_ipynb - set from ipynb object. This is2152* mainly meant to be run on the backend in the project,2153* but is also run on the frontend too, e.g.,2154* for client-side nbviewer (in which case it won't remove images, etc.).2155*2156* See the documentation for load_ipynb_file in project-actions.ts for2157* documentation about the data_only input variable.2158*/2159if (typeof ipynb != "object") {2160throw Error("ipynb must be an object");2161}21622163this._state = "load";21642165//dbg(misc.to_json(ipynb))21662167// We try to parse out the kernel so we can use process_output below.2168// (TODO: rewrite so process_output is not associated with a specific kernel)2169let kernel: string | undefined;2170const ipynb_metadata = ipynb.metadata;2171if (ipynb_metadata != null) {2172const kernelspec = ipynb_metadata.kernelspec;2173if (kernelspec != null) {2174kernel = kernelspec.name;2175}2176}2177//dbg("kernel in ipynb: name='#{kernel}'")21782179const existing_ids = this.store.get_cell_list().toJS();21802181let set, trust;2182if (data_only) {2183trust = undefined;2184set = function () {};2185} else {2186if (typeof this.reset_more_output === "function") {2187this.reset_more_output();2188// clear the more output handler (only on backend)2189}2190// We delete all of the cells.2191// We do NOT delete everything, namely the last_loaded and2192// the settings entry in the database, because that would2193// throw away important information, e.g., the current kernel2194// and its state. NOTe: Some of that extra info *should* be2195// moved to a different ephemeral table, but I haven't got2196// around to doing so.2197this.syncdb.delete({ type: "cell" });2198// preserve trust state across file updates/loads2199trust = this.store.get("trust");2200set = (obj) => {2201this.syncdb.set(obj);2202};2203}22042205// Change kernel to what is in the file if necessary:2206set({ type: "settings", kernel });2207this.ensure_backend_kernel_setup();22082209const importer = new IPynbImporter();22102211// NOTE: Below we re-use any existing ids to make the patch that defines changing2212// to the contents of ipynb more efficient. In case of a very slight change2213// on disk, this can be massively more efficient.22142215importer.import({2216ipynb,2217existing_ids,2218new_id: this.new_id.bind(this),2219process_attachment:2220this.jupyter_kernel != null2221? this.jupyter_kernel.process_attachment.bind(this.jupyter_kernel)2222: undefined,2223output_handler:2224this.jupyter_kernel != null2225? this._output_handler.bind(this)2226: undefined, // undefined in client; defined in project2227});22282229if (data_only) {2230importer.close();2231return;2232}22332234// Set all the cells2235const object = importer.cells();2236for (const _ in object) {2237const cell = object[_];2238set(cell);2239}22402241// Set the settings2242set({ type: "settings", kernel: importer.kernel(), trust });22432244// Set extra user-defined metadata2245const metadata = importer.metadata();2246if (metadata != null) {2247set({ type: "settings", metadata });2248}22492250importer.close();22512252this.syncdb.commit();2253await this.syncdb.save();2254this.ensure_backend_kernel_setup();2255this._state = "ready";2256}22572258public set_cell_slide(id: string, value: any): void {2259if (!value) {2260value = null; // delete2261}2262if (this.check_edit_protection(id, "making a cell aslide")) {2263return;2264}2265this._set({2266type: "cell",2267id,2268slide: value,2269});2270}22712272public ensure_positions_are_unique(): void {2273if (this._state != "ready" || this.store == null) {2274// because of debouncing, this ensure_positions_are_unique can2275// be called after jupyter actions are closed.2276return;2277}2278const changes = cell_utils.ensure_positions_are_unique(2279this.store.get("cells"),2280);2281if (changes != null) {2282for (const id in changes) {2283const pos = changes[id];2284this.set_cell_pos(id, pos, false);2285}2286}2287this._sync();2288}22892290public set_default_kernel(kernel?: string): void {2291if (kernel == null || kernel === "") return;2292// doesn't make sense for project (right now at least)2293if (this.is_project || this.is_compute_server) return;2294const account_store = this.redux.getStore("account") as any;2295if (account_store == null) return;2296const cur: any = {};2297// if available, retain existing jupyter config2298const acc_jup = account_store.getIn(["editor_settings", "jupyter"]);2299if (acc_jup != null) {2300Object.assign(cur, acc_jup.toJS());2301}2302// set new kernel and save it2303cur.kernel = kernel;2304(this.redux.getTable("account") as any).set({2305editor_settings: { jupyter: cur },2306});2307}23082309edit_attachments = (id: string): void => {2310this.setState({ edit_attachments: id });2311};23122313_attachment_markdown = (name: any) => {2314return `![${name}](attachment:${name})`;2315// Don't use this because official Jupyter tooling can't deal with it. See2316// https://github.com/sagemathinc/cocalc/issues/50552317return `<img src="attachment:${name}" style="max-width:100%">`;2318};23192320insert_input_at_cursor = (id: string, s: string, save: boolean = true) => {2321// TODO: this maybe doesn't make sense anymore...2322// TODO: redo this -- note that the input below is wrong, since it is2323// from the store, not necessarily from what is live in the cell.23242325if (this.store.getIn(["cells", id]) == null) {2326return;2327}2328if (this.check_edit_protection(id, "inserting input")) {2329return;2330}2331let input = this.store.getIn(["cells", id, "input"], "");2332const cursor = this._cursor_locs != null ? this._cursor_locs[0] : undefined;2333if ((cursor != null ? cursor.id : undefined) === id) {2334const v = input.split("\n");2335const line = v[cursor.y];2336v[cursor.y] = line.slice(0, cursor.x) + s + line.slice(cursor.x);2337input = v.join("\n");2338} else {2339input += s;2340}2341return this._set({ type: "cell", id, input }, save);2342};23432344// Sets attachments[name] = val2345public set_cell_attachment(2346id: string,2347name: string,2348val: any,2349save: boolean = true,2350): void {2351const cell = this.store.getIn(["cells", id]);2352if (cell == null) {2353throw Error(`no cell ${id}`);2354}2355if (this.check_edit_protection(id, "setting an attachment")) return;2356const attachments = cell.get("attachments", immutable.Map()).toJS();2357attachments[name] = val;2358this._set(2359{2360type: "cell",2361id,2362attachments,2363},2364save,2365);2366}23672368public async add_attachment_to_cell(id: string, path: string): Promise<void> {2369if (this.check_edit_protection(id, "adding an attachment")) {2370return;2371}2372let name: string = encodeURIComponent(2373misc.path_split(path).tail.toLowerCase(),2374);2375name = name.replace(/\(/g, "%28").replace(/\)/g, "%29");2376this.set_cell_attachment(id, name, { type: "load", value: path });2377await callback2(this.store.wait, {2378until: () =>2379this.store.getIn(["cells", id, "attachments", name, "type"]) === "sha1",2380timeout: 0,2381});2382// This has to happen in the next render loop, since changing immediately2383// can update before the attachments props are updated.2384await delay(10);2385this.insert_input_at_cursor(id, this._attachment_markdown(name), true);2386}23872388delete_attachment_from_cell = (id: string, name: any) => {2389if (this.check_edit_protection(id, "deleting an attachment")) {2390return;2391}2392this.set_cell_attachment(id, name, null, false);2393this.set_cell_input(2394id,2395misc.replace_all(2396this._get_cell_input(id),2397this._attachment_markdown(name),2398"",2399),2400);2401};24022403add_tag(id: string, tag: string, save: boolean = true): void {2404if (this.check_edit_protection(id, "adding a tag")) {2405return;2406}2407return this._set(2408{2409type: "cell",2410id,2411tags: { [tag]: true },2412},2413save,2414);2415}24162417remove_tag(id: string, tag: string, save: boolean = true): void {2418if (this.check_edit_protection(id, "removing a tag")) {2419return;2420}2421return this._set(2422{2423type: "cell",2424id,2425tags: { [tag]: null },2426},2427save,2428);2429}24302431toggle_tag(id: string, tag: string, save: boolean = true): void {2432const cell = this.store.getIn(["cells", id]);2433if (cell == null) {2434throw Error(`no cell with id ${id}`);2435}2436const tags = cell.get("tags");2437if (tags == null || !tags.get(tag)) {2438this.add_tag(id, tag, save);2439} else {2440this.remove_tag(id, tag, save);2441}2442}24432444edit_cell_metadata = (id: string): void => {2445const metadata = this.store.getIn(2446["cells", id, "metadata"],2447immutable.Map(),2448);2449this.setState({ edit_cell_metadata: { id, metadata } });2450};24512452public set_global_metadata(metadata: object, save: boolean = true): void {2453const cur = this.syncdb.get_one({ type: "settings" })?.toJS()?.metadata;2454if (cur) {2455metadata = {2456...cur,2457...metadata,2458};2459}2460this.syncdb.set({ type: "settings", metadata });2461if (save) {2462this.syncdb.commit();2463}2464}24652466public set_cell_metadata(opts: {2467id: string;2468metadata?: object; // not given = delete it2469save?: boolean; // defaults to true if not given2470merge?: boolean; // defaults to false if not given, in which case sets metadata, rather than merge. If true, does a SHALLOW merge.2471bypass_edit_protection?: boolean;2472}): void {2473let { id, metadata, save, merge, bypass_edit_protection } = (opts =2474defaults(opts, {2475id: required,2476metadata: required,2477save: true,2478merge: false,2479bypass_edit_protection: false,2480}));24812482if (2483!bypass_edit_protection &&2484this.check_edit_protection(id, "editing cell metadata")2485) {2486return;2487}2488// Special case: delete metdata (unconditionally)2489if (metadata == null || misc.len(metadata) === 0) {2490this._set(2491{2492type: "cell",2493id,2494metadata: null,2495},2496save,2497);2498return;2499}25002501if (merge) {2502const current = this.store.getIn(2503["cells", id, "metadata"],2504immutable.Map(),2505);2506metadata = current.merge(immutable.fromJS(metadata)).toJS();2507}25082509// special fields2510// "collapsed", "scrolled", "slideshow", and "tags"2511if (metadata.tags != null) {2512for (const tag of metadata.tags) {2513this.add_tag(id, tag, false);2514}2515delete metadata.tags;2516}2517// important to not store redundant inconsistent fields:2518for (const field of ["collapsed", "scrolled", "slideshow"]) {2519if (metadata[field] != null) {2520delete metadata[field];2521}2522}25232524if (!merge) {2525// first delete -- we have to do this due to shortcomings in syncdb, but it2526// can have annoying side effects on the UI2527this._set(2528{2529type: "cell",2530id,2531metadata: null,2532},2533false,2534);2535}2536// now set2537this._set(2538{2539type: "cell",2540id,2541metadata,2542},2543save,2544);2545if (this.store.getIn(["edit_cell_metadata", "id"]) === id) {2546this.edit_cell_metadata(id); // updates the state while editing2547}2548}25492550public set_raw_ipynb(): void {2551if (this._state != "ready") {2552// lies otherwise...2553return;2554}25552556this.setState({2557raw_ipynb: immutable.fromJS(this.store.get_ipynb()),2558});2559}25602561protected check_select_kernel(): void {2562const kernel = this.store?.get("kernel");2563if (kernel == null) return;2564let unknown_kernel = false;2565if (kernel === "") {2566unknown_kernel = false; // it's the "no kernel" kernel2567} else if (this.store.get("kernels") != null) {2568unknown_kernel = this.store.get_kernel_info(kernel) == null;2569}25702571// a kernel is set, but we don't know it2572if (unknown_kernel) {2573this.show_select_kernel("bad kernel");2574} else {2575// we got a kernel, close dialog if not requested by user2576if (2577this.store.get("show_kernel_selector") &&2578this.store.get("show_kernel_selector_reason") === "bad kernel"2579) {2580this.hide_select_kernel();2581}2582}25832584// also in the case when the kernel is "" we have to set this to true2585this.setState({ check_select_kernel_init: true });2586}25872588async update_select_kernel_data(): Promise<void> {2589if (this.store == null) return;2590const kernels = jupyter_kernels.get(await this.store.jupyter_kernel_key());2591if (kernels == null) return;2592const kernel_selection = this.store.get_kernel_selection(kernels);2593const [kernels_by_name, kernels_by_language] =2594get_kernels_by_name_or_language(kernels);2595const default_kernel = this.store.get_default_kernel();2596// do we have a similar kernel?2597let closestKernel: Kernel | undefined = undefined;2598const kernel = this.store.get("kernel");2599const kernel_info = this.store.get_kernel_info(kernel);2600// unknown kernel, we try to find a close match2601if (kernel_info == null && kernel != null && kernel !== "") {2602// kernel & kernels must be defined2603closestKernel = misc.closest_kernel_match(kernel, kernels as any) as any;2604// TODO about that any above: closest_kernel_match should be moved here so it knows the typings2605}2606this.setState({2607kernel_selection,2608kernels_by_name,2609kernels_by_language,2610default_kernel,2611closestKernel,2612});2613}26142615set_mode(mode: "escape" | "edit"): void {2616this.deprecated("set_mode", mode);2617}26182619public focus(wait?: boolean): void {2620this.deprecated("focus", wait);2621}26222623public blur(): void {2624this.deprecated("blur");2625}26262627async show_select_kernel(2628reason: show_kernel_selector_reasons,2629): Promise<void> {2630await this.update_select_kernel_data();2631// we might not have the "kernels" data yet (but we will, once fetching it is complete)2632// the select dialog will show a loading spinner2633this.setState({2634show_kernel_selector_reason: reason,2635show_kernel_selector: true,2636});2637}26382639hide_select_kernel = (): void => {2640this.setState({2641show_kernel_selector_reason: undefined,2642show_kernel_selector: false,2643});2644};26452646select_kernel = (kernel_name: string | null): void => {2647this.set_kernel(kernel_name);2648if (kernel_name != null && kernel_name !== "") {2649this.set_default_kernel(kernel_name);2650}2651this.focus(true);2652this.hide_select_kernel();2653};26542655kernel_dont_ask_again = (dont_ask: boolean): void => {2656// why is "as any" necessary?2657const account_table = this.redux.getTable("account") as any;2658account_table.set({2659editor_settings: { ask_jupyter_kernel: !dont_ask },2660});2661};26622663public check_edit_protection(id: string, reason?: string): boolean {2664if (!this.store.is_cell_editable(id)) {2665this.show_not_editable_error(reason);2666return true;2667} else {2668return false;2669}2670}26712672public check_delete_protection(id: string): boolean {2673if (!this.store.is_cell_deletable(id)) {2674this.show_not_deletable_error();2675return true;2676} else {2677return false;2678}2679}26802681split_current_cell = () => {2682this.deprecated("split_current_cell");2683};26842685handle_nbconvert_change(_oldVal, _newVal): void {2686throw Error("define this in derived class");2687}26882689// Return id of ACTIVE remote compute server, if one is connected, or 02690// if none is connected.2691getComputeServerId = (): number => {2692return this.syncdb.getComputeServerId();2693};26942695protected isCellRunner = (): boolean => {2696return false;2697};26982699set_kernel_error = (err) => {2700// anybody can *clear* error, but only cell runner can set it, since2701// only they should know.2702if (err && !this.isCellRunner()) {2703return;2704}2705this._set({2706type: "settings",2707kernel_error: `${err}`,2708});2709this.save_asap();2710};27112712// Returns true if the .ipynb file was explicitly deleted.2713// Returns false if it is NOT known to be explicitly deleted.2714// Returns undefined if not known or implemented.2715// NOTE: this is different than the file not being present on disk.2716protected isDeleted = () => {2717if (this.store == null || this._client == null) {2718return;2719}2720return this._client.is_deleted?.(this.store.get("path"), this.project_id);2721// [ ] TODO: we also need to do this on compute servers, but2722// they don't yet have the listings table.2723};27242725processRenderedMarkdown = ({ value, id }: { value: string; id: string }) => {2726value = latexEnvs(value);27272728const labelRegExp = /\s*\\label\{.*?\}\s*/g;2729const figLabelRegExp = /\s*\\figlabel\{.*?\}\s*/g;2730if (this.labels == null) {2731const labels = (this.labels = { math: {}, fig: {} });2732// do initial full document scan2733if (this.store == null) {2734return;2735}2736const cells = this.store.get("cells");2737if (cells == null) {2738return;2739}2740let mathN = 0;2741let figN = 0;2742for (const id of this.store.get_cell_ids_list()) {2743const cell = cells.get(id);2744if (cell?.get("cell_type") == "markdown") {2745const value = latexEnvs(cell.get("input") ?? "");2746value.replace(labelRegExp, (labelContent) => {2747const label = extractLabel(labelContent);2748mathN += 1;2749labels.math[label] = { tag: `${mathN}`, id };2750return "";2751});2752value.replace(figLabelRegExp, (labelContent) => {2753const label = extractLabel(labelContent);2754figN += 1;2755labels.fig[label] = { tag: `${figN}`, id };2756return "";2757});2758}2759}2760}2761const labels = this.labels;2762if (labels == null) {2763throw Error("bug");2764}2765value = value.replace(labelRegExp, (labelContent) => {2766const label = extractLabel(labelContent);2767if (labels.math[label] == null) {2768labels.math[label] = { tag: `${misc.len(labels.math) + 1}`, id };2769} else {2770// in case it moved to a different cell due to cut/paste2771labels.math[label].id = id;2772}2773return `\\tag{${labels.math[label].tag}}`;2774});2775value = value.replace(figLabelRegExp, (labelContent) => {2776const label = extractLabel(labelContent);2777if (labels.fig[label] == null) {2778labels.fig[label] = { tag: `${misc.len(labels.fig) + 1}`, id };2779} else {2780// in case it moved to a different cell due to cut/paste2781labels.fig[label].id = id;2782}2783return ` ${labels.fig[label].tag ?? "?"}`;2784});2785const refRegExp = /\\ref\{.*?\}/g;2786value = value.replace(refRegExp, (refContent) => {2787const label = extractLabel(refContent);2788if (labels.fig[label] == null && labels.math[label] == null) {2789// do not know the label2790return "?";2791}2792const { tag, id } = labels.fig[label] ?? labels.math[label];2793return `[${tag}](#id=${id})`;2794});27952796return value;2797};27982799// Update run progress, which is a number between 0 and 100,2800// giving the number of runnable cells that have been run since2801// the kernel was last set to the running state.2802// Currently only run in the browser, but could maybe be useful2803// elsewhere someday.2804updateRunProgress = () => {2805if (this.store == null) {2806return;2807}2808if (this.store.get("backend_state") != "running") {2809this.setState({ runProgress: 0 });2810return;2811}2812const cells = this.store.get("cells");2813if (cells == null) {2814return;2815}2816const last = this.store.get("last_backend_state");2817if (last == null) {2818// not supported yet, e.g., old backend, kernel never started2819return;2820}2821// count of number of cells that are runnable and2822// have start greater than last, and end set...2823// count a currently running cell as 0.5.2824let total = 0;2825let ran = 0;2826for (const [_, cell] of cells) {2827if (2828cell.get("cell_type", "code") != "code" ||2829!cell.get("input")?.trim()2830) {2831// not runnable2832continue;2833}2834total += 1;2835if ((cell.get("start") ?? 0) >= last) {2836if (cell.get("end")) {2837ran += 1;2838} else {2839ran += 0.5;2840}2841}2842}2843this.setState({ runProgress: total > 0 ? (100 * ran) / total : 100 });2844};2845}28462847function extractLabel(content: string): string {2848const i = content.indexOf("{");2849const j = content.lastIndexOf("}");2850return content.slice(i + 1, j);2851}28522853function bounded_integer(n: any, min: any, max: any, def: any) {2854if (typeof n !== "number") {2855n = parseInt(n);2856}2857if (isNaN(n)) {2858return def;2859}2860n = Math.round(n);2861if (n < min) {2862return min;2863}2864if (n > max) {2865return max;2866}2867return n;2868}28692870function getCompletionGroup(x: string): number {2871switch (x[0]) {2872case "_":2873return 1;2874case "%":2875return 2;2876default:2877return 0;2878}2879}288028812882