index.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. /*-----------------------------------------------------------------------------
  2. | Copyright (c) Jupyter Development Team.
  3. | Distributed under the terms of the Modified BSD License.
  4. |----------------------------------------------------------------------------*/
  5. import {
  6. ILayoutRestorer, IRouter, JupyterLab, JupyterLabPlugin
  7. } from '@jupyterlab/application';
  8. import {
  9. Dialog, ICommandPalette, ISplashScreen, IThemeManager, ThemeManager
  10. } from '@jupyterlab/apputils';
  11. import {
  12. DataConnector, ISettingRegistry, IStateDB, IWindowResolver, SettingRegistry,
  13. StateDB, URLExt, WindowResolver
  14. } from '@jupyterlab/coreutils';
  15. import {
  16. IMainMenu
  17. } from '@jupyterlab/mainmenu';
  18. import {
  19. ServiceManager
  20. } from '@jupyterlab/services';
  21. import {
  22. PromiseDelegate
  23. } from '@phosphor/coreutils';
  24. import {
  25. DisposableDelegate, IDisposable
  26. } from '@phosphor/disposable';
  27. import {
  28. Menu
  29. } from '@phosphor/widgets';
  30. import {
  31. activatePalette, restorePalette
  32. } from './palette';
  33. import {
  34. createRedirectForm
  35. } 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 = 1500;
  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
  52. const changeTheme = 'apputils:change-theme';
  53. export
  54. const loadState = 'apputils:load-statedb';
  55. export
  56. const recoverState = 'apputils:recover-statedb';
  57. export
  58. const reset = 'apputils:reset';
  59. export
  60. const resetOnLoad = 'apputils:reset-on-load';
  61. export
  62. const saveState = 'apputils:save-statedb';
  63. }
  64. /**
  65. * The routing regular expressions used by the apputils plugin.
  66. */
  67. namespace Patterns {
  68. export
  69. const loadState = /^\/workspaces\/([^?]+)/;
  70. export
  71. const resetOnLoad = /(\?reset|\&reset)($|&)/;
  72. }
  73. /**
  74. * A data connector to access plugin settings.
  75. */
  76. class SettingsConnector extends DataConnector<ISettingRegistry.IPlugin, string> {
  77. /**
  78. * Create a new settings connector.
  79. */
  80. constructor(manager: ServiceManager) {
  81. super();
  82. this._manager = manager;
  83. }
  84. /**
  85. * Retrieve a saved bundle from the data connector.
  86. */
  87. fetch(id: string): Promise<ISettingRegistry.IPlugin> {
  88. return this._manager.settings.fetch(id).then(data => {
  89. // Replace the server ID with the original unmodified version.
  90. data.id = id;
  91. return data;
  92. });
  93. }
  94. /**
  95. * Save the user setting data in the data connector.
  96. */
  97. save(id: string, raw: string): Promise<void> {
  98. return this._manager.settings.save(id, raw);
  99. }
  100. private _manager: ServiceManager;
  101. }
  102. /**
  103. * The default commmand palette extension.
  104. */
  105. const palette: JupyterLabPlugin<ICommandPalette> = {
  106. activate: activatePalette,
  107. id: '@jupyterlab/apputils-extension:palette',
  108. provides: ICommandPalette,
  109. autoStart: true
  110. };
  111. /**
  112. * The default commmand palette's restoration extension.
  113. *
  114. * #### Notes
  115. * The command palette's restoration logic is handled separately from the
  116. * command palette provider extension because the layout restorer dependency
  117. * causes the command palette to be unavailable to other extensions earlier
  118. * in the application load cycle.
  119. */
  120. const paletteRestorer: JupyterLabPlugin<void> = {
  121. activate: restorePalette,
  122. id: '@jupyterlab/apputils-extension:palette-restorer',
  123. requires: [ILayoutRestorer],
  124. autoStart: true
  125. };
  126. /**
  127. * The default setting registry provider.
  128. */
  129. const settings: JupyterLabPlugin<ISettingRegistry> = {
  130. id: '@jupyterlab/apputils-extension:settings',
  131. activate: (app: JupyterLab): ISettingRegistry => {
  132. const connector = new SettingsConnector(app.serviceManager);
  133. return new SettingRegistry({ connector });
  134. },
  135. autoStart: true,
  136. provides: ISettingRegistry
  137. };
  138. /**
  139. * The default theme manager provider.
  140. */
  141. const themes: JupyterLabPlugin<IThemeManager> = {
  142. id: '@jupyterlab/apputils-extension:themes',
  143. requires: [ISettingRegistry, ISplashScreen],
  144. optional: [ICommandPalette, IMainMenu],
  145. activate: (app: JupyterLab, settings: ISettingRegistry, splash: ISplashScreen, palette: ICommandPalette | null, mainMenu: IMainMenu | null): IThemeManager => {
  146. const host = app.shell;
  147. const commands = app.commands;
  148. const url = app.info.urls.themes;
  149. const key = themes.id;
  150. const manager = new ThemeManager({ key, host, settings, splash, url });
  151. // Keep a synchronously set reference to the current theme,
  152. // since the asynchronous setting of the theme in `changeTheme`
  153. // can lead to an incorrect toggle on the currently used theme.
  154. let currentTheme: string;
  155. commands.addCommand(CommandIDs.changeTheme, {
  156. label: args => {
  157. const theme = args['theme'] as string;
  158. return args['isPalette'] ? `Use ${theme} Theme` : theme;
  159. },
  160. isToggled: args => args['theme'] === currentTheme,
  161. execute: args => {
  162. const theme = args['theme'] as string;
  163. if (theme === manager.theme) {
  164. return;
  165. }
  166. currentTheme = theme;
  167. manager.setTheme(theme);
  168. commands.notifyCommandChanged(CommandIDs.changeTheme);
  169. }
  170. });
  171. // If we have a main menu, add the theme manager to the settings menu.
  172. if (mainMenu) {
  173. const themeMenu = new Menu({ commands });
  174. themeMenu.title.label = 'JupyterLab Theme';
  175. app.restored.then(() => {
  176. const command = CommandIDs.changeTheme;
  177. const isPalette = false;
  178. manager.themes.forEach(theme => {
  179. themeMenu.addItem({ command, args: { isPalette, theme } });
  180. });
  181. });
  182. mainMenu.settingsMenu.addGroup([{
  183. type: 'submenu' as Menu.ItemType, submenu: themeMenu
  184. }], 0);
  185. }
  186. // If we have a command palette, add theme switching options to it.
  187. if (palette) {
  188. app.restored.then(() => {
  189. const category = 'Settings';
  190. const command = CommandIDs.changeTheme;
  191. const isPalette = true;
  192. currentTheme = manager.theme;
  193. manager.themes.forEach(theme => {
  194. palette.addItem({ command, args: { isPalette, theme }, category });
  195. });
  196. });
  197. }
  198. return manager;
  199. },
  200. autoStart: true,
  201. provides: IThemeManager
  202. };
  203. /**
  204. * The default window name resolver provider.
  205. */
  206. const resolver: JupyterLabPlugin<IWindowResolver> = {
  207. id: '@jupyterlab/apputils-extension:resolver',
  208. autoStart: true,
  209. provides: IWindowResolver,
  210. requires: [IRouter],
  211. activate: (app: JupyterLab, router: IRouter) => {
  212. const candidate = Private.getWorkspace(router) || '';
  213. const resolver = new WindowResolver();
  214. return resolver.resolve(candidate)
  215. .catch(reason => {
  216. console.log('Window resolution failed.', reason);
  217. return Private.redirect(router);
  218. })
  219. .then(() => resolver);
  220. }
  221. };
  222. /**
  223. * The default splash screen provider.
  224. */
  225. const splash: JupyterLabPlugin<ISplashScreen> = {
  226. id: '@jupyterlab/apputils-extension:splash',
  227. autoStart: true,
  228. provides: ISplashScreen,
  229. activate: app => {
  230. return {
  231. show: () => {
  232. const { commands, restored } = app;
  233. const recovery = () => { commands.execute(CommandIDs.reset); };
  234. // If the reset command is available, show the recovery UI.
  235. if (commands.hasCommand(CommandIDs.reset)) {
  236. return Private.showSplash(restored, recovery);
  237. }
  238. // If the reset command is unavailable, showing the UI is superfluous.
  239. return new DisposableDelegate(() => { /* no-op */ });
  240. }
  241. };
  242. }
  243. };
  244. /**
  245. * The default state database for storing application state.
  246. */
  247. const state: JupyterLabPlugin<IStateDB> = {
  248. id: '@jupyterlab/apputils-extension:state',
  249. autoStart: true,
  250. provides: IStateDB,
  251. requires: [IRouter, IWindowResolver],
  252. activate: (app: JupyterLab, router: IRouter, resolver: IWindowResolver) => {
  253. let debouncer: number;
  254. let resolved = false;
  255. const { commands, info, serviceManager } = app;
  256. const { workspaces } = serviceManager;
  257. const transform = new PromiseDelegate<StateDB.DataTransform>();
  258. const state = new StateDB({
  259. namespace: info.namespace,
  260. transform: transform.promise,
  261. windowName: resolver.name
  262. });
  263. commands.addCommand(CommandIDs.recoverState, {
  264. execute: () => {
  265. const immediate = true;
  266. const silent = true;
  267. // Clear the state silently so that the state changed signal listener
  268. // will not be triggered as it causes a save state.
  269. return state.clear(silent)
  270. .then(() => commands.execute(CommandIDs.saveState, { immediate }));
  271. }
  272. });
  273. // Conflate all outstanding requests to the save state command that happen
  274. // within the `WORKSPACE_SAVE_DEBOUNCE_INTERVAL` into a single promise.
  275. let conflated: PromiseDelegate<void> | null = null;
  276. commands.addCommand(CommandIDs.saveState, {
  277. label: () => `Save Workspace (${Private.getWorkspace(router)})`,
  278. isEnabled: () => !!Private.getWorkspace(router),
  279. execute: args => {
  280. const workspace = Private.getWorkspace(router);
  281. if (!workspace) {
  282. return;
  283. }
  284. const timeout = args.immediate ? 0 : WORKSPACE_SAVE_DEBOUNCE_INTERVAL;
  285. const id = workspace;
  286. const metadata = { id };
  287. // Only instantiate a new conflated promise if one is not outstanding.
  288. if (!conflated) {
  289. conflated = new PromiseDelegate<void>();
  290. }
  291. if (debouncer) {
  292. window.clearTimeout(debouncer);
  293. }
  294. debouncer = window.setTimeout(() => {
  295. state.toJSON()
  296. .then(data => workspaces.save(id, { data, metadata }))
  297. .then(() => {
  298. conflated.resolve(undefined);
  299. conflated = null;
  300. })
  301. .catch(reason => {
  302. conflated.reject(reason);
  303. conflated = null;
  304. });
  305. }, timeout);
  306. return conflated.promise;
  307. }
  308. });
  309. const listener = (sender: any, change: StateDB.Change) => {
  310. commands.execute(CommandIDs.saveState);
  311. };
  312. commands.addCommand(CommandIDs.loadState, {
  313. execute: (args: IRouter.ILocation) => {
  314. const workspace = Private.getWorkspace(router);
  315. // If there is no workspace, bail.
  316. if (!workspace) {
  317. return;
  318. }
  319. // Any time the local state database changes, save the workspace.
  320. state.changed.connect(listener, state);
  321. // Fetch the workspace and overwrite the state database.
  322. return workspaces.fetch(workspace).then(session => {
  323. // If this command is called after a reset, the state database will
  324. // already be resolved.
  325. if (!resolved) {
  326. resolved = true;
  327. transform.resolve({ type: 'overwrite', contents: session.data });
  328. }
  329. }).catch(reason => {
  330. console.warn(`Fetching workspace (${workspace}) failed.`, reason);
  331. // If the workspace does not exist, cancel the data transformation and
  332. // save a workspace with the current user state data.
  333. if (!resolved) {
  334. resolved = true;
  335. transform.resolve({ type: 'cancel', contents: null });
  336. }
  337. return commands.execute(CommandIDs.saveState);
  338. });
  339. }
  340. });
  341. router.register({
  342. command: CommandIDs.loadState,
  343. pattern: Patterns.loadState
  344. });
  345. commands.addCommand(CommandIDs.reset, {
  346. label: 'Reset Application State',
  347. execute: () => {
  348. commands.execute(CommandIDs.recoverState)
  349. .then(() => { router.reload(); })
  350. .catch(() => { router.reload(); });
  351. }
  352. });
  353. commands.addCommand(CommandIDs.resetOnLoad, {
  354. execute: (args: IRouter.ILocation) => {
  355. const { hash, path, search } = args;
  356. const query = URLExt.queryStringToObject(search || '');
  357. const reset = 'reset' in query;
  358. if (!reset) {
  359. return;
  360. }
  361. // If the state database has already been resolved, resetting is
  362. // impossible without reloading.
  363. if (resolved) {
  364. return router.reload();
  365. }
  366. // Empty the state database.
  367. resolved = true;
  368. transform.resolve({ type: 'clear', contents: null });
  369. // Maintain the query string parameters but remove `reset`.
  370. delete query['reset'];
  371. const url = path + URLExt.objectToQueryString(query) + hash;
  372. const cleared = commands.execute(CommandIDs.recoverState)
  373. .then(() => router.stop); // Stop routing before new route navigation.
  374. // After the state has been reset, navigate to the URL.
  375. cleared.then(() => { router.navigate(url, { silent: true }); });
  376. return cleared;
  377. }
  378. });
  379. router.register({
  380. command: CommandIDs.resetOnLoad,
  381. pattern: Patterns.resetOnLoad,
  382. rank: 10 // Set reset rank at a higher priority than the default 100.
  383. });
  384. const fallthrough = () => {
  385. // If the state database is still unresolved after the first URL has been
  386. // routed, leave it intact.
  387. if (!resolved) {
  388. resolved = true;
  389. transform.resolve({ type: 'cancel', contents: null });
  390. }
  391. router.routed.disconnect(fallthrough, state);
  392. };
  393. router.routed.connect(fallthrough, state);
  394. return state;
  395. }
  396. };
  397. /**
  398. * Export the plugins as default.
  399. */
  400. const plugins: JupyterLabPlugin<any>[] = [
  401. palette, paletteRestorer, resolver, settings, state, splash, themes
  402. ];
  403. export default plugins;
  404. /**
  405. * The namespace for module private data.
  406. */
  407. namespace Private {
  408. /**
  409. * Returns the workspace name from the URL, if it exists.
  410. */
  411. export
  412. function getWorkspace(router: IRouter): string {
  413. const match = router.current.path.match(Patterns.loadState);
  414. return match && decodeURIComponent(match[1]) || '';
  415. }
  416. /**
  417. * Create a splash element.
  418. */
  419. function createSplash(): HTMLElement {
  420. const splash = document.createElement('div');
  421. const galaxy = document.createElement('div');
  422. const logo = document.createElement('div');
  423. splash.id = 'jupyterlab-splash';
  424. galaxy.id = 'galaxy';
  425. logo.id = 'main-logo';
  426. galaxy.appendChild(logo);
  427. ['1', '2', '3'].forEach(id => {
  428. const moon = document.createElement('div');
  429. const planet = document.createElement('div');
  430. moon.id = `moon${id}`;
  431. moon.className = 'moon orbit';
  432. planet.id = `planet${id}`;
  433. planet.className = 'planet';
  434. moon.appendChild(planet);
  435. galaxy.appendChild(moon);
  436. });
  437. splash.appendChild(galaxy);
  438. return splash;
  439. }
  440. /**
  441. * A debouncer for recovery attempts.
  442. */
  443. let debouncer = 0;
  444. /**
  445. * The recovery dialog.
  446. */
  447. let dialog: Dialog<any>;
  448. /**
  449. * Allows the user to clear state if splash screen takes too long.
  450. */
  451. function recover(fn: () => void): void {
  452. if (dialog) {
  453. return;
  454. }
  455. dialog = new Dialog({
  456. title: 'Loading...',
  457. body: `The loading screen is taking a long time.
  458. Would you like to clear the workspace or keep waiting?`,
  459. buttons: [
  460. Dialog.cancelButton({ label: 'Keep Waiting' }),
  461. Dialog.warnButton({ label: 'Clear Workspace' })
  462. ]
  463. });
  464. dialog.launch().then(result => {
  465. if (result.button.accept) {
  466. return fn();
  467. }
  468. dialog.dispose();
  469. dialog = null;
  470. debouncer = window.setTimeout(() => {
  471. recover(fn);
  472. }, SPLASH_RECOVER_TIMEOUT);
  473. }).catch(() => { /* no-op */ });
  474. }
  475. /**
  476. * Allows the user to clear state if splash screen takes too long.
  477. */
  478. export
  479. function redirect(router: IRouter, warn = false): Promise<void> {
  480. const form = createRedirectForm(warn);
  481. const dialog = new Dialog({
  482. title: 'We have a problem!',
  483. body: form,
  484. focusNodeSelector: 'input',
  485. buttons: [Dialog.okButton({ label: 'Create Workspace' })]
  486. });
  487. return dialog.launch().then(result => {
  488. dialog.dispose();
  489. if (result.value) {
  490. const url = `workspaces/${result.value}`;
  491. // Navigate to a new workspace URL and abandon this session altogether.
  492. router.navigate(url, { hard: true, silent: true });
  493. // This promise will never resolve because the application navigates
  494. // away to a new location. It only exists to satisfy the return type
  495. // of the `redirect` function.
  496. return new Promise<void>(() => { /* no-op */ });
  497. }
  498. return redirect(router, true);
  499. });
  500. }
  501. /**
  502. * The splash element.
  503. */
  504. const splash = createSplash();
  505. /**
  506. * The splash screen counter.
  507. */
  508. let splashCount = 0;
  509. /**
  510. * Show the splash element.
  511. *
  512. * @param ready - A promise that must be resolved before splash disappears.
  513. *
  514. * @param recovery - A function that recovers from a hanging splash.
  515. */
  516. export
  517. function showSplash(ready: Promise<any>, recovery: () => void): IDisposable {
  518. splash.classList.remove('splash-fade');
  519. splashCount++;
  520. if (debouncer) {
  521. window.clearTimeout(debouncer);
  522. }
  523. debouncer = window.setTimeout(() => {
  524. recover(recovery);
  525. }, SPLASH_RECOVER_TIMEOUT);
  526. document.body.appendChild(splash);
  527. return new DisposableDelegate(() => {
  528. ready.then(() => {
  529. if (--splashCount === 0) {
  530. if (debouncer) {
  531. window.clearTimeout(debouncer);
  532. debouncer = 0;
  533. }
  534. if (dialog) {
  535. dialog.dispose();
  536. dialog = null;
  537. }
  538. splash.classList.add('splash-fade');
  539. window.setTimeout(() => { document.body.removeChild(splash); }, 500);
  540. }
  541. });
  542. });
  543. }
  544. }