index.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750
  1. /*-----------------------------------------------------------------------------
  2. | Copyright (c) Jupyter Development Team.
  3. | Distributed under the terms of the Modified BSD License.
  4. |----------------------------------------------------------------------------*/
  5. import {
  6. ILayoutRestorer,
  7. IRouter,
  8. JupyterFrontEnd,
  9. JupyterLab,
  10. JupyterFrontEndPlugin
  11. } from '@jupyterlab/application';
  12. import {
  13. Dialog,
  14. ICommandPalette,
  15. ISplashScreen,
  16. IThemeManager,
  17. IWindowResolver,
  18. ThemeManager,
  19. WindowResolver
  20. } from '@jupyterlab/apputils';
  21. import {
  22. ISettingRegistry,
  23. IStateDB,
  24. PageConfig,
  25. SettingRegistry,
  26. StateDB,
  27. URLExt
  28. } from '@jupyterlab/coreutils';
  29. import { IMainMenu } from '@jupyterlab/mainmenu';
  30. import { CommandRegistry } from '@phosphor/commands';
  31. import { PromiseDelegate } from '@phosphor/coreutils';
  32. import { DisposableDelegate, IDisposable } from '@phosphor/disposable';
  33. import { Menu } from '@phosphor/widgets';
  34. import { Palette } from './palette';
  35. import { createRedirectForm } from './redirect';
  36. import '../style/index.css';
  37. /**
  38. * The interval in milliseconds that calls to save a workspace are debounced
  39. * to allow for multiple quickly executed state changes to result in a single
  40. * workspace save operation.
  41. */
  42. const WORKSPACE_SAVE_DEBOUNCE_INTERVAL = 750;
  43. /**
  44. * The interval in milliseconds before recover options appear during splash.
  45. */
  46. const SPLASH_RECOVER_TIMEOUT = 12000;
  47. /**
  48. * The command IDs used by the apputils plugin.
  49. */
  50. namespace CommandIDs {
  51. export const changeTheme = 'apputils:change-theme';
  52. export const loadState = 'apputils:load-statedb';
  53. export const recoverState = 'apputils:recover-statedb';
  54. export const reset = 'apputils:reset';
  55. export const resetOnLoad = 'apputils:reset-on-load';
  56. export const saveState = 'apputils:save-statedb';
  57. }
  58. /**
  59. * The routing regular expressions used by the apputils plugin.
  60. */
  61. namespace Patterns {
  62. export const resetOnLoad = /(\?reset|\&reset)($|&)/;
  63. export const workspace = new RegExp(
  64. `^${PageConfig.getOption('workspacesUrl')}([^?\/]+)`
  65. );
  66. }
  67. /**
  68. * The default command palette extension.
  69. */
  70. const palette: JupyterFrontEndPlugin<ICommandPalette> = {
  71. activate: Palette.activate,
  72. id: '@jupyterlab/apputils-extension:palette',
  73. provides: ICommandPalette,
  74. autoStart: true
  75. };
  76. /**
  77. * The default command palette's restoration extension.
  78. *
  79. * #### Notes
  80. * The command palette's restoration logic is handled separately from the
  81. * command palette provider extension because the layout restorer dependency
  82. * causes the command palette to be unavailable to other extensions earlier
  83. * in the application load cycle.
  84. */
  85. const paletteRestorer: JupyterFrontEndPlugin<void> = {
  86. activate: Palette.restore,
  87. id: '@jupyterlab/apputils-extension:palette-restorer',
  88. requires: [ILayoutRestorer],
  89. autoStart: true
  90. };
  91. /**
  92. * The default setting registry provider.
  93. */
  94. const settings: JupyterFrontEndPlugin<ISettingRegistry> = {
  95. id: '@jupyterlab/apputils-extension:settings',
  96. activate: async (app: JupyterFrontEnd): Promise<ISettingRegistry> => {
  97. const connector = app.serviceManager.settings;
  98. const plugins = (await connector.list()).values;
  99. return new SettingRegistry({ connector, plugins });
  100. },
  101. autoStart: true,
  102. provides: ISettingRegistry
  103. };
  104. /**
  105. * The default theme manager provider.
  106. */
  107. const themes: JupyterFrontEndPlugin<IThemeManager> = {
  108. id: '@jupyterlab/apputils-extension:themes',
  109. requires: [ISettingRegistry, JupyterLab.IInfo, ISplashScreen],
  110. optional: [ICommandPalette, IMainMenu],
  111. activate: (
  112. app: JupyterFrontEnd,
  113. settings: ISettingRegistry,
  114. info: JupyterLab.IInfo,
  115. splash: ISplashScreen | null,
  116. palette: ICommandPalette | null,
  117. mainMenu: IMainMenu | null
  118. ): IThemeManager => {
  119. const host = app.shell;
  120. const commands = app.commands;
  121. const url = URLExt.join(info.urls.base, info.urls.themes);
  122. const key = themes.id;
  123. const manager = new ThemeManager({ key, host, settings, splash, url });
  124. // Keep a synchronously set reference to the current theme,
  125. // since the asynchronous setting of the theme in `changeTheme`
  126. // can lead to an incorrect toggle on the currently used theme.
  127. let currentTheme: string;
  128. // Set data attributes on the application shell for the current theme.
  129. manager.themeChanged.connect((sender, args) => {
  130. currentTheme = args.newValue;
  131. app.shell.dataset.themeLight = String(manager.isLight(currentTheme));
  132. app.shell.dataset.themeName = currentTheme;
  133. commands.notifyCommandChanged(CommandIDs.changeTheme);
  134. });
  135. commands.addCommand(CommandIDs.changeTheme, {
  136. label: args => {
  137. const theme = args['theme'] as string;
  138. return args['isPalette'] ? `Use ${theme} Theme` : theme;
  139. },
  140. isToggled: args => args['theme'] === currentTheme,
  141. execute: args => {
  142. const theme = args['theme'] as string;
  143. if (theme === manager.theme) {
  144. return;
  145. }
  146. manager.setTheme(theme);
  147. }
  148. });
  149. // If we have a main menu, add the theme manager to the settings menu.
  150. if (mainMenu) {
  151. const themeMenu = new Menu({ commands });
  152. themeMenu.title.label = 'JupyterLab Theme';
  153. app.restored.then(() => {
  154. const command = CommandIDs.changeTheme;
  155. const isPalette = false;
  156. manager.themes.forEach(theme => {
  157. themeMenu.addItem({ command, args: { isPalette, theme } });
  158. });
  159. });
  160. mainMenu.settingsMenu.addGroup(
  161. [
  162. {
  163. type: 'submenu' as Menu.ItemType,
  164. submenu: themeMenu
  165. }
  166. ],
  167. 0
  168. );
  169. }
  170. // If we have a command palette, add theme switching options to it.
  171. if (palette) {
  172. app.restored.then(() => {
  173. const category = 'Settings';
  174. const command = CommandIDs.changeTheme;
  175. const isPalette = true;
  176. currentTheme = manager.theme;
  177. manager.themes.forEach(theme => {
  178. palette.addItem({ command, args: { isPalette, theme }, category });
  179. });
  180. });
  181. }
  182. return manager;
  183. },
  184. autoStart: true,
  185. provides: IThemeManager
  186. };
  187. /**
  188. * The default window name resolver provider.
  189. */
  190. const resolver: JupyterFrontEndPlugin<IWindowResolver> = {
  191. id: '@jupyterlab/apputils-extension:resolver',
  192. autoStart: true,
  193. provides: IWindowResolver,
  194. requires: [JupyterFrontEnd.IPaths, IRouter],
  195. activate: async (
  196. _: JupyterFrontEnd,
  197. paths: JupyterFrontEnd.IPaths,
  198. router: IRouter
  199. ) => {
  200. const solver = new WindowResolver();
  201. const match = router.current.path.match(Patterns.workspace);
  202. const workspace = (match && decodeURIComponent(match[1])) || '';
  203. const candidate = Private.candidate(paths.urls, workspace);
  204. try {
  205. await solver.resolve(candidate);
  206. } catch (error) {
  207. console.warn('Window resolution failed:', error);
  208. // Return a promise that never resolves.
  209. return new Promise<IWindowResolver>(() => {
  210. Private.redirect(router);
  211. });
  212. }
  213. return solver;
  214. }
  215. };
  216. /**
  217. * The default splash screen provider.
  218. */
  219. const splash: JupyterFrontEndPlugin<ISplashScreen> = {
  220. id: '@jupyterlab/apputils-extension:splash',
  221. autoStart: true,
  222. provides: ISplashScreen,
  223. activate: app => {
  224. return {
  225. show: (light = true) => {
  226. const { commands, restored } = app;
  227. return Private.showSplash(restored, commands, CommandIDs.reset, light);
  228. }
  229. };
  230. }
  231. };
  232. /**
  233. * The default state database for storing application state.
  234. */
  235. const state: JupyterFrontEndPlugin<IStateDB> = {
  236. id: '@jupyterlab/apputils-extension:state',
  237. autoStart: true,
  238. provides: IStateDB,
  239. requires: [IRouter, IWindowResolver, JupyterLab.IInfo],
  240. optional: [ISplashScreen],
  241. activate: (
  242. app: JupyterFrontEnd,
  243. router: IRouter,
  244. resolver: IWindowResolver,
  245. info: JupyterLab.IInfo,
  246. splash: ISplashScreen | null
  247. ) => {
  248. let debouncer: number;
  249. let resolved = false;
  250. const { commands, serviceManager } = app;
  251. const { workspaces } = serviceManager;
  252. const workspace = resolver.name;
  253. const transform = new PromiseDelegate<StateDB.DataTransform>();
  254. const db = new StateDB({
  255. namespace: info.namespace,
  256. transform: transform.promise,
  257. windowName: workspace
  258. });
  259. commands.addCommand(CommandIDs.recoverState, {
  260. execute: async ({ global }) => {
  261. const immediate = true;
  262. const silent = true;
  263. // Clear the state silently so that the state changed signal listener
  264. // will not be triggered as it causes a save state.
  265. await db.clear(silent);
  266. // If the user explictly chooses to recover state, all of local storage
  267. // should be cleared.
  268. if (global) {
  269. try {
  270. window.localStorage.clear();
  271. console.log('Cleared local storage');
  272. } catch (error) {
  273. console.warn('Clearing local storage failed.', error);
  274. // To give the user time to see the console warning before redirect,
  275. // do not set the `immediate` flag.
  276. return commands.execute(CommandIDs.saveState);
  277. }
  278. }
  279. return commands.execute(CommandIDs.saveState, { immediate });
  280. }
  281. });
  282. // Conflate all outstanding requests to the save state command that happen
  283. // within the `WORKSPACE_SAVE_DEBOUNCE_INTERVAL` into a single promise.
  284. let conflated: PromiseDelegate<void> | null = null;
  285. commands.addCommand(CommandIDs.saveState, {
  286. label: () => `Save Workspace (${workspace})`,
  287. execute: ({ immediate }) => {
  288. const timeout = immediate ? 0 : WORKSPACE_SAVE_DEBOUNCE_INTERVAL;
  289. const id = workspace;
  290. const metadata = { id };
  291. // Only instantiate a new conflated promise if one is not outstanding.
  292. if (!conflated) {
  293. conflated = new PromiseDelegate<void>();
  294. }
  295. if (debouncer) {
  296. window.clearTimeout(debouncer);
  297. }
  298. debouncer = window.setTimeout(async () => {
  299. // Prevent a race condition between the timeout and saving.
  300. if (!conflated) {
  301. return;
  302. }
  303. const data = await db.toJSON();
  304. try {
  305. await workspaces.save(id, { data, metadata });
  306. conflated.resolve(undefined);
  307. } catch (error) {
  308. conflated.reject(error);
  309. }
  310. conflated = null;
  311. }, timeout);
  312. return conflated.promise;
  313. }
  314. });
  315. const listener = (sender: any, change: StateDB.Change) => {
  316. commands.execute(CommandIDs.saveState);
  317. };
  318. commands.addCommand(CommandIDs.loadState, {
  319. execute: async (args: IRouter.ILocation) => {
  320. // Since the command can be executed an arbitrary number of times, make
  321. // sure it is safe to call multiple times.
  322. if (resolved) {
  323. return;
  324. }
  325. const { hash, path, search } = args;
  326. const { urls } = info;
  327. const query = URLExt.queryStringToObject(search || '');
  328. const clone =
  329. typeof query['clone'] === 'string'
  330. ? query['clone'] === ''
  331. ? urls.defaultWorkspace
  332. : URLExt.join(urls.base, urls.workspaces, query['clone'])
  333. : null;
  334. const source = clone || workspace;
  335. try {
  336. const saved = await workspaces.fetch(source);
  337. // If this command is called after a reset, the state database
  338. // will already be resolved.
  339. if (!resolved) {
  340. resolved = true;
  341. transform.resolve({ type: 'overwrite', contents: saved.data });
  342. }
  343. } catch (error) {
  344. console.warn(`Fetching workspace (${workspace}) failed:`, error);
  345. // If the workspace does not exist, cancel the data transformation
  346. // and save a workspace with the current user state data.
  347. if (!resolved) {
  348. resolved = true;
  349. transform.resolve({ type: 'cancel', contents: null });
  350. }
  351. }
  352. // Any time the local state database changes, save the workspace.
  353. if (workspace) {
  354. db.changed.connect(
  355. listener,
  356. db
  357. );
  358. }
  359. const immediate = true;
  360. if (source === clone) {
  361. // Maintain the query string parameters but remove `clone`.
  362. delete query['clone'];
  363. const url = path + URLExt.objectToQueryString(query) + hash;
  364. const cloned = commands
  365. .execute(CommandIDs.saveState, { immediate })
  366. .then(() => router.stop);
  367. // After the state has been cloned, navigate to the URL.
  368. cloned.then(() => {
  369. router.navigate(url, { silent: true });
  370. });
  371. return cloned;
  372. }
  373. // After the state database has finished loading, save it.
  374. return commands.execute(CommandIDs.saveState, { immediate });
  375. }
  376. });
  377. router.register({
  378. command: CommandIDs.loadState,
  379. pattern: /.?/,
  380. rank: 20 // Very high priority: 20:100.
  381. });
  382. commands.addCommand(CommandIDs.reset, {
  383. label: 'Reset Application State',
  384. execute: async () => {
  385. const global = true;
  386. try {
  387. await commands.execute(CommandIDs.recoverState, { global });
  388. } catch (error) {
  389. /* Ignore failures and redirect. */
  390. }
  391. router.reload();
  392. }
  393. });
  394. commands.addCommand(CommandIDs.resetOnLoad, {
  395. execute: (args: IRouter.ILocation) => {
  396. const { hash, path, search } = args;
  397. const query = URLExt.queryStringToObject(search || '');
  398. const reset = 'reset' in query;
  399. const clone = 'clone' in query;
  400. if (!reset) {
  401. return;
  402. }
  403. // If a splash provider exists, launch the splash screen.
  404. const loading = splash
  405. ? splash.show()
  406. : new DisposableDelegate(() => undefined);
  407. // If the state database has already been resolved, resetting is
  408. // impossible without reloading.
  409. if (resolved) {
  410. return router.reload();
  411. }
  412. // Empty the state database.
  413. resolved = true;
  414. transform.resolve({ type: 'clear', contents: null });
  415. // Maintain the query string parameters but remove `reset`.
  416. delete query['reset'];
  417. const silent = true;
  418. const hard = true;
  419. const url = path + URLExt.objectToQueryString(query) + hash;
  420. const cleared = commands
  421. .execute(CommandIDs.recoverState)
  422. .then(() => router.stop); // Stop routing before new route navigation.
  423. // After the state has been reset, navigate to the URL.
  424. if (clone) {
  425. cleared.then(() => {
  426. router.navigate(url, { silent, hard });
  427. });
  428. } else {
  429. cleared.then(() => {
  430. router.navigate(url, { silent });
  431. loading.dispose();
  432. });
  433. }
  434. return cleared;
  435. }
  436. });
  437. router.register({
  438. command: CommandIDs.resetOnLoad,
  439. pattern: Patterns.resetOnLoad,
  440. rank: 10 // Very high priority: 10:100.
  441. });
  442. // Clean up state database when the window unloads.
  443. window.addEventListener('beforeunload', () => {
  444. const silent = true;
  445. db.clear(silent).catch(() => {
  446. /* no-op */
  447. });
  448. });
  449. return db;
  450. }
  451. };
  452. /**
  453. * Export the plugins as default.
  454. */
  455. const plugins: JupyterFrontEndPlugin<any>[] = [
  456. palette,
  457. paletteRestorer,
  458. resolver,
  459. settings,
  460. state,
  461. splash,
  462. themes
  463. ];
  464. export default plugins;
  465. /**
  466. * The namespace for module private data.
  467. */
  468. namespace Private {
  469. /**
  470. * Generate a workspace name candidate.
  471. *
  472. * @param workspace - A potential workspace name parsed from the URL.
  473. *
  474. * @returns A workspace name candidate.
  475. */
  476. export function candidate(
  477. urls: JupyterFrontEnd.Paths.IURLs,
  478. workspace = ''
  479. ): string {
  480. return workspace
  481. ? URLExt.join(urls.base, urls.workspaces, workspace)
  482. : urls.defaultWorkspace;
  483. }
  484. /**
  485. * Create a splash element.
  486. */
  487. function createSplash(): HTMLElement {
  488. const splash = document.createElement('div');
  489. const galaxy = document.createElement('div');
  490. const logo = document.createElement('div');
  491. splash.id = 'jupyterlab-splash';
  492. galaxy.id = 'galaxy';
  493. logo.id = 'main-logo';
  494. galaxy.appendChild(logo);
  495. ['1', '2', '3'].forEach(id => {
  496. const moon = document.createElement('div');
  497. const planet = document.createElement('div');
  498. moon.id = `moon${id}`;
  499. moon.className = 'moon orbit';
  500. planet.id = `planet${id}`;
  501. planet.className = 'planet';
  502. moon.appendChild(planet);
  503. galaxy.appendChild(moon);
  504. });
  505. splash.appendChild(galaxy);
  506. return splash;
  507. }
  508. /**
  509. * A debouncer for recovery attempts.
  510. */
  511. let debouncer = 0;
  512. /**
  513. * The recovery dialog.
  514. */
  515. let dialog: Dialog<any>;
  516. /**
  517. * Allows the user to clear state if splash screen takes too long.
  518. */
  519. function recover(fn: () => void): void {
  520. if (dialog) {
  521. return;
  522. }
  523. dialog = new Dialog({
  524. title: 'Loading...',
  525. body: `The loading screen is taking a long time.
  526. Would you like to clear the workspace or keep waiting?`,
  527. buttons: [
  528. Dialog.cancelButton({ label: 'Keep Waiting' }),
  529. Dialog.warnButton({ label: 'Clear Workspace' })
  530. ]
  531. });
  532. dialog
  533. .launch()
  534. .then(result => {
  535. if (result.button.accept) {
  536. return fn();
  537. }
  538. dialog.dispose();
  539. dialog = null;
  540. debouncer = window.setTimeout(() => {
  541. recover(fn);
  542. }, SPLASH_RECOVER_TIMEOUT);
  543. })
  544. .catch(() => {
  545. /* no-op */
  546. });
  547. }
  548. /**
  549. * Allows the user to clear state if splash screen takes too long.
  550. */
  551. export async function redirect(router: IRouter, warn = false): Promise<void> {
  552. const form = createRedirectForm(warn);
  553. const dialog = new Dialog({
  554. title: 'Please use a different workspace.',
  555. body: form,
  556. focusNodeSelector: 'input',
  557. buttons: [Dialog.okButton({ label: 'Switch Workspace' })]
  558. });
  559. const result = await dialog.launch();
  560. dialog.dispose();
  561. if (!result.value) {
  562. return redirect(router, true);
  563. }
  564. // Navigate to a new workspace URL and abandon this session altogether.
  565. const page = PageConfig.getOption('pageUrl');
  566. const workspaces = PageConfig.getOption('workspacesUrl');
  567. const match = router.current.path.match(Patterns.workspace);
  568. const workspace = (match && decodeURIComponent(match[1])) || '';
  569. const prefix = (workspace ? workspaces : page).length + workspace.length;
  570. const rest = router.current.request.substring(prefix);
  571. const url = URLExt.join(workspaces, result.value, rest);
  572. router.navigate(url, { hard: true, silent: true });
  573. // This promise will never resolve because the application navigates
  574. // away to a new location. It only exists to satisfy the return type
  575. // of the `redirect` function.
  576. return new Promise<void>(() => undefined);
  577. }
  578. /**
  579. * The splash element.
  580. */
  581. const splash = createSplash();
  582. /**
  583. * The splash screen counter.
  584. */
  585. let splashCount = 0;
  586. /**
  587. * Show the splash element.
  588. *
  589. * @param ready - A promise that must be resolved before splash disappears.
  590. *
  591. * @param commands - The application's command registry.
  592. *
  593. * @param recovery - A command that recovers from a hanging splash.
  594. *
  595. * @param light - A flag indicating whether the theme is light or dark.
  596. */
  597. export function showSplash(
  598. ready: Promise<any>,
  599. commands: CommandRegistry,
  600. recovery: string,
  601. light: boolean
  602. ): IDisposable {
  603. splash.classList.remove('splash-fade');
  604. splash.classList.toggle('light', light);
  605. splash.classList.toggle('dark', !light);
  606. splashCount++;
  607. if (debouncer) {
  608. window.clearTimeout(debouncer);
  609. }
  610. debouncer = window.setTimeout(() => {
  611. if (commands.hasCommand(recovery)) {
  612. recover(() => {
  613. commands.execute(recovery);
  614. });
  615. }
  616. }, SPLASH_RECOVER_TIMEOUT);
  617. document.body.appendChild(splash);
  618. return new DisposableDelegate(() => {
  619. ready.then(() => {
  620. if (--splashCount === 0) {
  621. if (debouncer) {
  622. window.clearTimeout(debouncer);
  623. debouncer = 0;
  624. }
  625. if (dialog) {
  626. dialog.dispose();
  627. dialog = null;
  628. }
  629. splash.classList.add('splash-fade');
  630. window.setTimeout(() => {
  631. document.body.removeChild(splash);
  632. }, 500);
  633. }
  634. });
  635. });
  636. }
  637. }