index.ts 15 KB

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