Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/app/actions.ts
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Actions, redux } from "@cocalc/frontend/app-framework";6import { set_window_title } from "@cocalc/frontend/browser";7import { set_url, update_params } from "@cocalc/frontend/history";8import { getIntl, labels } from "@cocalc/frontend/i18n";9import {10exitFullscreen,11isFullscreen,12requestFullscreen,13} from "@cocalc/frontend/misc/fullscreen";14import { disconnect_from_project } from "@cocalc/frontend/project/websocket/connect";15import { session_manager } from "@cocalc/frontend/session";16import { once } from "@cocalc/util/async-utils";17import { PageState } from "./store";1819export class PageActions extends Actions<PageState> {20private session_manager?: any;21private active_key_handler?: any;22private suppress_key_handlers: boolean = false;23private popconfirmIsOpen: boolean = false;24private settingsModalIsOpen: boolean = false;2526/* Expects a func which takes a browser keydown event27Only allows one keyhandler to be active at a time.28FUTURE: Develop more general way to make key mappings for editors29HACK: __suppress_key_handlers is for file_use. See FUTURE above.30Adding even a single suppressor leads to spaghetti code.31Don't do it. -- J33233ws: added logic with project_id/path so that34only the currently focused editor can set/unset35the keyboard handler -- see https://github.com/sagemathinc/cocalc/issues/282636This feels a bit brittle though, but obviously something like this is needed,37due to slightly async calls to set_active_key_handler, and expecting editors38to do this is silly.39*/40public set_active_key_handler(41handler?: (e) => void,42project_id?: string,43path?: 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!44): void {45if (project_id != null) {46if (47this.redux.getStore("page").get("active_top_tab") !== project_id ||48this.redux.getProjectStore(project_id)?.get("active_project_tab") !==49"editor-" + path50) {51return;52}53}5455if (handler != null) {56$(window).off("keydown", this.active_key_handler);57this.active_key_handler = handler;58}5960if (this.active_key_handler != null && !this.suppress_key_handlers) {61$(window).on("keydown", this.active_key_handler);62}63}6465// Only clears it from the window66public unattach_active_key_handler() {67$(window).off("keydown", this.active_key_handler);68}6970// Actually removes the handler from active memory71// takes a handler to only remove if it's the active one72public erase_active_key_handler(handler?) {73if (handler == null || handler === this.active_key_handler) {74$(window).off("keydown", this.active_key_handler);75this.active_key_handler = undefined;76}77}7879// FUTURE: Will also clear all click handlers.80// Right now there aren't even any ways (other than manually)81// of adding click handlers that the app knows about.82public clear_all_handlers() {83$(window).off("keydown", this.active_key_handler);84this.active_key_handler = undefined;85}8687private add_a_ghost_tab(): void {88const current_num = redux.getStore("page").get("num_ghost_tabs");89this.setState({ num_ghost_tabs: current_num + 1 });90}9192public clear_ghost_tabs(): void {93this.setState({ num_ghost_tabs: 0 });94}9596public close_project_tab(project_id: string): void {97const page_store = redux.getStore("page");98const projects_store = redux.getStore("projects");99100const open_projects = projects_store.get("open_projects");101const active_top_tab = page_store.get("active_top_tab");102103const index = open_projects.indexOf(project_id);104if (index === -1) {105return;106}107108if (this.session_manager != null) {109this.session_manager.close_project(project_id);110} // remembers what files are open111112const { size } = open_projects;113if (project_id === active_top_tab) {114let next_active_tab;115if (index === -1 || size <= 1) {116next_active_tab = "projects";117} else if (index === size - 1) {118next_active_tab = open_projects.get(index - 1);119} else {120next_active_tab = open_projects.get(index + 1);121}122this.set_active_tab(next_active_tab);123}124125// The point of these "ghost tabs" is to make it so you can quickly close several126// open tabs, like in Chrome.127if (index === size - 1) {128this.clear_ghost_tabs();129} else {130this.add_a_ghost_tab();131}132133redux.getActions("projects").set_project_closed(project_id);134this.save_session();135136// if there happens to be a websocket to this project, get rid of it.137// Nothing will be using it when the project is closed.138disconnect_from_project(project_id);139}140141async set_active_tab(key, change_history = true): Promise<void> {142const prev_key = this.redux.getStore("page").get("active_top_tab");143this.setState({ active_top_tab: key });144145if (prev_key !== key && prev_key?.length == 36) {146// fire hide action on project we are switching from.147redux.getProjectActions(prev_key)?.hide();148}149if (key?.length == 36) {150// fire show action on project we are switching to151redux.getProjectActions(key)?.show();152}153154const intl = await getIntl();155156switch (key) {157case "projects":158if (change_history) {159set_url("/projects");160}161set_window_title(intl.formatMessage(labels.projects));162return;163case "account":164if (change_history) {165redux.getActions("account").push_state();166}167set_window_title(intl.formatMessage(labels.account));168return;169case "file-use": // this doesn't actually get used currently170if (change_history) {171set_url("/file-use");172}173set_window_title("File Usage");174return;175case "admin":176if (change_history) {177set_url("/admin");178}179set_window_title(intl.formatMessage(labels.admin));180return;181case "notifications":182if (change_history) {183set_url("/notifications");184}185set_window_title(intl.formatMessage(labels.notifications));186return;187case undefined:188return;189default:190if (change_history) {191redux.getProjectActions(key)?.push_state();192}193set_window_title("Loading Project");194var projects_store = redux.getStore("projects");195196if (projects_store.date_when_course_payment_required(key)) {197redux198.getActions("projects")199.apply_default_upgrades({ project_id: key });200}201202try {203const title: string = await projects_store.async_wait({204until: (store): string | undefined => {205let title: string | undefined = store.getIn([206"project_map",207key,208"title",209]);210if (title == null) {211title = store.getIn(["public_project_titles", key]);212}213if (title === "") {214return "Untitled Project";215}216if (title == null) {217redux.getActions("projects").fetch_public_project_title(key);218}219return title;220},221timeout: 15,222});223set_window_title(title);224} catch (err) {225set_window_title("");226}227}228}229230show_connection(show_connection) {231this.setState({ show_connection });232}233234// Suppress the activation of any new key handlers235disableGlobalKeyHandler = () => {236this.suppress_key_handlers = true;237this.unattach_active_key_handler();238};239// Enable whatever the current key handler should be240enableGlobalKeyHandler = () => {241this.suppress_key_handlers = false;242this.set_active_key_handler();243};244245// Toggles visibility of file use widget246// Temporarily disables window key handlers until closed247// FUTURE: Develop more general way to make key mappings248toggle_show_file_use() {249const currently_shown = redux.getStore("page").get("show_file_use");250if (currently_shown) {251this.enableGlobalKeyHandler(); // HACK: Terrible way to do this.252} else {253// Suppress the activation of any new key handlers until file_use closes254this.disableGlobalKeyHandler(); // HACK: Terrible way to do this.255}256257this.setState({ show_file_use: !currently_shown });258}259260set_ping(ping, avgping) {261this.setState({ ping, avgping });262}263264set_connection_status(connection_status, time: Date) {265if (time > (redux.getStore("page").get("last_status_time") ?? 0)) {266this.setState({ connection_status, last_status_time: time });267}268}269270set_connection_quality(connection_quality) {271this.setState({ connection_quality });272}273274set_new_version(new_version) {275this.setState({ new_version });276}277278async set_fullscreen(279fullscreen?: "default" | "kiosk" | "project" | undefined,280) {281// val = 'default', 'kiosk', 'project', undefined282// if kiosk is ever set, disable toggling back283if (redux.getStore("page").get("fullscreen") === "kiosk") {284return;285}286this.setState({ fullscreen });287if (fullscreen == "project") {288// this removes top row for embedding purposes and thus doesn't need289// full browser fullscreen.290return;291}292if (fullscreen) {293try {294await requestFullscreen();295} catch (err) {296// gives an error if not initiated explicitly by user action,297// or not available (e.g., iphone)298console.log(err);299}300} else {301if (isFullscreen()) {302exitFullscreen();303}304}305}306307set_get_api_key(val) {308this.setState({ get_api_key: val });309update_params();310}311312toggle_fullscreen() {313this.set_fullscreen(314redux.getStore("page").get("fullscreen") != null ? undefined : "default",315);316}317318set_session(session) {319// If existing different session, close it.320if (session !== redux.getStore("page").get("session")) {321if (this.session_manager != null) {322this.session_manager.close();323}324delete this.session_manager;325}326327// Save state and update URL.328this.setState({ session });329330// Make new session manager, but only register it if we have331// an actual session name!332if (!this.session_manager) {333const sm = session_manager(session, redux);334if (session) {335this.session_manager = sm;336}337}338}339340save_session() {341this.session_manager?.save();342}343344restore_session(project_id) {345this.session_manager?.restore(project_id);346}347348show_cookie_warning() {349this.setState({ cookie_warning: true });350}351352show_local_storage_warning() {353this.setState({ local_storage_warning: true });354}355356check_unload(_) {357if (redux.getStore("page").get("get_api_key")) {358// never confirm close if get_api_key is set.359return;360}361const fullscreen = redux.getStore("page").get("fullscreen");362if (fullscreen == "kiosk" || fullscreen == "project") {363// never confirm close in kiosk or project embed mode, since that should be364// responsibility of containing page, and it's confusing where365// the dialog is even coming from.366return;367}368// Returns a defined string if the user should confirm exiting the site.369const s = redux.getStore("account");370if (371(s != null ? s.get_user_type() : undefined) === "signed_in" &&372(s != null ? s.get_confirm_close() : undefined)373) {374return "Changes you make may not have been saved.";375} else {376return;377}378}379380set_sign_in_func(func) {381this.sign_in = func;382}383384remove_sign_in_func() {385this.sign_in = () => false;386}387388// Expected to be overridden by functions above389sign_in() {390return false;391}392393// The code below is complicated and tricky because multiple parts of our codebase could394// call it at the "same time". This happens, e.g., when opening several Jupyter notebooks395// on a compute server from the terminal using the open command.396// By "same time", I mean a second call to popconfirm comes in while the first is async397// awaiting to finish. We handle that below by locking while waiting. Since only one398// thing actually happens at a time in Javascript, the below should always work with399// no deadlocks. It's tricky looking code, but MUCH simpler than alternatives I considered.400popconfirm = async (opts): Promise<boolean> => {401const store = redux.getStore("page");402// wait for any currently open modal to be done.403while (this.popconfirmIsOpen) {404await once(store, "change");405}406// we got it, so let's take the lock407try {408this.popconfirmIsOpen = true;409// now we do it -- this causes the modal to appear410this.setState({ popconfirm: { open: true, ...opts } });411// wait for our to be done412while (store.getIn(["popconfirm", "open"])) {413await once(store, "change");414}415// report result of ours.416return !!store.getIn(["popconfirm", "ok"]);417} finally {418// give up the lock419this.popconfirmIsOpen = false;420// trigger a change, so other code has a chance to get the lock421this.setState({ popconfirm: { open: false } });422}423};424425settings = async (name) => {426if (!name) {427this.setState({ settingsModal: "" });428this.settingsModalIsOpen = false;429return;430}431const store = redux.getStore("page");432while (this.settingsModalIsOpen) {433await once(store, "change");434}435try {436this.settingsModalIsOpen = true;437this.setState({ settingsModal: name });438while (store.get("settingsModal")) {439await once(store, "change");440}441} finally {442this.settingsModalIsOpen = false;443}444};445}446447export function init_actions() {448redux.createActions("page", PageActions);449}450451452