index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  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. JupyterFrontEndPlugin
  10. } from '@jupyterlab/application';
  11. import {
  12. Dialog,
  13. ICommandPalette,
  14. ISplashScreen,
  15. IThemeManager,
  16. IWindowResolver,
  17. ThemeManager,
  18. WindowResolver,
  19. Printing
  20. } from '@jupyterlab/apputils';
  21. import {
  22. Debouncer,
  23. IRateLimiter,
  24. ISettingRegistry,
  25. IStateDB,
  26. SettingRegistry,
  27. StateDB,
  28. Throttler,
  29. URLExt
  30. } from '@jupyterlab/coreutils';
  31. import { IMainMenu } from '@jupyterlab/mainmenu';
  32. import { PromiseDelegate } from '@phosphor/coreutils';
  33. import { DisposableDelegate } from '@phosphor/disposable';
  34. import { Menu } from '@phosphor/widgets';
  35. import { Palette } from './palette';
  36. /**
  37. * The interval in milliseconds before recover options appear during splash.
  38. */
  39. const SPLASH_RECOVER_TIMEOUT = 12000;
  40. /**
  41. * The command IDs used by the apputils plugin.
  42. */
  43. namespace CommandIDs {
  44. export const changeTheme = 'apputils:change-theme';
  45. export const loadState = 'apputils:load-statedb';
  46. export const print = 'apputils:print';
  47. export const reset = 'apputils:reset';
  48. export const resetOnLoad = 'apputils:reset-on-load';
  49. }
  50. /**
  51. * The default command palette extension.
  52. */
  53. const palette: JupyterFrontEndPlugin<ICommandPalette> = {
  54. activate: Palette.activate,
  55. id: '@jupyterlab/apputils-extension:palette',
  56. provides: ICommandPalette,
  57. autoStart: true
  58. };
  59. /**
  60. * The default command palette's restoration extension.
  61. *
  62. * #### Notes
  63. * The command palette's restoration logic is handled separately from the
  64. * command palette provider extension because the layout restorer dependency
  65. * causes the command palette to be unavailable to other extensions earlier
  66. * in the application load cycle.
  67. */
  68. const paletteRestorer: JupyterFrontEndPlugin<void> = {
  69. activate: Palette.restore,
  70. id: '@jupyterlab/apputils-extension:palette-restorer',
  71. requires: [ILayoutRestorer],
  72. autoStart: true
  73. };
  74. /**
  75. * The default setting registry provider.
  76. */
  77. const settings: JupyterFrontEndPlugin<ISettingRegistry> = {
  78. id: '@jupyterlab/apputils-extension:settings',
  79. activate: async (app: JupyterFrontEnd): Promise<ISettingRegistry> => {
  80. const connector = app.serviceManager.settings;
  81. const plugins = (await connector.list()).values;
  82. return new SettingRegistry({ connector, plugins });
  83. },
  84. autoStart: true,
  85. provides: ISettingRegistry
  86. };
  87. /**
  88. * The default theme manager provider.
  89. */
  90. const themes: JupyterFrontEndPlugin<IThemeManager> = {
  91. id: '@jupyterlab/apputils-extension:themes',
  92. requires: [ISettingRegistry, JupyterFrontEnd.IPaths],
  93. optional: [ISplashScreen],
  94. activate: (
  95. app: JupyterFrontEnd,
  96. settings: ISettingRegistry,
  97. paths: JupyterFrontEnd.IPaths,
  98. splash: ISplashScreen | null
  99. ): IThemeManager => {
  100. const host = app.shell;
  101. const commands = app.commands;
  102. const url = URLExt.join(paths.urls.base, paths.urls.themes);
  103. const key = themes.id;
  104. const manager = new ThemeManager({ key, host, settings, splash, url });
  105. // Keep a synchronously set reference to the current theme,
  106. // since the asynchronous setting of the theme in `changeTheme`
  107. // can lead to an incorrect toggle on the currently used theme.
  108. let currentTheme: string;
  109. // Set data attributes on the application shell for the current theme.
  110. manager.themeChanged.connect((sender, args) => {
  111. currentTheme = args.newValue;
  112. document.body.dataset.jpThemeLight = String(
  113. manager.isLight(currentTheme)
  114. );
  115. document.body.dataset.jpThemeName = currentTheme;
  116. if (
  117. document.body.dataset.jpThemeScrollbars !==
  118. String(manager.themeScrollbars(currentTheme))
  119. ) {
  120. document.body.dataset.jpThemeScrollbars = String(
  121. manager.themeScrollbars(currentTheme)
  122. );
  123. }
  124. commands.notifyCommandChanged(CommandIDs.changeTheme);
  125. });
  126. commands.addCommand(CommandIDs.changeTheme, {
  127. label: args => {
  128. const theme = args['theme'] as string;
  129. return args['isPalette'] ? `Use ${theme} Theme` : theme;
  130. },
  131. isToggled: args => args['theme'] === currentTheme,
  132. execute: args => {
  133. const theme = args['theme'] as string;
  134. if (theme === manager.theme) {
  135. return;
  136. }
  137. return manager.setTheme(theme);
  138. }
  139. });
  140. return manager;
  141. },
  142. autoStart: true,
  143. provides: IThemeManager
  144. };
  145. /**
  146. * The default theme manager's UI command palette and main menu functionality.
  147. *
  148. * #### Notes
  149. * This plugin loads separately from the theme manager plugin in order to
  150. * prevent blocking of the theme manager while it waits for the command palette
  151. * and main menu to become available.
  152. */
  153. const themesPaletteMenu: JupyterFrontEndPlugin<void> = {
  154. id: '@jupyterlab/apputils-extension:themes-palette-menu',
  155. requires: [IThemeManager],
  156. optional: [ICommandPalette, IMainMenu],
  157. activate: (
  158. app: JupyterFrontEnd,
  159. manager: IThemeManager,
  160. palette: ICommandPalette | null,
  161. mainMenu: IMainMenu | null
  162. ): void => {
  163. const commands = app.commands;
  164. // If we have a main menu, add the theme manager to the settings menu.
  165. if (mainMenu) {
  166. const themeMenu = new Menu({ commands });
  167. themeMenu.title.label = 'JupyterLab Theme';
  168. void app.restored.then(() => {
  169. const command = CommandIDs.changeTheme;
  170. const isPalette = false;
  171. manager.themes.forEach(theme => {
  172. themeMenu.addItem({ command, args: { isPalette, theme } });
  173. });
  174. });
  175. mainMenu.settingsMenu.addGroup(
  176. [
  177. {
  178. type: 'submenu' as Menu.ItemType,
  179. submenu: themeMenu
  180. }
  181. ],
  182. 0
  183. );
  184. }
  185. // If we have a command palette, add theme switching options to it.
  186. if (palette) {
  187. void app.restored.then(() => {
  188. const category = 'Settings';
  189. const command = CommandIDs.changeTheme;
  190. const isPalette = true;
  191. manager.themes.forEach(theme => {
  192. palette.addItem({ command, args: { isPalette, theme }, category });
  193. });
  194. });
  195. }
  196. },
  197. autoStart: true
  198. };
  199. /**
  200. * The default window name resolver provider.
  201. */
  202. const resolver: JupyterFrontEndPlugin<IWindowResolver> = {
  203. id: '@jupyterlab/apputils-extension:resolver',
  204. autoStart: true,
  205. provides: IWindowResolver,
  206. requires: [JupyterFrontEnd.IPaths, IRouter],
  207. activate: async (
  208. _: JupyterFrontEnd,
  209. paths: JupyterFrontEnd.IPaths,
  210. router: IRouter
  211. ) => {
  212. const { hash, path, search } = router.current;
  213. const query = URLExt.queryStringToObject(search || '');
  214. const solver = new WindowResolver();
  215. const { urls } = paths;
  216. const match = path.match(new RegExp(`^${urls.workspaces}\/([^?\/]+)`));
  217. const workspace = (match && decodeURIComponent(match[1])) || '';
  218. const candidate = Private.candidate(paths, workspace);
  219. const rest = workspace
  220. ? path.replace(new RegExp(`^${urls.workspaces}\/${workspace}`), '')
  221. : path.replace(new RegExp(`^${urls.app}\/?`), '');
  222. try {
  223. await solver.resolve(candidate);
  224. return solver;
  225. } catch (error) {
  226. // Window resolution has failed so the URL must change. Return a promise
  227. // that never resolves to prevent the application from loading plugins
  228. // that rely on `IWindowResolver`.
  229. return new Promise<IWindowResolver>(() => {
  230. const { base, workspaces } = paths.urls;
  231. const pool =
  232. 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  233. const random = pool[Math.floor(Math.random() * pool.length)];
  234. const path = URLExt.join(base, workspaces, `auto-${random}`, rest);
  235. // Clone the originally requested workspace after redirecting.
  236. query['clone'] = workspace;
  237. const url = path + URLExt.objectToQueryString(query) + (hash || '');
  238. router.navigate(url, { hard: true });
  239. });
  240. }
  241. }
  242. };
  243. /**
  244. * The default splash screen provider.
  245. */
  246. const splash: JupyterFrontEndPlugin<ISplashScreen> = {
  247. id: '@jupyterlab/apputils-extension:splash',
  248. autoStart: true,
  249. provides: ISplashScreen,
  250. activate: app => {
  251. const { commands, restored } = app;
  252. // Create splash element and populate it.
  253. const splash = document.createElement('div');
  254. const galaxy = document.createElement('div');
  255. const logo = document.createElement('div');
  256. splash.id = 'jupyterlab-splash';
  257. galaxy.id = 'galaxy';
  258. logo.id = 'main-logo';
  259. galaxy.appendChild(logo);
  260. ['1', '2', '3'].forEach(id => {
  261. const moon = document.createElement('div');
  262. const planet = document.createElement('div');
  263. moon.id = `moon${id}`;
  264. moon.className = 'moon orbit';
  265. planet.id = `planet${id}`;
  266. planet.className = 'planet';
  267. moon.appendChild(planet);
  268. galaxy.appendChild(moon);
  269. });
  270. splash.appendChild(galaxy);
  271. // Create debounced recovery dialog function.
  272. let dialog: Dialog<any>;
  273. const recovery: IRateLimiter = new Throttler(async () => {
  274. if (dialog) {
  275. return;
  276. }
  277. dialog = new Dialog({
  278. title: 'Loading...',
  279. body: `The loading screen is taking a long time.
  280. Would you like to clear the workspace or keep waiting?`,
  281. buttons: [
  282. Dialog.cancelButton({ label: 'Keep Waiting' }),
  283. Dialog.warnButton({ label: 'Clear Workspace' })
  284. ]
  285. });
  286. try {
  287. const result = await dialog.launch();
  288. dialog.dispose();
  289. dialog = null;
  290. if (result.button.accept && commands.hasCommand(CommandIDs.reset)) {
  291. return commands.execute(CommandIDs.reset);
  292. }
  293. // Re-invoke the recovery timer in the next frame.
  294. requestAnimationFrame(() => {
  295. // Because recovery can be stopped, handle invocation rejection.
  296. void recovery.invoke().catch(_ => undefined);
  297. });
  298. } catch (error) {
  299. /* no-op */
  300. }
  301. }, SPLASH_RECOVER_TIMEOUT);
  302. // Return ISplashScreen.
  303. let splashCount = 0;
  304. return {
  305. show: (light = true) => {
  306. splash.classList.remove('splash-fade');
  307. splash.classList.toggle('light', light);
  308. splash.classList.toggle('dark', !light);
  309. splashCount++;
  310. document.body.appendChild(splash);
  311. // Because recovery can be stopped, handle invocation rejection.
  312. void recovery.invoke().catch(_ => undefined);
  313. return new DisposableDelegate(async () => {
  314. await restored;
  315. if (--splashCount === 0) {
  316. void recovery.stop();
  317. if (dialog) {
  318. dialog.dispose();
  319. dialog = null;
  320. }
  321. splash.classList.add('splash-fade');
  322. window.setTimeout(() => {
  323. document.body.removeChild(splash);
  324. }, 200);
  325. }
  326. });
  327. }
  328. };
  329. }
  330. };
  331. const print: JupyterFrontEndPlugin<void> = {
  332. id: '@jupyterlab/apputils-extension:print',
  333. autoStart: true,
  334. activate: (app: JupyterFrontEnd) => {
  335. app.commands.addCommand(CommandIDs.print, {
  336. label: 'Print...',
  337. isEnabled: () => {
  338. const widget = app.shell.currentWidget;
  339. return Printing.getPrintFunction(widget) !== null;
  340. },
  341. execute: async () => {
  342. const widget = app.shell.currentWidget;
  343. const printFunction = Printing.getPrintFunction(widget);
  344. if (printFunction) {
  345. await printFunction();
  346. }
  347. }
  348. });
  349. }
  350. };
  351. /**
  352. * The default state database for storing application state.
  353. *
  354. * #### Notes
  355. * If this extension is loaded with a window resolver, it will automatically add
  356. * state management commands, URL support for `clone` and `reset`, and workspace
  357. * auto-saving. Otherwise, it will return a simple in-memory state database.
  358. */
  359. const state: JupyterFrontEndPlugin<IStateDB> = {
  360. id: '@jupyterlab/apputils-extension:state',
  361. autoStart: true,
  362. provides: IStateDB,
  363. requires: [JupyterFrontEnd.IPaths, IRouter],
  364. optional: [ISplashScreen, IWindowResolver],
  365. activate: (
  366. app: JupyterFrontEnd,
  367. paths: JupyterFrontEnd.IPaths,
  368. router: IRouter,
  369. splash: ISplashScreen | null,
  370. resolver: IWindowResolver | null
  371. ) => {
  372. if (resolver === null) {
  373. return new StateDB();
  374. }
  375. let resolved = false;
  376. const { commands, serviceManager } = app;
  377. const { workspaces } = serviceManager;
  378. const workspace = resolver.name;
  379. const transform = new PromiseDelegate<StateDB.DataTransform>();
  380. const db = new StateDB({ transform: transform.promise });
  381. const save = new Debouncer(async () => {
  382. const id = workspace;
  383. const metadata = { id };
  384. const data = await db.toJSON();
  385. await workspaces.save(id, { data, metadata });
  386. });
  387. commands.addCommand(CommandIDs.loadState, {
  388. execute: async (args: IRouter.ILocation) => {
  389. // Since the command can be executed an arbitrary number of times, make
  390. // sure it is safe to call multiple times.
  391. if (resolved) {
  392. return;
  393. }
  394. const { hash, path, search } = args;
  395. const { urls } = paths;
  396. const query = URLExt.queryStringToObject(search || '');
  397. const clone =
  398. typeof query['clone'] === 'string'
  399. ? query['clone'] === ''
  400. ? URLExt.join(urls.base, urls.app)
  401. : URLExt.join(urls.base, urls.workspaces, query['clone'])
  402. : null;
  403. const source = clone || workspace || null;
  404. if (source === null) {
  405. console.error(`${CommandIDs.loadState} cannot load null workspace.`);
  406. return;
  407. }
  408. // Any time the local state database changes, save the workspace.
  409. db.changed.connect(() => void save.invoke(), db);
  410. try {
  411. const saved = await workspaces.fetch(source);
  412. // If this command is called after a reset, the state database
  413. // will already be resolved.
  414. if (!resolved) {
  415. resolved = true;
  416. transform.resolve({ type: 'overwrite', contents: saved.data });
  417. }
  418. } catch ({ message }) {
  419. console.log(`Fetching workspace "${workspace}" failed.`, message);
  420. // If the workspace does not exist, cancel the data transformation
  421. // and save a workspace with the current user state data.
  422. if (!resolved) {
  423. resolved = true;
  424. transform.resolve({ type: 'cancel', contents: null });
  425. }
  426. }
  427. if (source === clone) {
  428. // Maintain the query string parameters but remove `clone`.
  429. delete query['clone'];
  430. const url = path + URLExt.objectToQueryString(query) + hash;
  431. const cloned = save.invoke().then(() => router.stop);
  432. // After the state has been cloned, navigate to the URL.
  433. void cloned.then(() => {
  434. router.navigate(url);
  435. });
  436. return cloned;
  437. }
  438. // After the state database has finished loading, save it.
  439. await save.invoke();
  440. }
  441. });
  442. commands.addCommand(CommandIDs.reset, {
  443. label: 'Reset Application State',
  444. execute: async () => {
  445. await db.clear();
  446. await save.invoke();
  447. router.reload();
  448. }
  449. });
  450. commands.addCommand(CommandIDs.resetOnLoad, {
  451. execute: (args: IRouter.ILocation) => {
  452. const { hash, path, search } = args;
  453. const query = URLExt.queryStringToObject(search || '');
  454. const reset = 'reset' in query;
  455. const clone = 'clone' in query;
  456. if (!reset) {
  457. return;
  458. }
  459. // If a splash provider exists, launch the splash screen.
  460. const loading = splash
  461. ? splash.show()
  462. : new DisposableDelegate(() => undefined);
  463. // If the state database has already been resolved, resetting is
  464. // impossible without reloading.
  465. if (resolved) {
  466. return router.reload();
  467. }
  468. // Empty the state database.
  469. resolved = true;
  470. transform.resolve({ type: 'clear', contents: null });
  471. // Maintain the query string parameters but remove `reset`.
  472. delete query['reset'];
  473. const url = path + URLExt.objectToQueryString(query) + hash;
  474. const cleared = db.clear().then(() => router.stop);
  475. // After the state has been reset, navigate to the URL.
  476. if (clone) {
  477. void cleared.then(() => {
  478. router.navigate(url, { hard: true });
  479. });
  480. } else {
  481. void cleared.then(() => {
  482. router.navigate(url);
  483. loading.dispose();
  484. });
  485. }
  486. return cleared;
  487. }
  488. });
  489. router.register({
  490. command: CommandIDs.loadState,
  491. pattern: /.?/,
  492. rank: 30 // High priority: 30:100.
  493. });
  494. router.register({
  495. command: CommandIDs.resetOnLoad,
  496. pattern: /(\?reset|\&reset)($|&)/,
  497. rank: 20 // High priority: 20:100.
  498. });
  499. return db;
  500. }
  501. };
  502. /**
  503. * Export the plugins as default.
  504. */
  505. const plugins: JupyterFrontEndPlugin<any>[] = [
  506. palette,
  507. paletteRestorer,
  508. resolver,
  509. settings,
  510. state,
  511. splash,
  512. themes,
  513. themesPaletteMenu,
  514. print
  515. ];
  516. export default plugins;
  517. /**
  518. * The namespace for module private data.
  519. */
  520. namespace Private {
  521. /**
  522. * Generate a workspace name candidate.
  523. *
  524. * @param workspace - A potential workspace name parsed from the URL.
  525. *
  526. * @returns A workspace name candidate.
  527. */
  528. export function candidate(
  529. { urls }: JupyterFrontEnd.IPaths,
  530. workspace = ''
  531. ): string {
  532. return workspace
  533. ? URLExt.join(urls.workspaces, workspace)
  534. : URLExt.join(urls.app);
  535. }
  536. }