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/frontend/app/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
import { Actions, redux } from "@cocalc/frontend/app-framework";
7
import { set_window_title } from "@cocalc/frontend/browser";
8
import { set_url, update_params } from "@cocalc/frontend/history";
9
import { getIntl, labels } from "@cocalc/frontend/i18n";
10
import {
11
exitFullscreen,
12
isFullscreen,
13
requestFullscreen,
14
} from "@cocalc/frontend/misc/fullscreen";
15
import { disconnect_from_project } from "@cocalc/frontend/project/websocket/connect";
16
import { session_manager } from "@cocalc/frontend/session";
17
import { once } from "@cocalc/util/async-utils";
18
import { PageState } from "./store";
19
20
export class PageActions extends Actions<PageState> {
21
private session_manager?: any;
22
private active_key_handler?: any;
23
private suppress_key_handlers: boolean = false;
24
private popconfirmIsOpen: boolean = false;
25
private settingsModalIsOpen: boolean = false;
26
27
/* Expects a func which takes a browser keydown event
28
Only allows one keyhandler to be active at a time.
29
FUTURE: Develop more general way to make key mappings for editors
30
HACK: __suppress_key_handlers is for file_use. See FUTURE above.
31
Adding even a single suppressor leads to spaghetti code.
32
Don't do it. -- J3
33
34
ws: added logic with project_id/path so that
35
only the currently focused editor can set/unset
36
the keyboard handler -- see https://github.com/sagemathinc/cocalc/issues/2826
37
This feels a bit brittle though, but obviously something like this is needed,
38
due to slightly async calls to set_active_key_handler, and expecting editors
39
to do this is silly.
40
*/
41
public set_active_key_handler(
42
handler?: (e) => void,
43
project_id?: string,
44
path?: string, // IMPORTANT: This is the path for the tab! E.g., if setting keyboard handler for a frame, make sure to pass path for the tab. This is a terrible and confusing design and needs to be redone, probably via a hook!
45
): void {
46
if (project_id != null) {
47
if (
48
this.redux.getStore("page").get("active_top_tab") !== project_id ||
49
this.redux.getProjectStore(project_id)?.get("active_project_tab") !==
50
"editor-" + path
51
) {
52
return;
53
}
54
}
55
56
if (handler != null) {
57
$(window).off("keydown", this.active_key_handler);
58
this.active_key_handler = handler;
59
}
60
61
if (this.active_key_handler != null && !this.suppress_key_handlers) {
62
$(window).on("keydown", this.active_key_handler);
63
}
64
}
65
66
// Only clears it from the window
67
public unattach_active_key_handler() {
68
$(window).off("keydown", this.active_key_handler);
69
}
70
71
// Actually removes the handler from active memory
72
// takes a handler to only remove if it's the active one
73
public erase_active_key_handler(handler?) {
74
if (handler == null || handler === this.active_key_handler) {
75
$(window).off("keydown", this.active_key_handler);
76
this.active_key_handler = undefined;
77
}
78
}
79
80
// FUTURE: Will also clear all click handlers.
81
// Right now there aren't even any ways (other than manually)
82
// of adding click handlers that the app knows about.
83
public clear_all_handlers() {
84
$(window).off("keydown", this.active_key_handler);
85
this.active_key_handler = undefined;
86
}
87
88
private add_a_ghost_tab(): void {
89
const current_num = redux.getStore("page").get("num_ghost_tabs");
90
this.setState({ num_ghost_tabs: current_num + 1 });
91
}
92
93
public clear_ghost_tabs(): void {
94
this.setState({ num_ghost_tabs: 0 });
95
}
96
97
public close_project_tab(project_id: string): void {
98
const page_store = redux.getStore("page");
99
const projects_store = redux.getStore("projects");
100
101
const open_projects = projects_store.get("open_projects");
102
const active_top_tab = page_store.get("active_top_tab");
103
104
const index = open_projects.indexOf(project_id);
105
if (index === -1) {
106
return;
107
}
108
109
if (this.session_manager != null) {
110
this.session_manager.close_project(project_id);
111
} // remembers what files are open
112
113
const { size } = open_projects;
114
if (project_id === active_top_tab) {
115
let next_active_tab;
116
if (index === -1 || size <= 1) {
117
next_active_tab = "projects";
118
} else if (index === size - 1) {
119
next_active_tab = open_projects.get(index - 1);
120
} else {
121
next_active_tab = open_projects.get(index + 1);
122
}
123
this.set_active_tab(next_active_tab);
124
}
125
126
// The point of these "ghost tabs" is to make it so you can quickly close several
127
// open tabs, like in Chrome.
128
if (index === size - 1) {
129
this.clear_ghost_tabs();
130
} else {
131
this.add_a_ghost_tab();
132
}
133
134
redux.getActions("projects").set_project_closed(project_id);
135
this.save_session();
136
137
// if there happens to be a websocket to this project, get rid of it.
138
// Nothing will be using it when the project is closed.
139
disconnect_from_project(project_id);
140
}
141
142
async set_active_tab(key, change_history = true): Promise<void> {
143
const prev_key = this.redux.getStore("page").get("active_top_tab");
144
this.setState({ active_top_tab: key });
145
146
if (prev_key !== key && prev_key?.length == 36) {
147
// fire hide action on project we are switching from.
148
redux.getProjectActions(prev_key)?.hide();
149
}
150
if (key?.length == 36) {
151
// fire show action on project we are switching to
152
redux.getProjectActions(key)?.show();
153
}
154
155
const intl = await getIntl();
156
157
switch (key) {
158
case "projects":
159
if (change_history) {
160
set_url("/projects");
161
}
162
set_window_title(intl.formatMessage(labels.projects));
163
return;
164
case "account":
165
if (change_history) {
166
redux.getActions("account").push_state();
167
}
168
set_window_title(intl.formatMessage(labels.account));
169
return;
170
case "file-use": // this doesn't actually get used currently
171
if (change_history) {
172
set_url("/file-use");
173
}
174
set_window_title("File Usage");
175
return;
176
case "admin":
177
if (change_history) {
178
set_url("/admin");
179
}
180
set_window_title(intl.formatMessage(labels.admin));
181
return;
182
case "notifications":
183
if (change_history) {
184
set_url("/notifications");
185
}
186
set_window_title(intl.formatMessage(labels.notifications));
187
return;
188
case undefined:
189
return;
190
default:
191
if (change_history) {
192
redux.getProjectActions(key)?.push_state();
193
}
194
set_window_title("Loading Project");
195
var projects_store = redux.getStore("projects");
196
197
if (projects_store.date_when_course_payment_required(key)) {
198
redux
199
.getActions("projects")
200
.apply_default_upgrades({ project_id: key });
201
}
202
203
try {
204
const title: string = await projects_store.async_wait({
205
until: (store): string | undefined => {
206
let title: string | undefined = store.getIn([
207
"project_map",
208
key,
209
"title",
210
]);
211
if (title == null) {
212
title = store.getIn(["public_project_titles", key]);
213
}
214
if (title === "") {
215
return "Untitled Project";
216
}
217
if (title == null) {
218
redux.getActions("projects").fetch_public_project_title(key);
219
}
220
return title;
221
},
222
timeout: 15,
223
});
224
set_window_title(title);
225
} catch (err) {
226
set_window_title("");
227
}
228
}
229
}
230
231
show_connection(show_connection) {
232
this.setState({ show_connection });
233
}
234
235
// Suppress the activation of any new key handlers
236
disableGlobalKeyHandler = () => {
237
this.suppress_key_handlers = true;
238
this.unattach_active_key_handler();
239
};
240
// Enable whatever the current key handler should be
241
enableGlobalKeyHandler = () => {
242
this.suppress_key_handlers = false;
243
this.set_active_key_handler();
244
};
245
246
// Toggles visibility of file use widget
247
// Temporarily disables window key handlers until closed
248
// FUTURE: Develop more general way to make key mappings
249
toggle_show_file_use() {
250
const currently_shown = redux.getStore("page").get("show_file_use");
251
if (currently_shown) {
252
this.enableGlobalKeyHandler(); // HACK: Terrible way to do this.
253
} else {
254
// Suppress the activation of any new key handlers until file_use closes
255
this.disableGlobalKeyHandler(); // HACK: Terrible way to do this.
256
}
257
258
this.setState({ show_file_use: !currently_shown });
259
}
260
261
set_ping(ping, avgping) {
262
this.setState({ ping, avgping });
263
}
264
265
set_connection_status(connection_status, time: Date) {
266
if (time > (redux.getStore("page").get("last_status_time") ?? 0)) {
267
this.setState({ connection_status, last_status_time: time });
268
}
269
}
270
271
set_connection_quality(connection_quality) {
272
this.setState({ connection_quality });
273
}
274
275
set_new_version(new_version) {
276
this.setState({ new_version });
277
}
278
279
async set_fullscreen(
280
fullscreen?: "default" | "kiosk" | "project" | undefined,
281
) {
282
// val = 'default', 'kiosk', 'project', undefined
283
// if kiosk is ever set, disable toggling back
284
if (redux.getStore("page").get("fullscreen") === "kiosk") {
285
return;
286
}
287
this.setState({ fullscreen });
288
if (fullscreen == "project") {
289
// this removes top row for embedding purposes and thus doesn't need
290
// full browser fullscreen.
291
return;
292
}
293
if (fullscreen) {
294
try {
295
await requestFullscreen();
296
} catch (err) {
297
// gives an error if not initiated explicitly by user action,
298
// or not available (e.g., iphone)
299
console.log(err);
300
}
301
} else {
302
if (isFullscreen()) {
303
exitFullscreen();
304
}
305
}
306
}
307
308
set_get_api_key(val) {
309
this.setState({ get_api_key: val });
310
update_params();
311
}
312
313
toggle_fullscreen() {
314
this.set_fullscreen(
315
redux.getStore("page").get("fullscreen") != null ? undefined : "default",
316
);
317
}
318
319
set_session(session) {
320
// If existing different session, close it.
321
if (session !== redux.getStore("page").get("session")) {
322
if (this.session_manager != null) {
323
this.session_manager.close();
324
}
325
delete this.session_manager;
326
}
327
328
// Save state and update URL.
329
this.setState({ session });
330
331
// Make new session manager, but only register it if we have
332
// an actual session name!
333
if (!this.session_manager) {
334
const sm = session_manager(session, redux);
335
if (session) {
336
this.session_manager = sm;
337
}
338
}
339
}
340
341
save_session() {
342
this.session_manager?.save();
343
}
344
345
restore_session(project_id) {
346
this.session_manager?.restore(project_id);
347
}
348
349
show_cookie_warning() {
350
this.setState({ cookie_warning: true });
351
}
352
353
show_local_storage_warning() {
354
this.setState({ local_storage_warning: true });
355
}
356
357
check_unload(_) {
358
if (redux.getStore("page").get("get_api_key")) {
359
// never confirm close if get_api_key is set.
360
return;
361
}
362
const fullscreen = redux.getStore("page").get("fullscreen");
363
if (fullscreen == "kiosk" || fullscreen == "project") {
364
// never confirm close in kiosk or project embed mode, since that should be
365
// responsibility of containing page, and it's confusing where
366
// the dialog is even coming from.
367
return;
368
}
369
// Returns a defined string if the user should confirm exiting the site.
370
const s = redux.getStore("account");
371
if (
372
(s != null ? s.get_user_type() : undefined) === "signed_in" &&
373
(s != null ? s.get_confirm_close() : undefined)
374
) {
375
return "Changes you make may not have been saved.";
376
} else {
377
return;
378
}
379
}
380
381
set_sign_in_func(func) {
382
this.sign_in = func;
383
}
384
385
remove_sign_in_func() {
386
this.sign_in = () => false;
387
}
388
389
// Expected to be overridden by functions above
390
sign_in() {
391
return false;
392
}
393
394
// The code below is complicated and tricky because multiple parts of our codebase could
395
// call it at the "same time". This happens, e.g., when opening several Jupyter notebooks
396
// on a compute server from the terminal using the open command.
397
// By "same time", I mean a second call to popconfirm comes in while the first is async
398
// awaiting to finish. We handle that below by locking while waiting. Since only one
399
// thing actually happens at a time in Javascript, the below should always work with
400
// no deadlocks. It's tricky looking code, but MUCH simpler than alternatives I considered.
401
popconfirm = async (opts): Promise<boolean> => {
402
const store = redux.getStore("page");
403
// wait for any currently open modal to be done.
404
while (this.popconfirmIsOpen) {
405
await once(store, "change");
406
}
407
// we got it, so let's take the lock
408
try {
409
this.popconfirmIsOpen = true;
410
// now we do it -- this causes the modal to appear
411
this.setState({ popconfirm: { open: true, ...opts } });
412
// wait for our to be done
413
while (store.getIn(["popconfirm", "open"])) {
414
await once(store, "change");
415
}
416
// report result of ours.
417
return !!store.getIn(["popconfirm", "ok"]);
418
} finally {
419
// give up the lock
420
this.popconfirmIsOpen = false;
421
// trigger a change, so other code has a chance to get the lock
422
this.setState({ popconfirm: { open: false } });
423
}
424
};
425
426
settings = async (name) => {
427
if (!name) {
428
this.setState({ settingsModal: "" });
429
this.settingsModalIsOpen = false;
430
return;
431
}
432
const store = redux.getStore("page");
433
while (this.settingsModalIsOpen) {
434
await once(store, "change");
435
}
436
try {
437
this.settingsModalIsOpen = true;
438
this.setState({ settingsModal: name });
439
while (store.get("settingsModal")) {
440
await once(store, "change");
441
}
442
} finally {
443
this.settingsModalIsOpen = false;
444
}
445
};
446
}
447
448
export function init_actions() {
449
redux.createActions("page", PageActions);
450
}
451
452