CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/jupyter/redux/actions.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Jupyter actions -- these are the actions for the underlying document structure.
8
This can be used both on the frontend and the backend.
9
*/
10
11
// This was 10000 for a while and that caused regular noticeable problems:
12
// https://github.com/sagemathinc/cocalc/issues/4590
13
const DEFAULT_MAX_OUTPUT_LENGTH = 1000000;
14
15
declare const localStorage: any;
16
17
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
18
import * as immutable from "immutable";
19
import { Actions } from "@cocalc/util/redux/Actions";
20
import { three_way_merge } from "@cocalc/sync/editor/generic/util";
21
import { callback2, retry_until_success } from "@cocalc/util/async-utils";
22
import * as misc from "@cocalc/util/misc";
23
import { callback, delay } from "awaiting";
24
import * as cell_utils from "@cocalc/jupyter/util/cell-utils";
25
import {
26
JupyterStore,
27
JupyterStoreState,
28
show_kernel_selector_reasons,
29
} from "@cocalc/jupyter/redux/store";
30
import { Cell, KernelInfo } from "@cocalc/jupyter/types";
31
import { get_kernels_by_name_or_language } from "@cocalc/jupyter/util/misc";
32
import { Kernel, Kernels } from "@cocalc/jupyter/util/misc";
33
import { IPynbImporter } from "@cocalc/jupyter/ipynb/import-from-ipynb";
34
import type { JupyterKernelInterface } from "@cocalc/jupyter/types/project-interface";
35
import {
36
char_idx_to_js_idx,
37
codemirror_to_jupyter_pos,
38
js_idx_to_char_idx,
39
} from "@cocalc/jupyter/util/misc";
40
import { SyncDB } from "@cocalc/sync/editor/db/sync";
41
import type { Client } from "@cocalc/sync/client/types";
42
import { once } from "@cocalc/util/async-utils";
43
import latexEnvs from "@cocalc/util/latex-envs";
44
45
const { close, required, defaults } = misc;
46
47
// local cache: map project_id (string) -> kernels (immutable)
48
let jupyter_kernels = immutable.Map<string, Kernels>();
49
50
/*
51
The actions -- what you can do with a jupyter notebook, and also the
52
underlying synchronized state.
53
*/
54
55
// no worries, they don't break react rendering even when they escape
56
const CellWriteProtectedException = new Error("CellWriteProtectedException");
57
const CellDeleteProtectedException = new Error("CellDeleteProtectedException");
58
59
export abstract class JupyterActions extends Actions<JupyterStoreState> {
60
public is_project: boolean;
61
public is_compute_server?: boolean;
62
readonly path: string;
63
readonly project_id: string;
64
private _last_start?: number;
65
public jupyter_kernel?: JupyterKernelInterface;
66
private last_cursor_move_time: Date = new Date(0);
67
private _cursor_locs?: any;
68
private _introspect_request?: any;
69
protected set_save_status: any;
70
protected _client: Client;
71
protected _file_watcher: any;
72
protected _state: any;
73
protected restartKernelOnClose?: (...args: any[]) => void;
74
75
public _complete_request?: number;
76
public store: JupyterStore;
77
public syncdb: SyncDB;
78
private labels?: {
79
math: { [label: string]: { tag: string; id: string } };
80
fig: { [label: string]: { tag: string; id: string } };
81
};
82
83
public _init(
84
project_id: string,
85
path: string,
86
syncdb: SyncDB,
87
store: any,
88
client: Client,
89
): void {
90
this._client = client;
91
const dbg = this.dbg("_init");
92
dbg("Initializing Jupyter Actions");
93
if (project_id == null || path == null) {
94
// typescript should ensure this, but just in case.
95
throw Error("type error -- project_id and path can't be null");
96
}
97
store.dbg = (f) => {
98
return client.dbg(`JupyterStore('${store.get("path")}').${f}`);
99
};
100
this._state = "init"; // 'init', 'load', 'ready', 'closed'
101
this.store = store;
102
// @ts-ignore
103
this.project_id = project_id;
104
// @ts-ignore
105
this.path = path;
106
store.syncdb = syncdb;
107
this.syncdb = syncdb;
108
// the project client is designated to manage execution/conflict, etc.
109
this.is_project = client.is_project();
110
if (this.is_project) {
111
this.syncdb.on("first-load", () => {
112
dbg("handling first load of syncdb in project");
113
// Clear settings the first time the syncdb is ever
114
// loaded, since it has settings like "ipynb last save"
115
// and trust, which shouldn't be initialized to
116
// what they were before. Not doing this caused
117
// https://github.com/sagemathinc/cocalc/issues/7074
118
this.syncdb.delete({ type: "settings" });
119
this.syncdb.commit();
120
});
121
}
122
123
this.is_compute_server = client.is_compute_server();
124
125
let directory: any;
126
const split_path = misc.path_split(path);
127
if (split_path != null) {
128
directory = split_path.head;
129
}
130
131
this.setState({
132
error: undefined,
133
has_unsaved_changes: false,
134
sel_ids: immutable.Set(), // immutable set of selected cells
135
md_edit_ids: immutable.Set(), // set of ids of markdown cells in edit mode
136
mode: "escape",
137
project_id,
138
directory,
139
path,
140
max_output_length: DEFAULT_MAX_OUTPUT_LENGTH,
141
});
142
143
this.syncdb.on("change", this._syncdb_change);
144
145
this.syncdb.on("close", this.close);
146
147
if (!this.is_project) {
148
this.fetch_jupyter_kernels();
149
}
150
151
// Hook for additional initialization.
152
this.init2();
153
}
154
155
// default is to do nothing, but e.g., frontend browser client
156
// does overload this to do a lot of additional init.
157
protected init2(): void {
158
// this can be overloaded in a derived class
159
}
160
161
// Only use this on the frontend, of course.
162
protected getFrameActions() {
163
return this.redux.getEditorActions(this.project_id, this.path);
164
}
165
166
protected async set_kernel_after_load(): Promise<void> {
167
// Browser Client: Wait until the .ipynb file has actually been parsed into
168
// the (hidden, e.g. .a.ipynb.sage-jupyter2) syncdb file,
169
// then set the kernel, if necessary.
170
try {
171
await this.syncdb.wait((s) => !!s.get_one({ type: "file" }), 600);
172
} catch (err) {
173
if (this._state != "ready") {
174
// Probably user just closed the notebook before it finished
175
// loading, so we don't need to set the kernel.
176
return;
177
}
178
throw Error("error waiting for ipynb file to load");
179
}
180
this._syncdb_init_kernel();
181
}
182
183
sync_read_only = (): void => {
184
if (this._state == "closed") return;
185
const a = this.store.get("read_only");
186
const b = this.syncdb?.is_read_only();
187
if (a !== b) {
188
this.setState({ read_only: b });
189
this.set_cm_options();
190
}
191
};
192
193
private apiCallHandler: {
194
id: number; // this is a sequential id used for request/response pairing
195
// when get response from computer server, one of these callbacks gets called:
196
responseCallbacks: { [id: number]: (err: any, response?: any) => void };
197
} | null = null;
198
199
private initApiCallHandler = () => {
200
this.apiCallHandler = { id: 0, responseCallbacks: {} };
201
const { responseCallbacks } = this.apiCallHandler;
202
this.syncdb.on("message", (data) => {
203
const cb = responseCallbacks[data.id];
204
if (cb != null) {
205
delete responseCallbacks[data.id];
206
if (data.response?.event == "error") {
207
cb(data.response.message ?? "error");
208
} else {
209
cb(undefined, data.response);
210
}
211
}
212
});
213
};
214
215
protected async api_call(
216
endpoint: string,
217
query?: any,
218
timeout_ms?: number,
219
): Promise<any> {
220
if (this._state === "closed" || this.syncdb == null) {
221
throw Error("closed -- jupyter actions -- api_call");
222
}
223
if (this.syncdb.get_state() == "init") {
224
await once(this.syncdb, "ready");
225
}
226
if (this.apiCallHandler == null) {
227
this.initApiCallHandler();
228
}
229
if (this.apiCallHandler == null) {
230
throw Error("bug");
231
}
232
233
this.apiCallHandler.id += 1;
234
const { id, responseCallbacks } = this.apiCallHandler;
235
await this.syncdb.sendMessageToProject({
236
event: "api-request",
237
id,
238
path: this.path,
239
endpoint,
240
query,
241
});
242
const waitForResponse = (cb) => {
243
if (timeout_ms) {
244
setTimeout(() => {
245
if (responseCallbacks[id] == null) return;
246
cb("timeout");
247
delete responseCallbacks[id];
248
}, timeout_ms);
249
}
250
responseCallbacks[id] = cb;
251
};
252
const resp = await callback(waitForResponse);
253
return resp;
254
}
255
256
protected dbg = (f: string) => {
257
if (this.is_closed()) {
258
// calling dbg after the actions are closed is possible; this.store would
259
// be undefined, and then this log message would crash, which sucks. It happened to me.
260
// See https://github.com/sagemathinc/cocalc/issues/6788
261
return (..._) => {};
262
}
263
return this._client.dbg(`JupyterActions("${this.path}").${f}`);
264
};
265
266
protected close_client_only(): void {
267
// no-op: this can be defined in a derived class. E.g., in the frontend, it removes
268
// an account_change listener.
269
}
270
271
public is_closed(): boolean {
272
return this._state === "closed" || this._state === undefined;
273
}
274
275
public async close({ noSave }: { noSave?: boolean } = {}): Promise<void> {
276
if (this.is_closed()) {
277
return;
278
}
279
// ensure save to disk happens:
280
// - it will automatically happen for the sync-doc file, but
281
// we also need it for the ipynb file... as ipynb is unique
282
// in having two formats.
283
if (!noSave) {
284
await this.save();
285
}
286
if (this.is_closed()) {
287
return;
288
}
289
290
if (this.syncdb != null) {
291
this.syncdb.close();
292
}
293
if (this._file_watcher != null) {
294
this._file_watcher.close();
295
}
296
if (this.is_project || this.is_compute_server) {
297
this.close_project_only();
298
} else {
299
this.close_client_only();
300
}
301
// We *must* destroy the action before calling close,
302
// since otherwise this.redux and this.name are gone,
303
// which makes destroying the actions properly impossible.
304
this.destroy();
305
this.store.destroy();
306
close(this);
307
this._state = "closed";
308
}
309
310
public close_project_only() {
311
// real version is in derived class that project runs.
312
}
313
314
fetch_jupyter_kernels = async (): Promise<void> => {
315
let data;
316
const f = async () => {
317
data = await this.api_call("kernels", undefined, 5000);
318
if (this._state === "closed") {
319
return;
320
}
321
};
322
try {
323
await retry_until_success({
324
max_time: 1000 * 15, // up to 15 seconds
325
start_delay: 500,
326
max_delay: 5000,
327
f,
328
desc: "jupyter:fetch_jupyter_kernels",
329
});
330
} catch (err) {
331
this.set_error(err);
332
return;
333
}
334
if (this._state === "closed") {
335
return;
336
}
337
// we filter kernels that are disabled for the cocalc notebook – motivated by a broken GAP kernel
338
const kernels = immutable
339
.fromJS(data ?? [])
340
.filter((k) => !k.getIn(["metadata", "cocalc", "disabled"], false));
341
const key: string = await this.store.jupyter_kernel_key();
342
jupyter_kernels = jupyter_kernels.set(key, kernels); // global
343
this.setState({ kernels });
344
// We must also update the kernel info (e.g., display name), now that we
345
// know the kernels (e.g., maybe it changed or is now known but wasn't before).
346
const kernel_info = this.store.get_kernel_info(this.store.get("kernel"));
347
this.setState({ kernel_info });
348
await this.update_select_kernel_data(); // e.g. "kernel_selection" is drived from "kernels"
349
this.check_select_kernel();
350
};
351
352
set_jupyter_kernels = async () => {
353
if (this.store == null) return;
354
const kernels = jupyter_kernels.get(await this.store.jupyter_kernel_key());
355
if (kernels != null) {
356
this.setState({ kernels });
357
} else {
358
await this.fetch_jupyter_kernels();
359
}
360
await this.update_select_kernel_data();
361
this.check_select_kernel();
362
};
363
364
set_error = (err: any): void => {
365
if (this._state === "closed") return;
366
if (err == null) {
367
this.setState({ error: undefined }); // delete from store
368
return;
369
}
370
if (typeof err != "string") {
371
err = `${err}`;
372
}
373
const cur = this.store.get("error");
374
// don't show the same error more than once
375
if ((cur?.indexOf(err) ?? -1) >= 0) {
376
return;
377
}
378
if (cur) {
379
err = err + "\n\n" + cur;
380
}
381
this.setState({ error: err });
382
};
383
384
// Set the input of the given cell in the syncdb, which will also change the store.
385
// Might throw a CellWriteProtectedException
386
public set_cell_input(id: string, input: string, save = true): void {
387
if (!this.store) return;
388
if (this.store.getIn(["cells", id, "input"]) == input) {
389
// nothing changed. Note, I tested doing the above check using
390
// both this.syncdb and this.store, and this.store is orders of magnitude faster.
391
return;
392
}
393
if (this.check_edit_protection(id, "changing input")) {
394
// note -- we assume above that there was an actual change before checking
395
// for edit protection. Thus the above check is important.
396
return;
397
}
398
this._set(
399
{
400
type: "cell",
401
id,
402
input,
403
start: null,
404
end: null,
405
},
406
save,
407
);
408
}
409
410
set_cell_output = (id: string, output: any, save = true) => {
411
this._set(
412
{
413
type: "cell",
414
id,
415
output,
416
},
417
save,
418
);
419
};
420
421
setCellId = (id: string, newId: string, save = true) => {
422
let cell = this.store.getIn(["cells", id])?.toJS();
423
if (cell == null) {
424
return;
425
}
426
cell.id = newId;
427
this.syncdb.delete({ type: "cell", id });
428
this.syncdb.set(cell);
429
if (save) {
430
this.syncdb.commit();
431
}
432
};
433
434
clear_selected_outputs = () => {
435
this.deprecated("clear_selected_outputs");
436
};
437
438
// Clear output in the list of cell id's.
439
// NOTE: clearing output *is* allowed for non-editable cells, since the definition
440
// of editable is that the *input* is editable.
441
// See https://github.com/sagemathinc/cocalc/issues/4805
442
public clear_outputs(cell_ids: string[], save: boolean = true): void {
443
const cells = this.store.get("cells");
444
if (cells == null) return; // nothing to do
445
for (const id of cell_ids) {
446
const cell = cells.get(id);
447
if (cell == null) continue;
448
if (cell.get("output") != null || cell.get("exec_count")) {
449
this._set({ type: "cell", id, output: null, exec_count: null }, false);
450
}
451
}
452
if (save) {
453
this._sync();
454
}
455
}
456
457
public clear_all_outputs(save: boolean = true): void {
458
this.clear_outputs(this.store.get_cell_list().toJS(), save);
459
}
460
461
private show_not_xable_error(x: string, n: number, reason?: string): void {
462
if (n <= 0) return;
463
const verb: string = n === 1 ? "is" : "are";
464
const noun: string = misc.plural(n, "cell");
465
this.set_error(
466
`${n} ${noun} ${verb} protected from ${x}${
467
reason ? " when " + reason : ""
468
}.`,
469
);
470
}
471
472
private show_not_editable_error(reason?: string): void {
473
this.show_not_xable_error("editing", 1, reason);
474
}
475
476
private show_not_deletable_error(n: number = 1): void {
477
this.show_not_xable_error("deletion", n);
478
}
479
480
public toggle_output(id: string, property: "collapsed" | "scrolled"): void {
481
this.toggle_outputs([id], property);
482
}
483
484
public toggle_outputs(
485
cell_ids: string[],
486
property: "collapsed" | "scrolled",
487
): void {
488
const cells = this.store.get("cells");
489
if (cells == null) {
490
throw Error("cells not defined");
491
}
492
for (const id of cell_ids) {
493
const cell = cells.get(id);
494
if (cell == null) {
495
throw Error(`no cell with id ${id}`);
496
}
497
if (cell.get("cell_type", "code") == "code") {
498
this._set(
499
{
500
type: "cell",
501
id,
502
[property]: !cell.get(
503
property,
504
property == "scrolled" ? false : true, // default scrolled to false
505
),
506
},
507
false,
508
);
509
}
510
}
511
this._sync();
512
}
513
514
public toggle_all_outputs(property: "collapsed" | "scrolled"): void {
515
this.toggle_outputs(this.store.get_cell_ids_list(), property);
516
}
517
518
public set_cell_pos(id: string, pos: number, save: boolean = true): void {
519
this._set({ type: "cell", id, pos }, save);
520
}
521
522
public moveCell(
523
oldIndex: number,
524
newIndex: number,
525
save: boolean = true,
526
): void {
527
if (oldIndex == newIndex) return; // nothing to do
528
// Move the cell that is currently at position oldIndex to
529
// be at position newIndex.
530
const cell_list = this.store.get_cell_list();
531
const newPos = cell_utils.moveCell({
532
oldIndex,
533
newIndex,
534
size: cell_list.size,
535
getPos: (index) =>
536
this.store.getIn(["cells", cell_list.get(index) ?? "", "pos"]) ?? 0,
537
});
538
this.set_cell_pos(cell_list.get(oldIndex) ?? "", newPos, save);
539
}
540
541
public set_cell_type(
542
id: string,
543
cell_type: string = "code",
544
save: boolean = true,
545
): void {
546
if (this.check_edit_protection(id, "changing cell type")) return;
547
if (
548
cell_type !== "markdown" &&
549
cell_type !== "raw" &&
550
cell_type !== "code"
551
) {
552
throw Error(
553
`cell type (='${cell_type}') must be 'markdown', 'raw', or 'code'`,
554
);
555
}
556
const obj: any = {
557
type: "cell",
558
id,
559
cell_type,
560
};
561
if (cell_type !== "code") {
562
// delete output and exec time info when switching to non-code cell_type
563
obj.output = obj.start = obj.end = obj.collapsed = obj.scrolled = null;
564
}
565
this._set(obj, save);
566
}
567
568
public set_selected_cell_type(cell_type: string): void {
569
this.deprecated("set_selected_cell_type", cell_type);
570
}
571
572
set_md_cell_editing = (id: string): void => {
573
this.deprecated("set_md_cell_editing", id);
574
};
575
576
set_md_cell_not_editing = (id: string): void => {
577
this.deprecated("set_md_cell_not_editing", id);
578
};
579
580
// Set which cell is currently the cursor.
581
set_cur_id = (id: string): void => {
582
this.deprecated("set_cur_id", id);
583
};
584
585
protected deprecated(f: string, ...args): void {
586
const s = "DEPRECATED JupyterActions(" + this.path + ")." + f;
587
console.warn(s, ...args);
588
}
589
590
private set_cell_list(): void {
591
const cells = this.store.get("cells");
592
if (cells == null) {
593
return;
594
}
595
const cell_list = cell_utils.sorted_cell_list(cells);
596
if (!cell_list.equals(this.store.get_cell_list())) {
597
this.setState({ cell_list });
598
this.store.emit("cell-list-recompute");
599
}
600
}
601
602
private syncdb_cell_change(id: string, new_cell: any): boolean {
603
const cells: immutable.Map<
604
string,
605
immutable.Map<string, any>
606
> = this.store.get("cells");
607
if (cells == null) throw Error("BUG -- cells must have been initialized!");
608
let cell_list_needs_recompute = false;
609
//@dbg("_syncdb_cell_change")("#{id} #{JSON.stringify(new_cell?.toJS())}")
610
let old_cell = cells.get(id);
611
if (new_cell == null) {
612
// delete cell
613
this.reset_more_output(id); // free up memory locally
614
if (old_cell != null) {
615
const cell_list = this.store.get_cell_list().filter((x) => x !== id);
616
this.setState({ cells: cells.delete(id), cell_list });
617
}
618
} else {
619
// change or add cell
620
old_cell = cells.get(id);
621
if (new_cell.equals(old_cell)) {
622
return false; // nothing to do
623
}
624
if (old_cell != null && new_cell.get("start") !== old_cell.get("start")) {
625
// cell re-evaluated so any more output is no longer valid.
626
this.reset_more_output(id);
627
}
628
if (old_cell == null || old_cell.get("pos") !== new_cell.get("pos")) {
629
cell_list_needs_recompute = true;
630
}
631
// preserve cursor info if happen to have it, rather than just letting
632
// it get deleted whenever the cell changes.
633
if (old_cell?.has("cursors")) {
634
new_cell = new_cell.set("cursors", old_cell.get("cursors"));
635
}
636
this.setState({ cells: cells.set(id, new_cell) });
637
if (this.store.getIn(["edit_cell_metadata", "id"]) === id) {
638
this.edit_cell_metadata(id); // updates the state during active editing.
639
}
640
}
641
642
this.onCellChange(id, new_cell, old_cell);
643
this.store.emit("cell_change", id, new_cell, old_cell);
644
645
return cell_list_needs_recompute;
646
}
647
648
_syncdb_change = (changes: any) => {
649
if (this.syncdb == null) return;
650
this.store.emit("syncdb-before-change");
651
this.__syncdb_change(changes);
652
this.store.emit("syncdb-after-change");
653
if (this.set_save_status != null) {
654
this.set_save_status();
655
}
656
};
657
658
__syncdb_change = (changes: any): void => {
659
if (
660
this.syncdb == null ||
661
changes == null ||
662
(changes != null && changes.size == 0)
663
) {
664
return;
665
}
666
const doInit = this._state === "init";
667
let cell_list_needs_recompute = false;
668
669
if (changes == "all" || this.store.get("cells") == null) {
670
// changes == 'all' is used by nbgrader to set the state...
671
// First time initialization, rather than some small
672
// update. We could use the same code, e.g.,
673
// calling syncdb_cell_change, but that SCALES HORRIBLY
674
// as the number of cells gets large!
675
676
// this.syncdb.get() returns an immutable.List of all the records
677
// in the syncdb database. These look like, e.g.,
678
// {type: "settings", backend_state: "running", trust: true, kernel: "python3", …}
679
// {type: "cell", id: "22cc3e", pos: 0, input: "# small copy", state: "done"}
680
let cells: immutable.Map<string, Cell> = immutable.Map();
681
this.syncdb.get().forEach((record) => {
682
switch (record.get("type")) {
683
case "cell":
684
cells = cells.set(record.get("id"), record);
685
break;
686
case "settings":
687
if (record == null) {
688
return;
689
}
690
const orig_kernel = this.store.get("kernel");
691
const kernel = record.get("kernel");
692
const obj: any = {
693
trust: !!record.get("trust"), // case to boolean
694
backend_state: record.get("backend_state"),
695
last_backend_state: record.get("last_backend_state"),
696
kernel_state: record.get("kernel_state"),
697
metadata: record.get("metadata"), // extra custom user-specified metadata
698
max_output_length: bounded_integer(
699
record.get("max_output_length"),
700
100,
701
250000,
702
DEFAULT_MAX_OUTPUT_LENGTH,
703
),
704
};
705
if (kernel !== orig_kernel) {
706
obj.kernel = kernel;
707
obj.kernel_info = this.store.get_kernel_info(kernel);
708
obj.backend_kernel_info = undefined;
709
}
710
this.setState(obj);
711
if (
712
!this.is_project &&
713
!this.is_compute_server &&
714
orig_kernel !== kernel
715
) {
716
this.set_cm_options();
717
}
718
719
break;
720
}
721
});
722
723
this.setState({ cells, cell_list: cell_utils.sorted_cell_list(cells) });
724
cell_list_needs_recompute = false;
725
} else {
726
changes.forEach((key) => {
727
const type: string = key.get("type");
728
const record = this.syncdb.get_one(key);
729
switch (type) {
730
case "cell":
731
if (this.syncdb_cell_change(key.get("id"), record)) {
732
cell_list_needs_recompute = true;
733
}
734
break;
735
case "fatal":
736
const error = record != null ? record.get("error") : undefined;
737
this.setState({ fatal: error });
738
// This check can be deleted in a few weeks:
739
if (
740
error != null &&
741
error.indexOf("file is currently being read or written") !== -1
742
) {
743
// No longer relevant -- see https://github.com/sagemathinc/cocalc/issues/1742
744
this.syncdb.delete({ type: "fatal" });
745
this.syncdb.commit();
746
}
747
break;
748
749
case "nbconvert":
750
if (this.is_project || this.is_compute_server) {
751
// before setting in store, let backend start reacting to change
752
this.handle_nbconvert_change(this.store.get("nbconvert"), record);
753
}
754
// Now set in our store.
755
this.setState({ nbconvert: record });
756
break;
757
758
case "settings":
759
if (record == null) {
760
return;
761
}
762
const orig_kernel = this.store.get("kernel", null);
763
const kernel = record.get("kernel");
764
const obj: any = {
765
trust: !!record.get("trust"), // case to boolean
766
backend_state: record.get("backend_state"),
767
last_backend_state: record.get("last_backend_state"),
768
kernel_state: record.get("kernel_state"),
769
kernel_error: record.get("kernel_error"),
770
metadata: record.get("metadata"), // extra custom user-specified metadata
771
connection_file: record.get("connection_file") ?? "",
772
max_output_length: bounded_integer(
773
record.get("max_output_length"),
774
100,
775
250000,
776
DEFAULT_MAX_OUTPUT_LENGTH,
777
),
778
};
779
if (kernel !== orig_kernel) {
780
obj.kernel = kernel;
781
obj.kernel_info = this.store.get_kernel_info(kernel);
782
obj.backend_kernel_info = undefined;
783
}
784
const prev_backend_state = this.store.get("backend_state");
785
this.setState(obj);
786
if (!this.is_project && !this.is_compute_server) {
787
// if the kernel changes or it just started running – we set the codemirror options!
788
// otherwise, just when computing them without the backend information, only a crude
789
// heuristic sets the values and we end up with "C" formatting for custom python kernels.
790
// @see https://github.com/sagemathinc/cocalc/issues/5478
791
const started_running =
792
record.get("backend_state") === "running" &&
793
prev_backend_state !== "running";
794
if (orig_kernel !== kernel || started_running) {
795
this.set_cm_options();
796
}
797
}
798
break;
799
}
800
});
801
}
802
if (cell_list_needs_recompute) {
803
this.set_cell_list();
804
}
805
806
this.__syncdb_change_post_hook(doInit);
807
};
808
809
protected __syncdb_change_post_hook(_doInit: boolean) {
810
// no-op in base class -- does interesting and different
811
// things in project, browser, etc.
812
}
813
814
protected onCellChange(_id: string, _new_cell: any, _old_cell: any) {
815
// no-op in base class. This is a hook though
816
// for potentially doing things when any cell changes.
817
}
818
819
ensure_backend_kernel_setup() {
820
// nontrivial in the project, but not in client or here.
821
}
822
823
protected _output_handler(_cell: any) {
824
throw Error("define in a derived class.");
825
}
826
827
private _syncdb_init_kernel(): void {
828
// console.log("jupyter::_syncdb_init_kernel", this.store.get("kernel"));
829
if (this.store.get("kernel") == null) {
830
// Creating a new notebook with no kernel set
831
if (!this.is_project && !this.is_compute_server) {
832
// we either let the user select a kernel, or use a stored one
833
let using_default_kernel = false;
834
835
const account_store = this.redux.getStore("account") as any;
836
const editor_settings = account_store.get("editor_settings") as any;
837
if (
838
editor_settings != null &&
839
!editor_settings.get("ask_jupyter_kernel")
840
) {
841
const default_kernel = editor_settings.getIn(["jupyter", "kernel"]);
842
// TODO: check if kernel is actually known
843
if (default_kernel != null) {
844
this.set_kernel(default_kernel);
845
using_default_kernel = true;
846
}
847
}
848
849
if (!using_default_kernel) {
850
// otherwise we let the user choose a kernel
851
this.show_select_kernel("bad kernel");
852
}
853
// we also finalize the kernel selection check, because it doesn't switch to true
854
// if there is no kernel at all.
855
this.setState({ check_select_kernel_init: true });
856
}
857
} else {
858
// Opening an existing notebook
859
const default_kernel = this.store.get_default_kernel();
860
if (default_kernel == null && this.store.get("kernel")) {
861
// But user has no default kernel, since they never before explicitly set one.
862
// So we set it. This is so that a user's default
863
// kernel is that of the first ipynb they
864
// opened, which is very sensible in courses.
865
this.set_default_kernel(this.store.get("kernel"));
866
}
867
}
868
}
869
870
/*
871
WARNING: Changes via set that are made when the actions
872
are not 'ready' or the syncdb is not ready are ignored.
873
These might happen right now if the user were to try to do
874
some random thing at the exact moment they are closing the
875
notebook. See https://github.com/sagemathinc/cocalc/issues/4274
876
*/
877
_set = (obj: any, save: boolean = true) => {
878
if (
879
this._state !== "ready" ||
880
this.store.get("read_only") ||
881
(this.syncdb != null && this.syncdb.get_state() != "ready")
882
) {
883
// no possible way to do anything.
884
return;
885
}
886
// check write protection regarding specific keys to be set
887
if (
888
obj.type === "cell" &&
889
obj.id != null &&
890
!this.store.is_cell_editable(obj.id)
891
) {
892
for (const protected_key of ["input", "cell_type", "attachments"]) {
893
if (misc.has_key(obj, protected_key)) {
894
throw CellWriteProtectedException;
895
}
896
}
897
}
898
//@dbg("_set")("obj=#{misc.to_json(obj)}")
899
this.syncdb.set(obj);
900
if (save) {
901
this.syncdb.commit();
902
}
903
// ensure that we update locally immediately for our own changes.
904
this._syncdb_change(
905
immutable.fromJS([misc.copy_with(obj, ["id", "type"])]),
906
);
907
};
908
909
// might throw a CellDeleteProtectedException
910
_delete = (obj: any, save = true) => {
911
if (this._state === "closed" || this.store.get("read_only")) {
912
return;
913
}
914
// check: don't delete cells marked as deletable=false
915
if (obj.type === "cell" && obj.id != null) {
916
if (!this.store.is_cell_deletable(obj.id)) {
917
throw CellDeleteProtectedException;
918
}
919
}
920
this.syncdb.delete(obj);
921
if (save) {
922
this.syncdb.commit();
923
}
924
this._syncdb_change(immutable.fromJS([{ type: obj.type, id: obj.id }]));
925
};
926
927
public _sync = () => {
928
if (this._state === "closed") {
929
return;
930
}
931
this.syncdb.commit();
932
};
933
934
public save = async (): Promise<void> => {
935
if (this.store.get("read_only") || this.isDeleted()) {
936
// can't save when readonly or deleted
937
return;
938
}
939
if (this.store.get("mode") === "edit") {
940
this._get_cell_input();
941
}
942
// Save the .ipynb file to disk. Note that this
943
// *changes* the syncdb by updating the last save time.
944
try {
945
// Make sure syncdb content is all sent to the project.
946
// This does not actually save the syncdb file to disk.
947
// This "save" means save state to backend.
948
await this.syncdb.save();
949
if (this._state === "closed") return;
950
// Export the ipynb file to disk.
951
await this.api_call("save_ipynb_file", {});
952
if (this._state === "closed") return;
953
// Save our custom-format syncdb to disk.
954
await this.syncdb.save_to_disk();
955
} catch (err) {
956
if (this._state === "closed") return;
957
if (err.toString().indexOf("no kernel with path") != -1) {
958
// This means that the kernel simply hasn't been initialized yet.
959
// User can try to save later, once it has.
960
return;
961
}
962
if (err.toString().indexOf("unknown endpoint") != -1) {
963
this.set_error(
964
"You MUST restart your project to run the latest Jupyter server! Click 'Restart Project' in your project's settings.",
965
);
966
return;
967
}
968
this.set_error(err.toString());
969
} finally {
970
if (this._state === "closed") return;
971
// And update the save status finally.
972
if (typeof this.set_save_status === "function") {
973
this.set_save_status();
974
}
975
}
976
};
977
978
save_asap = async (): Promise<void> => {
979
if (this.syncdb != null) {
980
await this.syncdb.save();
981
}
982
};
983
984
private id_is_available(id: string): boolean {
985
return this.store.getIn(["cells", id]) == null;
986
}
987
988
protected new_id(is_available?: (string) => boolean): string {
989
while (true) {
990
const id = misc.uuid().slice(0, 6);
991
if (
992
(is_available != null && is_available(id)) ||
993
this.id_is_available(id)
994
) {
995
return id;
996
}
997
}
998
}
999
1000
insert_cell(delta: any): string {
1001
this.deprecated("insert-cell", delta);
1002
return "";
1003
}
1004
1005
insert_cell_at(
1006
pos: number,
1007
save: boolean = true,
1008
id: string | undefined = undefined, // dangerous since could conflict (used by whiteboard)
1009
): string {
1010
if (this.store.get("read_only")) {
1011
throw Error("document is read only");
1012
}
1013
const new_id = id ?? this.new_id();
1014
this._set(
1015
{
1016
type: "cell",
1017
id: new_id,
1018
pos,
1019
input: "",
1020
},
1021
save,
1022
);
1023
return new_id; // violates CQRS... (this *is* used elsewhere)
1024
}
1025
1026
// insert a cell adjacent to the cell with given id.
1027
// -1 = above and +1 = below.
1028
insert_cell_adjacent(
1029
id: string,
1030
delta: -1 | 1,
1031
save: boolean = true,
1032
): string {
1033
const pos = cell_utils.new_cell_pos(
1034
this.store.get("cells"),
1035
this.store.get_cell_list(),
1036
id,
1037
delta,
1038
);
1039
return this.insert_cell_at(pos, save);
1040
}
1041
1042
delete_selected_cells = (sync = true): void => {
1043
this.deprecated("delete_selected_cells", sync);
1044
};
1045
1046
delete_cells(cells: string[], sync: boolean = true): void {
1047
let not_deletable: number = 0;
1048
for (const id of cells) {
1049
if (this.store.is_cell_deletable(id)) {
1050
this._delete({ type: "cell", id }, false);
1051
} else {
1052
not_deletable += 1;
1053
}
1054
}
1055
if (sync) {
1056
this._sync();
1057
}
1058
if (not_deletable === 0) return;
1059
1060
this.show_not_deletable_error(not_deletable);
1061
}
1062
1063
// Delete all blank code cells in the entire notebook.
1064
delete_all_blank_code_cells(sync: boolean = true): void {
1065
const cells: string[] = [];
1066
for (const id of this.store.get_cell_list()) {
1067
if (!this.store.is_cell_deletable(id)) {
1068
continue;
1069
}
1070
const cell = this.store.getIn(["cells", id]);
1071
if (cell == null) continue;
1072
if (
1073
cell.get("cell_type", "code") == "code" &&
1074
cell.get("input", "").trim() == "" &&
1075
cell.get("output", []).length == 0
1076
) {
1077
cells.push(id);
1078
}
1079
}
1080
this.delete_cells(cells, sync);
1081
}
1082
1083
move_selected_cells = (delta: number) => {
1084
this.deprecated("move_selected_cells", delta);
1085
};
1086
1087
undo = (): void => {
1088
if (this.syncdb != null) {
1089
this.syncdb.undo();
1090
}
1091
};
1092
1093
redo = (): void => {
1094
if (this.syncdb != null) {
1095
this.syncdb.redo();
1096
}
1097
};
1098
1099
in_undo_mode(): boolean {
1100
return this.syncdb?.in_undo_mode() ?? false;
1101
}
1102
1103
public run_code_cell(
1104
id: string,
1105
save: boolean = true,
1106
no_halt: boolean = false,
1107
): void {
1108
const cell = this.store.getIn(["cells", id]);
1109
if (cell == null) {
1110
// it is trivial to run a cell that does not exist -- nothing needs to be done.
1111
return;
1112
}
1113
const kernel = this.store.get("kernel");
1114
if (kernel == null || kernel === "") {
1115
// just in case, we clear any "running" indicators
1116
this._set({ type: "cell", id, state: "done" });
1117
// don't attempt to run a code-cell if there is no kernel defined
1118
this.set_error(
1119
"No kernel set for running cells. Therefore it is not possible to run a code cell. You have to select a kernel!",
1120
);
1121
return;
1122
}
1123
1124
if (cell.get("state", "done") != "done") {
1125
// already running -- stop it first somehow if you want to run it again...
1126
return;
1127
}
1128
1129
// We mark the start timestamp uniquely, so that the backend can sort
1130
// multiple cells with a simultaneous time to start request.
1131
1132
let start: number = this._client.server_time().valueOf();
1133
if (this._last_start != null && start <= this._last_start) {
1134
start = this._last_start + 1;
1135
}
1136
this._last_start = start;
1137
this.set_jupyter_metadata(id, "outputs_hidden", undefined, false);
1138
1139
this._set(
1140
{
1141
type: "cell",
1142
id,
1143
state: "start",
1144
start,
1145
end: null,
1146
// time last evaluation took
1147
last:
1148
cell.get("start") != null && cell.get("end") != null
1149
? cell.get("end") - cell.get("start")
1150
: cell.get("last"),
1151
output: null,
1152
exec_count: null,
1153
collapsed: null,
1154
no_halt: no_halt ? no_halt : null,
1155
},
1156
save,
1157
);
1158
this.set_trust_notebook(true, save);
1159
}
1160
1161
clear_cell = (id: string, save = true) => {
1162
const cell = this.store.getIn(["cells", id]);
1163
1164
return this._set(
1165
{
1166
type: "cell",
1167
id,
1168
state: null,
1169
start: null,
1170
end: null,
1171
last:
1172
cell?.get("start") != null && cell?.get("end") != null
1173
? cell?.get("end") - cell?.get("start")
1174
: (cell?.get("last") ?? null),
1175
output: null,
1176
exec_count: null,
1177
collapsed: null,
1178
},
1179
save,
1180
);
1181
};
1182
1183
clear_cell_run_state = (id: string, save = true) => {
1184
return this._set(
1185
{
1186
type: "cell",
1187
id,
1188
state: "done",
1189
},
1190
save,
1191
);
1192
};
1193
1194
run_selected_cells = (): void => {
1195
this.deprecated("run_selected_cells");
1196
};
1197
1198
public abstract run_cell(id: string, save?: boolean, no_halt?: boolean): void;
1199
1200
run_all_cells = (no_halt: boolean = false): void => {
1201
this.store.get_cell_list().forEach((id) => {
1202
this.run_cell(id, false, no_halt);
1203
});
1204
this.save_asap();
1205
};
1206
1207
clear_all_cell_run_state = (): void => {
1208
if (!this.store) return;
1209
this.store.get_cell_list().forEach((id) => {
1210
this.clear_cell_run_state(id, false);
1211
});
1212
this.save_asap();
1213
};
1214
1215
// Run all cells strictly above the specified cell.
1216
run_all_above_cell(id: string): void {
1217
const i: number = this.store.get_cell_index(id);
1218
const v: string[] = this.store.get_cell_list().toJS();
1219
for (const id of v.slice(0, i)) {
1220
this.run_cell(id, false);
1221
}
1222
this.save_asap();
1223
}
1224
1225
// Run all cells below (and *including*) the specified cell.
1226
public run_all_below_cell(id: string): void {
1227
const i: number = this.store.get_cell_index(id);
1228
const v: string[] = this.store.get_cell_list().toJS();
1229
for (const id of v.slice(i)) {
1230
this.run_cell(id, false);
1231
}
1232
this.save_asap();
1233
}
1234
1235
public set_cursor_locs(locs: any[] = [], side_effect: boolean = false): void {
1236
this.last_cursor_move_time = new Date();
1237
if (this.syncdb == null) {
1238
// syncdb not always set -- https://github.com/sagemathinc/cocalc/issues/2107
1239
return;
1240
}
1241
if (locs.length === 0) {
1242
// don't remove on blur -- cursor will fade out just fine
1243
return;
1244
}
1245
this._cursor_locs = locs; // remember our own cursors for splitting cell
1246
this.syncdb.set_cursor_locs(locs, side_effect);
1247
}
1248
1249
public split_cell(id: string, cursor: { line: number; ch: number }): void {
1250
if (this.check_edit_protection(id, "splitting cell")) {
1251
return;
1252
}
1253
// insert a new cell before the currently selected one
1254
const new_id: string = this.insert_cell_adjacent(id, -1, false);
1255
1256
// split the cell content at the cursor loc
1257
const cell = this.store.get("cells").get(id);
1258
if (cell == null) {
1259
throw Error(`no cell with id=${id}`);
1260
}
1261
const cell_type = cell.get("cell_type");
1262
if (cell_type !== "code") {
1263
this.set_cell_type(new_id, cell_type, false);
1264
}
1265
const input = cell.get("input");
1266
if (input == null) {
1267
this.syncdb.commit();
1268
return; // very easy case.
1269
}
1270
1271
const lines = input.split("\n");
1272
let v = lines.slice(0, cursor.line);
1273
const line: string | undefined = lines[cursor.line];
1274
if (line != null) {
1275
const left = line.slice(0, cursor.ch);
1276
if (left) {
1277
v.push(left);
1278
}
1279
}
1280
const top = v.join("\n");
1281
1282
v = lines.slice(cursor.line + 1);
1283
if (line != null) {
1284
const right = line.slice(cursor.ch);
1285
if (right) {
1286
v = [right].concat(v);
1287
}
1288
}
1289
const bottom = v.join("\n");
1290
this.set_cell_input(new_id, top, false);
1291
this.set_cell_input(id, bottom, true);
1292
}
1293
1294
// Copy content from the cell below the given cell into the currently
1295
// selected cell, then delete the cell below the given cell.
1296
public merge_cell_below_cell(cell_id: string, save: boolean = true): void {
1297
const next_id = this.store.get_cell_id(1, cell_id);
1298
if (next_id == null) {
1299
// no cell below given cell, so trivial.
1300
return;
1301
}
1302
for (const id of [cell_id, next_id]) {
1303
if (this.check_edit_protection(id, "merging cell")) return;
1304
}
1305
if (this.check_delete_protection(next_id)) return;
1306
1307
const cells = this.store.get("cells");
1308
if (cells == null) {
1309
return;
1310
}
1311
1312
const input: string =
1313
cells.getIn([cell_id, "input"], "") +
1314
"\n" +
1315
cells.getIn([next_id, "input"], "");
1316
1317
const output0 = cells.getIn([cell_id, "output"]) as any;
1318
const output1 = cells.getIn([next_id, "output"]) as any;
1319
let output: any = undefined;
1320
if (output0 == null) {
1321
output = output1;
1322
} else if (output1 == null) {
1323
output = output0;
1324
} else {
1325
// both output0 and output1 are defined; need to merge.
1326
// This is complicated since output is a map from string numbers.
1327
output = output0;
1328
let n = output0.size;
1329
for (let i = 0; i < output1.size; i++) {
1330
output = output.set(`${n}`, output1.get(`${i}`));
1331
n += 1;
1332
}
1333
}
1334
1335
this._delete({ type: "cell", id: next_id }, false);
1336
this._set(
1337
{
1338
type: "cell",
1339
id: cell_id,
1340
input,
1341
output: output != null ? output : null,
1342
start: null,
1343
end: null,
1344
},
1345
save,
1346
);
1347
}
1348
1349
// Merge the given cells into one cell, which replaces
1350
// the frist cell in cell_ids.
1351
// We also merge all output, instead of throwing away
1352
// all but first output (which jupyter does, and makes no sense).
1353
public merge_cells(cell_ids: string[]): void {
1354
const n = cell_ids.length;
1355
if (n <= 1) return; // trivial special case.
1356
for (let i = 0; i < n - 1; i++) {
1357
this.merge_cell_below_cell(cell_ids[0], i == n - 2);
1358
}
1359
}
1360
1361
// Copy the list of cells into our internal clipboard
1362
public copy_cells(cell_ids: string[]): void {
1363
const cells = this.store.get("cells");
1364
let global_clipboard = immutable.List();
1365
for (const id of cell_ids) {
1366
global_clipboard = global_clipboard.push(cells.get(id));
1367
}
1368
this.store.set_global_clipboard(global_clipboard);
1369
}
1370
1371
public studentProjectFunctionality() {
1372
return this.redux
1373
.getStore("projects")
1374
.get_student_project_functionality(this.project_id);
1375
}
1376
1377
public requireToggleReadonly(): void {
1378
if (this.studentProjectFunctionality().disableJupyterToggleReadonly) {
1379
throw Error("Toggling of write protection is disabled in this project.");
1380
}
1381
}
1382
1383
/* write protection disables any modifications, entering "edit"
1384
mode, and prohibits cell evaluations example: teacher handout
1385
notebook and student should not be able to modify an
1386
instruction cell in any way. */
1387
public toggle_write_protection_on_cells(
1388
cell_ids: string[],
1389
save: boolean = true,
1390
): void {
1391
this.requireToggleReadonly();
1392
this.toggle_metadata_boolean_on_cells(cell_ids, "editable", true, save);
1393
}
1394
1395
set_metadata_on_cells = (
1396
cell_ids: string[],
1397
key: string,
1398
value,
1399
save: boolean = true,
1400
) => {
1401
for (const id of cell_ids) {
1402
this.set_cell_metadata({
1403
id,
1404
metadata: { [key]: value },
1405
merge: true,
1406
save: false,
1407
bypass_edit_protection: true,
1408
});
1409
}
1410
if (save) {
1411
this.save_asap();
1412
}
1413
};
1414
1415
public write_protect_cells(
1416
cell_ids: string[],
1417
protect: boolean,
1418
save: boolean = true,
1419
) {
1420
this.set_metadata_on_cells(cell_ids, "editable", !protect, save);
1421
}
1422
1423
public delete_protect_cells(
1424
cell_ids: string[],
1425
protect: boolean,
1426
save: boolean = true,
1427
) {
1428
this.set_metadata_on_cells(cell_ids, "deletable", !protect, save);
1429
}
1430
1431
// this prevents any cell from being deleted, either directly, or indirectly via a "merge"
1432
// example: teacher handout notebook and student should not be able to modify an instruction cell in any way
1433
public toggle_delete_protection_on_cells(
1434
cell_ids: string[],
1435
save: boolean = true,
1436
): void {
1437
this.requireToggleReadonly();
1438
this.toggle_metadata_boolean_on_cells(cell_ids, "deletable", true, save);
1439
}
1440
1441
// This toggles the boolean value of given metadata field.
1442
// If not set, it is assumed to be true and toggled to false
1443
// For more than one cell, the first one is used to toggle
1444
// all cells to the inverted state
1445
private toggle_metadata_boolean_on_cells(
1446
cell_ids: string[],
1447
key: string,
1448
default_value: boolean, // default metadata value, if the metadata field is not set.
1449
save: boolean = true,
1450
): void {
1451
for (const id of cell_ids) {
1452
this.set_cell_metadata({
1453
id,
1454
metadata: {
1455
[key]: !this.store.getIn(
1456
["cells", id, "metadata", key],
1457
default_value,
1458
),
1459
},
1460
merge: true,
1461
save: false,
1462
bypass_edit_protection: true,
1463
});
1464
}
1465
if (save) {
1466
this.save_asap();
1467
}
1468
}
1469
1470
public toggle_jupyter_metadata_boolean(
1471
id: string,
1472
key: string,
1473
save: boolean = true,
1474
): void {
1475
const jupyter = this.store
1476
.getIn(["cells", id, "metadata", "jupyter"], immutable.Map())
1477
.toJS();
1478
jupyter[key] = !jupyter[key];
1479
this.set_cell_metadata({
1480
id,
1481
metadata: { jupyter },
1482
merge: true,
1483
save,
1484
});
1485
}
1486
1487
public set_jupyter_metadata(
1488
id: string,
1489
key: string,
1490
value: any,
1491
save: boolean = true,
1492
): void {
1493
const jupyter = this.store
1494
.getIn(["cells", id, "metadata", "jupyter"], immutable.Map())
1495
.toJS();
1496
if (value == null && jupyter[key] == null) return; // nothing to do.
1497
if (value != null) {
1498
jupyter[key] = value;
1499
} else {
1500
delete jupyter[key];
1501
}
1502
this.set_cell_metadata({
1503
id,
1504
metadata: { jupyter },
1505
merge: true,
1506
save,
1507
});
1508
}
1509
1510
// Paste cells from the internal clipboard; also
1511
// delta = 0 -- replace cell_ids cells
1512
// delta = 1 -- paste cells below last cell in cell_ids
1513
// delta = -1 -- paste cells above first cell in cell_ids.
1514
public paste_cells_at(cell_ids: string[], delta: 0 | 1 | -1 = 1): void {
1515
const clipboard = this.store.get_global_clipboard();
1516
if (clipboard == null || clipboard.size === 0) {
1517
return; // nothing to do
1518
}
1519
1520
if (cell_ids.length === 0) {
1521
// There are no cells currently selected. This can
1522
// happen in an edge case with slow network -- see
1523
// https://github.com/sagemathinc/cocalc/issues/3899
1524
clipboard.forEach((cell, i) => {
1525
cell = cell.set("id", this.new_id()); // randomize the id of the cell
1526
cell = cell.set("pos", i);
1527
this._set(cell, false);
1528
});
1529
this.ensure_positions_are_unique();
1530
this._sync();
1531
return;
1532
}
1533
1534
let cell_before_pasted_id: string;
1535
const cells = this.store.get("cells");
1536
if (delta === -1 || delta === 0) {
1537
// one before first selected
1538
cell_before_pasted_id = this.store.get_cell_id(-1, cell_ids[0]) ?? "";
1539
} else if (delta === 1) {
1540
// last selected
1541
cell_before_pasted_id = cell_ids[cell_ids.length - 1];
1542
} else {
1543
// Typescript should prevent this, but just to be sure.
1544
throw Error(`delta (=${delta}) must be 0, -1, or 1`);
1545
}
1546
try {
1547
let after_pos: number, before_pos: number | undefined;
1548
if (delta === 0) {
1549
// replace, so delete cell_ids, unless just one, since
1550
// cursor cell_ids selection is confusing with Jupyter's model.
1551
if (cell_ids.length > 1) {
1552
this.delete_cells(cell_ids, false);
1553
}
1554
}
1555
// put the cells from the clipboard into the document, setting their positions
1556
if (cell_before_pasted_id == null) {
1557
// very top cell
1558
before_pos = undefined;
1559
after_pos = cells.getIn([cell_ids[0], "pos"]) as number;
1560
} else {
1561
before_pos = cells.getIn([cell_before_pasted_id, "pos"]) as
1562
| number
1563
| undefined;
1564
after_pos = cells.getIn([
1565
this.store.get_cell_id(+1, cell_before_pasted_id),
1566
"pos",
1567
]) as number;
1568
}
1569
const positions = cell_utils.positions_between(
1570
before_pos,
1571
after_pos,
1572
clipboard.size,
1573
);
1574
clipboard.forEach((cell, i) => {
1575
cell = cell.set("id", this.new_id()); // randomize the id of the cell
1576
cell = cell.set("pos", positions[i]);
1577
this._set(cell, false);
1578
});
1579
} finally {
1580
// very important that we save whatever is done above, so other viewers see it.
1581
this._sync();
1582
}
1583
}
1584
1585
// File --> Open: just show the file listing page.
1586
file_open = (): void => {
1587
if (this.redux == null) return;
1588
this.redux
1589
.getProjectActions(this.store.get("project_id"))
1590
.set_active_tab("files");
1591
};
1592
1593
// File --> New: like open, but also show the create panel
1594
file_new = (): void => {
1595
if (this.redux == null) return;
1596
const project_actions = this.redux.getProjectActions(
1597
this.store.get("project_id"),
1598
);
1599
project_actions.set_active_tab("new");
1600
};
1601
1602
private _get_cell_input = (id?: string | undefined): string => {
1603
this.deprecated("_get_cell_input", id);
1604
return "";
1605
};
1606
1607
// Version of the cell's input stored in store.
1608
// (A live codemirror editor could have a slightly
1609
// newer version, so this is only a fallback).
1610
get_cell_input(id: string): string {
1611
return this.store.getIn(["cells", id, "input"], "");
1612
}
1613
1614
set_kernel = (kernel: string | null) => {
1615
if (this.syncdb.get_state() != "ready") {
1616
console.warn("Jupyter syncdb not yet ready -- not setting kernel");
1617
return;
1618
}
1619
if (this.store.get("kernel") !== kernel) {
1620
this._set({
1621
type: "settings",
1622
kernel,
1623
});
1624
// clear error when changing the kernel
1625
this.set_error(null);
1626
}
1627
if (this.store.get("show_kernel_selector") || kernel === "") {
1628
this.hide_select_kernel();
1629
}
1630
if (kernel === "") {
1631
this.halt(); // user "detaches" kernel from notebook, we stop the kernel
1632
}
1633
};
1634
1635
public show_history_viewer(): void {
1636
const project_actions = this.redux.getProjectActions(this.project_id);
1637
if (project_actions == null) return;
1638
project_actions.open_file({
1639
path: misc.history_path(this.path),
1640
foreground: true,
1641
});
1642
}
1643
1644
// Attempt to fetch completions for give code and cursor_pos
1645
// If successful, the completions are put in store.get('completions') and looks like
1646
// this (as an immutable map):
1647
// cursor_end : 2
1648
// cursor_start : 0
1649
// matches : ['the', 'completions', ...]
1650
// status : "ok"
1651
// code : code
1652
// cursor_pos : cursor_pos
1653
//
1654
// If not successful, result is:
1655
// status : "error"
1656
// code : code
1657
// cursor_pos : cursor_pos
1658
// error : 'an error message'
1659
//
1660
// Only the most recent fetch has any impact, and calling
1661
// clear_complete() ensures any fetch made before that
1662
// is ignored.
1663
1664
// Returns true if a dialog with options appears, and false otherwise.
1665
public async complete(
1666
code: string,
1667
pos?: { line: number; ch: number } | number,
1668
id?: string,
1669
offset?: any,
1670
): Promise<boolean> {
1671
let cursor_pos;
1672
const req = (this._complete_request =
1673
(this._complete_request != null ? this._complete_request : 0) + 1);
1674
1675
this.setState({ complete: undefined });
1676
1677
// pos can be either a {line:?, ch:?} object as in codemirror,
1678
// or a number.
1679
if (pos == null || typeof pos == "number") {
1680
cursor_pos = pos;
1681
} else {
1682
cursor_pos = codemirror_to_jupyter_pos(code, pos);
1683
}
1684
cursor_pos = js_idx_to_char_idx(cursor_pos, code);
1685
1686
const start = new Date();
1687
let complete;
1688
try {
1689
complete = await this.api_call("complete", {
1690
code,
1691
cursor_pos,
1692
});
1693
} catch (err) {
1694
if (this._complete_request > req) return false;
1695
this.setState({ complete: { error: err } });
1696
// no op for now...
1697
throw Error(`ignore -- ${err}`);
1698
//return false;
1699
}
1700
1701
if (this.last_cursor_move_time >= start) {
1702
// see https://github.com/sagemathinc/cocalc/issues/3611
1703
throw Error("ignore");
1704
//return false;
1705
}
1706
if (this._complete_request > req) {
1707
// future completion or clear happened; so ignore this result.
1708
throw Error("ignore");
1709
//return false;
1710
}
1711
1712
if (complete.status !== "ok") {
1713
this.setState({
1714
complete: {
1715
error: complete.error ? complete.error : "completion failed",
1716
},
1717
});
1718
return false;
1719
}
1720
1721
if (complete.matches == 0) {
1722
return false;
1723
}
1724
1725
delete complete.status;
1726
complete.base = code;
1727
complete.code = code;
1728
complete.pos = char_idx_to_js_idx(cursor_pos, code);
1729
complete.cursor_start = char_idx_to_js_idx(complete.cursor_start, code);
1730
complete.cursor_end = char_idx_to_js_idx(complete.cursor_end, code);
1731
complete.id = id;
1732
// Set the result so the UI can then react to the change.
1733
if (offset != null) {
1734
complete.offset = offset;
1735
}
1736
// For some reason, sometimes complete.matches are not unique, which is annoying/confusing,
1737
// and breaks an assumption in our react code too.
1738
// I think the reason is e.g., a filename and a variable could be the same. We're not
1739
// worrying about that now.
1740
complete.matches = Array.from(new Set(complete.matches));
1741
// sort in a way that matches how JupyterLab sorts completions, which
1742
// is case insensitive with % magics at the bottom
1743
complete.matches.sort((x, y) => {
1744
const c = misc.cmp(getCompletionGroup(x), getCompletionGroup(y));
1745
if (c) {
1746
return c;
1747
}
1748
return misc.cmp(x.toLowerCase(), y.toLowerCase());
1749
});
1750
const i_complete = immutable.fromJS(complete);
1751
if (complete.matches && complete.matches.length === 1 && id != null) {
1752
// special case -- a unique completion and we know id of cell in which completing is given.
1753
this.select_complete(id, complete.matches[0], i_complete);
1754
return false;
1755
} else {
1756
this.setState({ complete: i_complete });
1757
return true;
1758
}
1759
}
1760
1761
clear_complete = (): void => {
1762
this._complete_request =
1763
(this._complete_request != null ? this._complete_request : 0) + 1;
1764
this.setState({ complete: undefined });
1765
};
1766
1767
public select_complete(
1768
id: string,
1769
item: string,
1770
complete?: immutable.Map<string, any>,
1771
): void {
1772
if (complete == null) {
1773
complete = this.store.get("complete");
1774
}
1775
this.clear_complete();
1776
if (complete == null) {
1777
return;
1778
}
1779
const input = complete.get("code");
1780
if (input != null && complete.get("error") == null) {
1781
const starting = input.slice(0, complete.get("cursor_start"));
1782
const ending = input.slice(complete.get("cursor_end"));
1783
const new_input = starting + item + ending;
1784
const base = complete.get("base");
1785
this.complete_cell(id, base, new_input);
1786
}
1787
}
1788
1789
complete_cell(id: string, base: string, new_input: string): void {
1790
this.merge_cell_input(id, base, new_input);
1791
}
1792
1793
merge_cell_input(
1794
id: string,
1795
base: string,
1796
input: string,
1797
save: boolean = true,
1798
): void {
1799
const remote = this.store.getIn(["cells", id, "input"]);
1800
if (remote == null || base == null || input == null) {
1801
return;
1802
}
1803
const new_input = three_way_merge({
1804
base,
1805
local: input,
1806
remote,
1807
});
1808
this.set_cell_input(id, new_input, save);
1809
}
1810
1811
is_introspecting(): boolean {
1812
const actions = this.getFrameActions() as any;
1813
return actions?.store?.get("introspect") != null;
1814
}
1815
1816
introspect_close = () => {
1817
if (this.is_introspecting()) {
1818
this.getFrameActions()?.setState({ introspect: undefined });
1819
}
1820
};
1821
1822
introspect_at_pos = async (
1823
code: string,
1824
level: 0 | 1 = 0,
1825
pos: { ch: number; line: number },
1826
): Promise<void> => {
1827
if (code === "") return; // no-op if there is no code (should never happen)
1828
await this.introspect(code, level, codemirror_to_jupyter_pos(code, pos));
1829
};
1830
1831
introspect = async (
1832
code: string,
1833
level: 0 | 1,
1834
cursor_pos?: number,
1835
): Promise<immutable.Map<string, any> | undefined> => {
1836
const req = (this._introspect_request =
1837
(this._introspect_request != null ? this._introspect_request : 0) + 1);
1838
1839
if (cursor_pos == null) {
1840
cursor_pos = code.length;
1841
}
1842
cursor_pos = js_idx_to_char_idx(cursor_pos, code);
1843
1844
let introspect;
1845
try {
1846
introspect = await this.api_call("introspect", {
1847
code,
1848
cursor_pos,
1849
level,
1850
});
1851
if (introspect.status !== "ok") {
1852
introspect = { error: "completion failed" };
1853
}
1854
delete introspect.status;
1855
} catch (err) {
1856
introspect = { error: err };
1857
}
1858
if (this._introspect_request > req) return;
1859
const i = immutable.fromJS(introspect);
1860
this.getFrameActions()?.setState({
1861
introspect: i,
1862
});
1863
return i; // convenient / useful, e.g., for use by whiteboard.
1864
};
1865
1866
clear_introspect = (): void => {
1867
this._introspect_request =
1868
(this._introspect_request != null ? this._introspect_request : 0) + 1;
1869
this.getFrameActions()?.setState({ introspect: undefined });
1870
};
1871
1872
public async signal(signal = "SIGINT"): Promise<void> {
1873
// TODO: more setStates, awaits, and UI to reflect this happening...
1874
try {
1875
await this.api_call("signal", { signal }, 5000);
1876
} catch (err) {
1877
this.set_error(err);
1878
}
1879
}
1880
1881
// Kill the running kernel and does NOT start it up again.
1882
halt = reuseInFlight(async (): Promise<void> => {
1883
if (this.restartKernelOnClose != null && this.jupyter_kernel != null) {
1884
this.jupyter_kernel.removeListener("closed", this.restartKernelOnClose);
1885
delete this.restartKernelOnClose;
1886
}
1887
this.clear_all_cell_run_state();
1888
await this.signal("SIGKILL");
1889
// Wait a little, since SIGKILL has to really happen on backend,
1890
// and server has to respond and change state.
1891
const not_running = (s): boolean => {
1892
if (this._state === "closed") return true;
1893
const t = s.get_one({ type: "settings" });
1894
return t != null && t.get("backend_state") != "running";
1895
};
1896
try {
1897
await this.syncdb.wait(not_running, 30);
1898
// worked -- and also no need to show "kernel got killed" message since this was intentional.
1899
this.set_error("");
1900
} catch (err) {
1901
// failed
1902
this.set_error(err);
1903
}
1904
});
1905
1906
restart = reuseInFlight(async (): Promise<void> => {
1907
await this.halt();
1908
if (this._state === "closed") return;
1909
this.clear_all_cell_run_state();
1910
// Actually start it running again (rather than waiting for
1911
// user to do something), since this is called "restart".
1912
try {
1913
await this.set_backend_kernel_info(); // causes kernel to start
1914
} catch (err) {
1915
this.set_error(err);
1916
}
1917
});
1918
1919
public shutdown = reuseInFlight(async (): Promise<void> => {
1920
if (this._state === "closed") return;
1921
await this.signal("SIGKILL");
1922
if (this._state === "closed") return;
1923
this.clear_all_cell_run_state();
1924
await this.save_asap();
1925
});
1926
1927
set_backend_kernel_info = async (): Promise<void> => {
1928
if (this._state === "closed" || this.syncdb.is_read_only()) {
1929
return;
1930
}
1931
1932
if (this.isCellRunner() && (this.is_project || this.is_compute_server)) {
1933
const dbg = this.dbg(`set_backend_kernel_info ${misc.uuid()}`);
1934
if (
1935
this.jupyter_kernel == null ||
1936
this.jupyter_kernel.get_state() == "closed"
1937
) {
1938
dbg("no Jupyter kernel defined");
1939
return;
1940
}
1941
dbg("getting kernel_info...");
1942
let backend_kernel_info: KernelInfo;
1943
try {
1944
backend_kernel_info = immutable.fromJS(
1945
await this.jupyter_kernel.kernel_info(),
1946
);
1947
} catch (err) {
1948
dbg(`error = ${err}`);
1949
return;
1950
}
1951
this.setState({ backend_kernel_info });
1952
} else {
1953
await this._set_backend_kernel_info_client();
1954
}
1955
};
1956
1957
_set_backend_kernel_info_client = reuseInFlight(async (): Promise<void> => {
1958
await retry_until_success({
1959
max_time: 120000,
1960
start_delay: 1000,
1961
max_delay: 10000,
1962
f: this._fetch_backend_kernel_info_from_server,
1963
desc: "jupyter:_set_backend_kernel_info_client",
1964
});
1965
});
1966
1967
_fetch_backend_kernel_info_from_server = async (): Promise<void> => {
1968
const f = async () => {
1969
if (this._state === "closed") {
1970
return;
1971
}
1972
const data = await this.api_call("kernel_info", {});
1973
this.setState({
1974
backend_kernel_info: data,
1975
// this is when the server for this doc started, not when kernel last started!
1976
start_time: data.start_time,
1977
});
1978
};
1979
try {
1980
await retry_until_success({
1981
max_time: 1000 * 60 * 30,
1982
start_delay: 500,
1983
max_delay: 3000,
1984
f,
1985
desc: "jupyter:_fetch_backend_kernel_info_from_server",
1986
});
1987
} catch (err) {
1988
this.set_error(err);
1989
}
1990
if (this.is_closed()) return;
1991
// Update the codemirror editor options.
1992
this.set_cm_options();
1993
};
1994
1995
// Do a file action, e.g., 'compress', 'delete', 'rename', 'duplicate', 'move',
1996
// 'copy', 'share', 'download', 'open_file', 'close_file', 'reopen_file'
1997
// Each just shows
1998
// the corresponding dialog in
1999
// the file manager, so gives a step to confirm, etc.
2000
// The path may optionally be *any* file in this project.
2001
public async file_action(action_name: string, path?: string): Promise<void> {
2002
if (this._state == "closed") return;
2003
const a = this.redux.getProjectActions(this.project_id);
2004
if (path == null) {
2005
path = this.store.get("path");
2006
if (path == null) {
2007
throw Error("path must be defined in the store to use default");
2008
}
2009
}
2010
if (action_name === "reopen_file") {
2011
a.close_file(path);
2012
// ensure the side effects from changing registered
2013
// editors in project_file.* finish happening
2014
await delay(0);
2015
a.open_file({ path });
2016
return;
2017
}
2018
if (action_name === "close_file") {
2019
await this.syncdb.save();
2020
a.close_file(path);
2021
return;
2022
}
2023
if (action_name === "open_file") {
2024
a.open_file({ path });
2025
return;
2026
}
2027
if (action_name == "download") {
2028
a.download_file({ path });
2029
return;
2030
}
2031
const { head, tail } = misc.path_split(path);
2032
a.open_directory(head);
2033
a.set_all_files_unchecked();
2034
a.set_file_checked(path, true);
2035
return a.set_file_action(action_name, () => tail);
2036
}
2037
2038
set_max_output_length = (n) => {
2039
return this._set({
2040
type: "settings",
2041
max_output_length: n,
2042
});
2043
};
2044
2045
async fetch_more_output(id: string): Promise<void> {
2046
const time = this._client.server_time().valueOf();
2047
try {
2048
const more_output = await this.api_call("more_output", { id: id }, 60000);
2049
if (!this.store.getIn(["cells", id, "scrolled"])) {
2050
// make output area scrolled, since there is going to be a lot of output
2051
this.toggle_output(id, "scrolled");
2052
}
2053
this.set_more_output(id, { time, mesg_list: more_output });
2054
} catch (err) {
2055
this.set_error(err);
2056
}
2057
}
2058
2059
// NOTE: set_more_output on project-actions is different
2060
set_more_output = (id: string, more_output: any, _?: any): void => {
2061
if (this.store.getIn(["cells", id]) == null) {
2062
return;
2063
}
2064
const x = this.store.get("more_output", immutable.Map());
2065
this.setState({
2066
more_output: x.set(id, immutable.fromJS(more_output)),
2067
});
2068
};
2069
2070
reset_more_output = (id?: any): void => {
2071
let left: any;
2072
const more_output =
2073
(left = this.store.get("more_output")) != null ? left : immutable.Map();
2074
if (more_output.has(id)) {
2075
this.setState({ more_output: more_output.delete(id) });
2076
}
2077
};
2078
2079
protected set_cm_options(): void {
2080
// this only does something in browser-actions.
2081
}
2082
2083
set_trust_notebook = (trust: any, save: boolean = true) => {
2084
return this._set(
2085
{
2086
type: "settings",
2087
trust: !!trust,
2088
},
2089
save,
2090
); // case to bool
2091
};
2092
2093
scroll(pos): any {
2094
this.deprecated("scroll", pos);
2095
}
2096
2097
// submit input for a particular cell -- this is used by the
2098
// Input component output message type for interactive input.
2099
public async submit_input(id: string, value: string): Promise<void> {
2100
const output = this.store.getIn(["cells", id, "output"]);
2101
if (output == null) {
2102
return;
2103
}
2104
const n = `${output.size - 1}`;
2105
const mesg = output.get(n);
2106
if (mesg == null) {
2107
return;
2108
}
2109
2110
if (mesg.getIn(["opts", "password"])) {
2111
// handle password input separately by first submitting to the backend.
2112
try {
2113
await this.submit_password(id, value);
2114
} catch (err) {
2115
this.set_error(`Error setting backend key/value store (${err})`);
2116
return;
2117
}
2118
const m = value.length;
2119
value = "";
2120
for (let i = 0; i < m; i++) {
2121
value == "●";
2122
}
2123
this.set_cell_output(id, output.set(n, mesg.set("value", value)), false);
2124
this.save_asap();
2125
return;
2126
}
2127
2128
this.set_cell_output(id, output.set(n, mesg.set("value", value)), false);
2129
this.save_asap();
2130
}
2131
2132
submit_password = async (id: string, value: any): Promise<void> => {
2133
await this.set_in_backend_key_value_store(id, value);
2134
};
2135
2136
set_in_backend_key_value_store = async (
2137
key: any,
2138
value: any,
2139
): Promise<void> => {
2140
try {
2141
await this.api_call("store", { key, value });
2142
} catch (err) {
2143
this.set_error(err);
2144
}
2145
};
2146
2147
public async set_to_ipynb(
2148
ipynb: any,
2149
data_only: boolean = false,
2150
): Promise<void> {
2151
/*
2152
* set_to_ipynb - set from ipynb object. This is
2153
* mainly meant to be run on the backend in the project,
2154
* but is also run on the frontend too, e.g.,
2155
* for client-side nbviewer (in which case it won't remove images, etc.).
2156
*
2157
* See the documentation for load_ipynb_file in project-actions.ts for
2158
* documentation about the data_only input variable.
2159
*/
2160
if (typeof ipynb != "object") {
2161
throw Error("ipynb must be an object");
2162
}
2163
2164
this._state = "load";
2165
2166
//dbg(misc.to_json(ipynb))
2167
2168
// We try to parse out the kernel so we can use process_output below.
2169
// (TODO: rewrite so process_output is not associated with a specific kernel)
2170
let kernel: string | undefined;
2171
const ipynb_metadata = ipynb.metadata;
2172
if (ipynb_metadata != null) {
2173
const kernelspec = ipynb_metadata.kernelspec;
2174
if (kernelspec != null) {
2175
kernel = kernelspec.name;
2176
}
2177
}
2178
//dbg("kernel in ipynb: name='#{kernel}'")
2179
2180
const existing_ids = this.store.get_cell_list().toJS();
2181
2182
let set, trust;
2183
if (data_only) {
2184
trust = undefined;
2185
set = function () {};
2186
} else {
2187
if (typeof this.reset_more_output === "function") {
2188
this.reset_more_output();
2189
// clear the more output handler (only on backend)
2190
}
2191
// We delete all of the cells.
2192
// We do NOT delete everything, namely the last_loaded and
2193
// the settings entry in the database, because that would
2194
// throw away important information, e.g., the current kernel
2195
// and its state. NOTe: Some of that extra info *should* be
2196
// moved to a different ephemeral table, but I haven't got
2197
// around to doing so.
2198
this.syncdb.delete({ type: "cell" });
2199
// preserve trust state across file updates/loads
2200
trust = this.store.get("trust");
2201
set = (obj) => {
2202
this.syncdb.set(obj);
2203
};
2204
}
2205
2206
// Change kernel to what is in the file if necessary:
2207
set({ type: "settings", kernel });
2208
this.ensure_backend_kernel_setup();
2209
2210
const importer = new IPynbImporter();
2211
2212
// NOTE: Below we re-use any existing ids to make the patch that defines changing
2213
// to the contents of ipynb more efficient. In case of a very slight change
2214
// on disk, this can be massively more efficient.
2215
2216
importer.import({
2217
ipynb,
2218
existing_ids,
2219
new_id: this.new_id.bind(this),
2220
process_attachment:
2221
this.jupyter_kernel != null
2222
? this.jupyter_kernel.process_attachment.bind(this.jupyter_kernel)
2223
: undefined,
2224
output_handler:
2225
this.jupyter_kernel != null
2226
? this._output_handler.bind(this)
2227
: undefined, // undefined in client; defined in project
2228
});
2229
2230
if (data_only) {
2231
importer.close();
2232
return;
2233
}
2234
2235
// Set all the cells
2236
const object = importer.cells();
2237
for (const _ in object) {
2238
const cell = object[_];
2239
set(cell);
2240
}
2241
2242
// Set the settings
2243
set({ type: "settings", kernel: importer.kernel(), trust });
2244
2245
// Set extra user-defined metadata
2246
const metadata = importer.metadata();
2247
if (metadata != null) {
2248
set({ type: "settings", metadata });
2249
}
2250
2251
importer.close();
2252
2253
this.syncdb.commit();
2254
await this.syncdb.save();
2255
this.ensure_backend_kernel_setup();
2256
this._state = "ready";
2257
}
2258
2259
public set_cell_slide(id: string, value: any): void {
2260
if (!value) {
2261
value = null; // delete
2262
}
2263
if (this.check_edit_protection(id, "making a cell aslide")) {
2264
return;
2265
}
2266
this._set({
2267
type: "cell",
2268
id,
2269
slide: value,
2270
});
2271
}
2272
2273
public ensure_positions_are_unique(): void {
2274
if (this._state != "ready" || this.store == null) {
2275
// because of debouncing, this ensure_positions_are_unique can
2276
// be called after jupyter actions are closed.
2277
return;
2278
}
2279
const changes = cell_utils.ensure_positions_are_unique(
2280
this.store.get("cells"),
2281
);
2282
if (changes != null) {
2283
for (const id in changes) {
2284
const pos = changes[id];
2285
this.set_cell_pos(id, pos, false);
2286
}
2287
}
2288
this._sync();
2289
}
2290
2291
public set_default_kernel(kernel?: string): void {
2292
if (kernel == null || kernel === "") return;
2293
// doesn't make sense for project (right now at least)
2294
if (this.is_project || this.is_compute_server) return;
2295
const account_store = this.redux.getStore("account") as any;
2296
if (account_store == null) return;
2297
const cur: any = {};
2298
// if available, retain existing jupyter config
2299
const acc_jup = account_store.getIn(["editor_settings", "jupyter"]);
2300
if (acc_jup != null) {
2301
Object.assign(cur, acc_jup.toJS());
2302
}
2303
// set new kernel and save it
2304
cur.kernel = kernel;
2305
(this.redux.getTable("account") as any).set({
2306
editor_settings: { jupyter: cur },
2307
});
2308
}
2309
2310
edit_attachments = (id: string): void => {
2311
this.setState({ edit_attachments: id });
2312
};
2313
2314
_attachment_markdown = (name: any) => {
2315
return `![${name}](attachment:${name})`;
2316
// Don't use this because official Jupyter tooling can't deal with it. See
2317
// https://github.com/sagemathinc/cocalc/issues/5055
2318
return `<img src="attachment:${name}" style="max-width:100%">`;
2319
};
2320
2321
insert_input_at_cursor = (id: string, s: string, save: boolean = true) => {
2322
// TODO: this maybe doesn't make sense anymore...
2323
// TODO: redo this -- note that the input below is wrong, since it is
2324
// from the store, not necessarily from what is live in the cell.
2325
2326
if (this.store.getIn(["cells", id]) == null) {
2327
return;
2328
}
2329
if (this.check_edit_protection(id, "inserting input")) {
2330
return;
2331
}
2332
let input = this.store.getIn(["cells", id, "input"], "");
2333
const cursor = this._cursor_locs != null ? this._cursor_locs[0] : undefined;
2334
if ((cursor != null ? cursor.id : undefined) === id) {
2335
const v = input.split("\n");
2336
const line = v[cursor.y];
2337
v[cursor.y] = line.slice(0, cursor.x) + s + line.slice(cursor.x);
2338
input = v.join("\n");
2339
} else {
2340
input += s;
2341
}
2342
return this._set({ type: "cell", id, input }, save);
2343
};
2344
2345
// Sets attachments[name] = val
2346
public set_cell_attachment(
2347
id: string,
2348
name: string,
2349
val: any,
2350
save: boolean = true,
2351
): void {
2352
const cell = this.store.getIn(["cells", id]);
2353
if (cell == null) {
2354
throw Error(`no cell ${id}`);
2355
}
2356
if (this.check_edit_protection(id, "setting an attachment")) return;
2357
const attachments = cell.get("attachments", immutable.Map()).toJS();
2358
attachments[name] = val;
2359
this._set(
2360
{
2361
type: "cell",
2362
id,
2363
attachments,
2364
},
2365
save,
2366
);
2367
}
2368
2369
public async add_attachment_to_cell(id: string, path: string): Promise<void> {
2370
if (this.check_edit_protection(id, "adding an attachment")) {
2371
return;
2372
}
2373
let name: string = encodeURIComponent(
2374
misc.path_split(path).tail.toLowerCase(),
2375
);
2376
name = name.replace(/\(/g, "%28").replace(/\)/g, "%29");
2377
this.set_cell_attachment(id, name, { type: "load", value: path });
2378
await callback2(this.store.wait, {
2379
until: () =>
2380
this.store.getIn(["cells", id, "attachments", name, "type"]) === "sha1",
2381
timeout: 0,
2382
});
2383
// This has to happen in the next render loop, since changing immediately
2384
// can update before the attachments props are updated.
2385
await delay(10);
2386
this.insert_input_at_cursor(id, this._attachment_markdown(name), true);
2387
}
2388
2389
delete_attachment_from_cell = (id: string, name: any) => {
2390
if (this.check_edit_protection(id, "deleting an attachment")) {
2391
return;
2392
}
2393
this.set_cell_attachment(id, name, null, false);
2394
this.set_cell_input(
2395
id,
2396
misc.replace_all(
2397
this._get_cell_input(id),
2398
this._attachment_markdown(name),
2399
"",
2400
),
2401
);
2402
};
2403
2404
add_tag(id: string, tag: string, save: boolean = true): void {
2405
if (this.check_edit_protection(id, "adding a tag")) {
2406
return;
2407
}
2408
return this._set(
2409
{
2410
type: "cell",
2411
id,
2412
tags: { [tag]: true },
2413
},
2414
save,
2415
);
2416
}
2417
2418
remove_tag(id: string, tag: string, save: boolean = true): void {
2419
if (this.check_edit_protection(id, "removing a tag")) {
2420
return;
2421
}
2422
return this._set(
2423
{
2424
type: "cell",
2425
id,
2426
tags: { [tag]: null },
2427
},
2428
save,
2429
);
2430
}
2431
2432
toggle_tag(id: string, tag: string, save: boolean = true): void {
2433
const cell = this.store.getIn(["cells", id]);
2434
if (cell == null) {
2435
throw Error(`no cell with id ${id}`);
2436
}
2437
const tags = cell.get("tags");
2438
if (tags == null || !tags.get(tag)) {
2439
this.add_tag(id, tag, save);
2440
} else {
2441
this.remove_tag(id, tag, save);
2442
}
2443
}
2444
2445
edit_cell_metadata = (id: string): void => {
2446
const metadata = this.store.getIn(
2447
["cells", id, "metadata"],
2448
immutable.Map(),
2449
);
2450
this.setState({ edit_cell_metadata: { id, metadata } });
2451
};
2452
2453
public set_global_metadata(metadata: object, save: boolean = true): void {
2454
const cur = this.syncdb.get_one({ type: "settings" })?.toJS()?.metadata;
2455
if (cur) {
2456
metadata = {
2457
...cur,
2458
...metadata,
2459
};
2460
}
2461
this.syncdb.set({ type: "settings", metadata });
2462
if (save) {
2463
this.syncdb.commit();
2464
}
2465
}
2466
2467
public set_cell_metadata(opts: {
2468
id: string;
2469
metadata?: object; // not given = delete it
2470
save?: boolean; // defaults to true if not given
2471
merge?: boolean; // defaults to false if not given, in which case sets metadata, rather than merge. If true, does a SHALLOW merge.
2472
bypass_edit_protection?: boolean;
2473
}): void {
2474
let { id, metadata, save, merge, bypass_edit_protection } = (opts =
2475
defaults(opts, {
2476
id: required,
2477
metadata: required,
2478
save: true,
2479
merge: false,
2480
bypass_edit_protection: false,
2481
}));
2482
2483
if (
2484
!bypass_edit_protection &&
2485
this.check_edit_protection(id, "editing cell metadata")
2486
) {
2487
return;
2488
}
2489
// Special case: delete metdata (unconditionally)
2490
if (metadata == null || misc.len(metadata) === 0) {
2491
this._set(
2492
{
2493
type: "cell",
2494
id,
2495
metadata: null,
2496
},
2497
save,
2498
);
2499
return;
2500
}
2501
2502
if (merge) {
2503
const current = this.store.getIn(
2504
["cells", id, "metadata"],
2505
immutable.Map(),
2506
);
2507
metadata = current.merge(immutable.fromJS(metadata)).toJS();
2508
}
2509
2510
// special fields
2511
// "collapsed", "scrolled", "slideshow", and "tags"
2512
if (metadata.tags != null) {
2513
for (const tag of metadata.tags) {
2514
this.add_tag(id, tag, false);
2515
}
2516
delete metadata.tags;
2517
}
2518
// important to not store redundant inconsistent fields:
2519
for (const field of ["collapsed", "scrolled", "slideshow"]) {
2520
if (metadata[field] != null) {
2521
delete metadata[field];
2522
}
2523
}
2524
2525
if (!merge) {
2526
// first delete -- we have to do this due to shortcomings in syncdb, but it
2527
// can have annoying side effects on the UI
2528
this._set(
2529
{
2530
type: "cell",
2531
id,
2532
metadata: null,
2533
},
2534
false,
2535
);
2536
}
2537
// now set
2538
this._set(
2539
{
2540
type: "cell",
2541
id,
2542
metadata,
2543
},
2544
save,
2545
);
2546
if (this.store.getIn(["edit_cell_metadata", "id"]) === id) {
2547
this.edit_cell_metadata(id); // updates the state while editing
2548
}
2549
}
2550
2551
public set_raw_ipynb(): void {
2552
if (this._state != "ready") {
2553
// lies otherwise...
2554
return;
2555
}
2556
2557
this.setState({
2558
raw_ipynb: immutable.fromJS(this.store.get_ipynb()),
2559
});
2560
}
2561
2562
protected check_select_kernel(): void {
2563
const kernel = this.store?.get("kernel");
2564
if (kernel == null) return;
2565
let unknown_kernel = false;
2566
if (kernel === "") {
2567
unknown_kernel = false; // it's the "no kernel" kernel
2568
} else if (this.store.get("kernels") != null) {
2569
unknown_kernel = this.store.get_kernel_info(kernel) == null;
2570
}
2571
2572
// a kernel is set, but we don't know it
2573
if (unknown_kernel) {
2574
this.show_select_kernel("bad kernel");
2575
} else {
2576
// we got a kernel, close dialog if not requested by user
2577
if (
2578
this.store.get("show_kernel_selector") &&
2579
this.store.get("show_kernel_selector_reason") === "bad kernel"
2580
) {
2581
this.hide_select_kernel();
2582
}
2583
}
2584
2585
// also in the case when the kernel is "" we have to set this to true
2586
this.setState({ check_select_kernel_init: true });
2587
}
2588
2589
async update_select_kernel_data(): Promise<void> {
2590
if (this.store == null) return;
2591
const kernels = jupyter_kernels.get(await this.store.jupyter_kernel_key());
2592
if (kernels == null) return;
2593
const kernel_selection = this.store.get_kernel_selection(kernels);
2594
const [kernels_by_name, kernels_by_language] =
2595
get_kernels_by_name_or_language(kernels);
2596
const default_kernel = this.store.get_default_kernel();
2597
// do we have a similar kernel?
2598
let closestKernel: Kernel | undefined = undefined;
2599
const kernel = this.store.get("kernel");
2600
const kernel_info = this.store.get_kernel_info(kernel);
2601
// unknown kernel, we try to find a close match
2602
if (kernel_info == null && kernel != null && kernel !== "") {
2603
// kernel & kernels must be defined
2604
closestKernel = misc.closest_kernel_match(kernel, kernels as any) as any;
2605
// TODO about that any above: closest_kernel_match should be moved here so it knows the typings
2606
}
2607
this.setState({
2608
kernel_selection,
2609
kernels_by_name,
2610
kernels_by_language,
2611
default_kernel,
2612
closestKernel,
2613
});
2614
}
2615
2616
set_mode(mode: "escape" | "edit"): void {
2617
this.deprecated("set_mode", mode);
2618
}
2619
2620
public focus(wait?: boolean): void {
2621
this.deprecated("focus", wait);
2622
}
2623
2624
public blur(): void {
2625
this.deprecated("blur");
2626
}
2627
2628
async show_select_kernel(
2629
reason: show_kernel_selector_reasons,
2630
): Promise<void> {
2631
await this.update_select_kernel_data();
2632
// we might not have the "kernels" data yet (but we will, once fetching it is complete)
2633
// the select dialog will show a loading spinner
2634
this.setState({
2635
show_kernel_selector_reason: reason,
2636
show_kernel_selector: true,
2637
});
2638
}
2639
2640
hide_select_kernel = (): void => {
2641
this.setState({
2642
show_kernel_selector_reason: undefined,
2643
show_kernel_selector: false,
2644
});
2645
};
2646
2647
select_kernel = (kernel_name: string | null): void => {
2648
this.set_kernel(kernel_name);
2649
if (kernel_name != null && kernel_name !== "") {
2650
this.set_default_kernel(kernel_name);
2651
}
2652
this.focus(true);
2653
this.hide_select_kernel();
2654
};
2655
2656
kernel_dont_ask_again = (dont_ask: boolean): void => {
2657
// why is "as any" necessary?
2658
const account_table = this.redux.getTable("account") as any;
2659
account_table.set({
2660
editor_settings: { ask_jupyter_kernel: !dont_ask },
2661
});
2662
};
2663
2664
public check_edit_protection(id: string, reason?: string): boolean {
2665
if (!this.store.is_cell_editable(id)) {
2666
this.show_not_editable_error(reason);
2667
return true;
2668
} else {
2669
return false;
2670
}
2671
}
2672
2673
public check_delete_protection(id: string): boolean {
2674
if (!this.store.is_cell_deletable(id)) {
2675
this.show_not_deletable_error();
2676
return true;
2677
} else {
2678
return false;
2679
}
2680
}
2681
2682
split_current_cell = () => {
2683
this.deprecated("split_current_cell");
2684
};
2685
2686
handle_nbconvert_change(_oldVal, _newVal): void {
2687
throw Error("define this in derived class");
2688
}
2689
2690
// Return id of ACTIVE remote compute server, if one is connected, or 0
2691
// if none is connected.
2692
getComputeServerId = (): number => {
2693
return this.syncdb.getComputeServerId();
2694
};
2695
2696
protected isCellRunner = (): boolean => {
2697
return false;
2698
};
2699
2700
set_kernel_error = (err) => {
2701
// anybody can *clear* error, but only cell runner can set it, since
2702
// only they should know.
2703
if (err && !this.isCellRunner()) {
2704
return;
2705
}
2706
this._set({
2707
type: "settings",
2708
kernel_error: `${err}`,
2709
});
2710
this.save_asap();
2711
};
2712
2713
// Returns true if the .ipynb file was explicitly deleted.
2714
// Returns false if it is NOT known to be explicitly deleted.
2715
// Returns undefined if not known or implemented.
2716
// NOTE: this is different than the file not being present on disk.
2717
protected isDeleted = () => {
2718
if (this.store == null || this._client == null) {
2719
return;
2720
}
2721
return this._client.is_deleted?.(this.store.get("path"), this.project_id);
2722
// [ ] TODO: we also need to do this on compute servers, but
2723
// they don't yet have the listings table.
2724
};
2725
2726
processRenderedMarkdown = ({ value, id }: { value: string; id: string }) => {
2727
value = latexEnvs(value);
2728
2729
const labelRegExp = /\s*\\label\{.*?\}\s*/g;
2730
const figLabelRegExp = /\s*\\figlabel\{.*?\}\s*/g;
2731
if (this.labels == null) {
2732
const labels = (this.labels = { math: {}, fig: {} });
2733
// do initial full document scan
2734
if (this.store == null) {
2735
return;
2736
}
2737
const cells = this.store.get("cells");
2738
if (cells == null) {
2739
return;
2740
}
2741
let mathN = 0;
2742
let figN = 0;
2743
for (const id of this.store.get_cell_ids_list()) {
2744
const cell = cells.get(id);
2745
if (cell?.get("cell_type") == "markdown") {
2746
const value = latexEnvs(cell.get("input") ?? "");
2747
value.replace(labelRegExp, (labelContent) => {
2748
const label = extractLabel(labelContent);
2749
mathN += 1;
2750
labels.math[label] = { tag: `${mathN}`, id };
2751
return "";
2752
});
2753
value.replace(figLabelRegExp, (labelContent) => {
2754
const label = extractLabel(labelContent);
2755
figN += 1;
2756
labels.fig[label] = { tag: `${figN}`, id };
2757
return "";
2758
});
2759
}
2760
}
2761
}
2762
const labels = this.labels;
2763
if (labels == null) {
2764
throw Error("bug");
2765
}
2766
value = value.replace(labelRegExp, (labelContent) => {
2767
const label = extractLabel(labelContent);
2768
if (labels.math[label] == null) {
2769
labels.math[label] = { tag: `${misc.len(labels.math) + 1}`, id };
2770
} else {
2771
// in case it moved to a different cell due to cut/paste
2772
labels.math[label].id = id;
2773
}
2774
return `\\tag{${labels.math[label].tag}}`;
2775
});
2776
value = value.replace(figLabelRegExp, (labelContent) => {
2777
const label = extractLabel(labelContent);
2778
if (labels.fig[label] == null) {
2779
labels.fig[label] = { tag: `${misc.len(labels.fig) + 1}`, id };
2780
} else {
2781
// in case it moved to a different cell due to cut/paste
2782
labels.fig[label].id = id;
2783
}
2784
return ` ${labels.fig[label].tag ?? "?"}`;
2785
});
2786
const refRegExp = /\\ref\{.*?\}/g;
2787
value = value.replace(refRegExp, (refContent) => {
2788
const label = extractLabel(refContent);
2789
if (labels.fig[label] == null && labels.math[label] == null) {
2790
// do not know the label
2791
return "?";
2792
}
2793
const { tag, id } = labels.fig[label] ?? labels.math[label];
2794
return `[${tag}](#id=${id})`;
2795
});
2796
2797
return value;
2798
};
2799
2800
// Update run progress, which is a number between 0 and 100,
2801
// giving the number of runnable cells that have been run since
2802
// the kernel was last set to the running state.
2803
// Currently only run in the browser, but could maybe be useful
2804
// elsewhere someday.
2805
updateRunProgress = () => {
2806
if (this.store == null) {
2807
return;
2808
}
2809
if (this.store.get("backend_state") != "running") {
2810
this.setState({ runProgress: 0 });
2811
return;
2812
}
2813
const cells = this.store.get("cells");
2814
if (cells == null) {
2815
return;
2816
}
2817
const last = this.store.get("last_backend_state");
2818
if (last == null) {
2819
// not supported yet, e.g., old backend, kernel never started
2820
return;
2821
}
2822
// count of number of cells that are runnable and
2823
// have start greater than last, and end set...
2824
// count a currently running cell as 0.5.
2825
let total = 0;
2826
let ran = 0;
2827
for (const [_, cell] of cells) {
2828
if (
2829
cell.get("cell_type", "code") != "code" ||
2830
!cell.get("input")?.trim()
2831
) {
2832
// not runnable
2833
continue;
2834
}
2835
total += 1;
2836
if ((cell.get("start") ?? 0) >= last) {
2837
if (cell.get("end")) {
2838
ran += 1;
2839
} else {
2840
ran += 0.5;
2841
}
2842
}
2843
}
2844
this.setState({ runProgress: total > 0 ? (100 * ran) / total : 100 });
2845
};
2846
}
2847
2848
function extractLabel(content: string): string {
2849
const i = content.indexOf("{");
2850
const j = content.lastIndexOf("}");
2851
return content.slice(i + 1, j);
2852
}
2853
2854
function bounded_integer(n: any, min: any, max: any, def: any) {
2855
if (typeof n !== "number") {
2856
n = parseInt(n);
2857
}
2858
if (isNaN(n)) {
2859
return def;
2860
}
2861
n = Math.round(n);
2862
if (n < min) {
2863
return min;
2864
}
2865
if (n > max) {
2866
return max;
2867
}
2868
return n;
2869
}
2870
2871
function getCompletionGroup(x: string): number {
2872
switch (x[0]) {
2873
case "_":
2874
return 1;
2875
case "%":
2876
return 2;
2877
default:
2878
return 0;
2879
}
2880
}
2881
2882