index.tsx 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. /**
  4. * @packageDocumentation
  5. * @module application-extension
  6. */
  7. import {
  8. ConnectionLost,
  9. IConnectionLost,
  10. ILabShell,
  11. ILabStatus,
  12. ILayoutRestorer,
  13. IRouter,
  14. ITreePathUpdater,
  15. JupyterFrontEnd,
  16. JupyterFrontEndContextMenu,
  17. JupyterFrontEndPlugin,
  18. JupyterLab,
  19. LabShell,
  20. LayoutRestorer,
  21. Router
  22. } from '@jupyterlab/application';
  23. import {
  24. Dialog,
  25. ICommandPalette,
  26. IWindowResolver,
  27. MenuFactory,
  28. showDialog,
  29. showErrorMessage
  30. } from '@jupyterlab/apputils';
  31. import { PageConfig, URLExt } from '@jupyterlab/coreutils';
  32. import {
  33. IPropertyInspectorProvider,
  34. SideBarPropertyInspectorProvider
  35. } from '@jupyterlab/property-inspector';
  36. import { ISettingRegistry, SettingRegistry } from '@jupyterlab/settingregistry';
  37. import { IStateDB } from '@jupyterlab/statedb';
  38. import { ITranslator, TranslationBundle } from '@jupyterlab/translation';
  39. import {
  40. buildIcon,
  41. ContextMenuSvg,
  42. jupyterIcon,
  43. RankedMenu
  44. } from '@jupyterlab/ui-components';
  45. import { each, iter, toArray } from '@lumino/algorithm';
  46. import { JSONExt, PromiseDelegate } from '@lumino/coreutils';
  47. import { DisposableDelegate, DisposableSet } from '@lumino/disposable';
  48. import { DockLayout, DockPanel, Widget } from '@lumino/widgets';
  49. import * as React from 'react';
  50. /**
  51. * Default context menu item rank
  52. */
  53. export const DEFAULT_CONTEXT_ITEM_RANK = 100;
  54. /**
  55. * The command IDs used by the application plugin.
  56. */
  57. namespace CommandIDs {
  58. export const activateNextTab: string = 'application:activate-next-tab';
  59. export const activatePreviousTab: string =
  60. 'application:activate-previous-tab';
  61. export const activateNextTabBar: string = 'application:activate-next-tab-bar';
  62. export const activatePreviousTabBar: string =
  63. 'application:activate-previous-tab-bar';
  64. export const close = 'application:close';
  65. export const closeOtherTabs = 'application:close-other-tabs';
  66. export const closeRightTabs = 'application:close-right-tabs';
  67. export const closeAll: string = 'application:close-all';
  68. export const setMode: string = 'application:set-mode';
  69. export const toggleMode: string = 'application:toggle-mode';
  70. export const toggleLeftArea: string = 'application:toggle-left-area';
  71. export const toggleRightArea: string = 'application:toggle-right-area';
  72. export const togglePresentationMode: string =
  73. 'application:toggle-presentation-mode';
  74. export const tree: string = 'router:tree';
  75. export const switchSidebar = 'sidebar:switch';
  76. }
  77. /**
  78. * A plugin to register the commands for the main application.
  79. */
  80. const mainCommands: JupyterFrontEndPlugin<void> = {
  81. id: '@jupyterlab/application-extension:commands',
  82. autoStart: true,
  83. requires: [ITranslator],
  84. optional: [ILabShell, ICommandPalette],
  85. activate: (
  86. app: JupyterFrontEnd,
  87. translator: ITranslator,
  88. labShell: ILabShell | null,
  89. palette: ICommandPalette | null
  90. ) => {
  91. const { commands, shell } = app;
  92. const trans = translator.load('jupyterlab');
  93. const category = trans.__('Main Area');
  94. // Add Command to override the JLab context menu.
  95. commands.addCommand(JupyterFrontEndContextMenu.contextMenu, {
  96. label: trans.__('Shift+Right Click for Browser Menu'),
  97. isEnabled: () => false,
  98. execute: () => void 0
  99. });
  100. // Returns the widget associated with the most recent contextmenu event.
  101. const contextMenuWidget = (): Widget | null => {
  102. const test = (node: HTMLElement) => !!node.dataset.id;
  103. const node = app.contextMenuHitTest(test);
  104. if (!node) {
  105. // Fall back to active widget if path cannot be obtained from event.
  106. return shell.currentWidget;
  107. }
  108. const matches = toArray(shell.widgets('main')).filter(
  109. widget => widget.id === node.dataset.id
  110. );
  111. if (matches.length < 1) {
  112. return shell.currentWidget;
  113. }
  114. return matches[0];
  115. };
  116. // Closes an array of widgets.
  117. const closeWidgets = (widgets: Array<Widget>): void => {
  118. widgets.forEach(widget => widget.close());
  119. };
  120. // Find the tab area for a widget within a specific dock area.
  121. const findTab = (
  122. area: DockLayout.AreaConfig,
  123. widget: Widget
  124. ): DockLayout.ITabAreaConfig | null => {
  125. switch (area.type) {
  126. case 'split-area': {
  127. const iterator = iter(area.children);
  128. let tab: DockLayout.ITabAreaConfig | null = null;
  129. let value: DockLayout.AreaConfig | undefined;
  130. do {
  131. value = iterator.next();
  132. if (value) {
  133. tab = findTab(value, widget);
  134. }
  135. } while (!tab && value);
  136. return tab;
  137. }
  138. case 'tab-area': {
  139. const { id } = widget;
  140. return area.widgets.some(widget => widget.id === id) ? area : null;
  141. }
  142. default:
  143. return null;
  144. }
  145. };
  146. // Find the tab area for a widget within the main dock area.
  147. const tabAreaFor = (widget: Widget): DockLayout.ITabAreaConfig | null => {
  148. const layout = labShell?.saveLayout();
  149. const mainArea = layout?.mainArea;
  150. if (!mainArea || PageConfig.getOption('mode') !== 'multiple-document') {
  151. return null;
  152. }
  153. const area = mainArea.dock?.main;
  154. if (!area) {
  155. return null;
  156. }
  157. return findTab(area, widget);
  158. };
  159. // Returns an array of all widgets to the right of a widget in a tab area.
  160. const widgetsRightOf = (widget: Widget): Array<Widget> => {
  161. const { id } = widget;
  162. const tabArea = tabAreaFor(widget);
  163. const widgets = tabArea ? tabArea.widgets || [] : [];
  164. const index = widgets.findIndex(widget => widget.id === id);
  165. if (index < 0) {
  166. return [];
  167. }
  168. return widgets.slice(index + 1);
  169. };
  170. commands.addCommand(CommandIDs.close, {
  171. label: () => trans.__('Close Tab'),
  172. isEnabled: () => {
  173. const widget = contextMenuWidget();
  174. return !!widget && widget.title.closable;
  175. },
  176. execute: () => {
  177. const widget = contextMenuWidget();
  178. if (widget) {
  179. widget.close();
  180. }
  181. }
  182. });
  183. commands.addCommand(CommandIDs.closeOtherTabs, {
  184. label: () => trans.__('Close All Other Tabs'),
  185. isEnabled: () => {
  186. // Ensure there are at least two widgets.
  187. const iterator = shell.widgets('main');
  188. return !!iterator.next() && !!iterator.next();
  189. },
  190. execute: () => {
  191. const widget = contextMenuWidget();
  192. if (!widget) {
  193. return;
  194. }
  195. const { id } = widget;
  196. const otherWidgets = toArray(shell.widgets('main')).filter(
  197. widget => widget.id !== id
  198. );
  199. closeWidgets(otherWidgets);
  200. }
  201. });
  202. commands.addCommand(CommandIDs.closeRightTabs, {
  203. label: () => trans.__('Close Tabs to Right'),
  204. isEnabled: () =>
  205. !!contextMenuWidget() &&
  206. widgetsRightOf(contextMenuWidget()!).length > 0,
  207. execute: () => {
  208. const widget = contextMenuWidget();
  209. if (!widget) {
  210. return;
  211. }
  212. closeWidgets(widgetsRightOf(widget));
  213. }
  214. });
  215. if (labShell) {
  216. commands.addCommand(CommandIDs.activateNextTab, {
  217. label: trans.__('Activate Next Tab'),
  218. execute: () => {
  219. labShell.activateNextTab();
  220. }
  221. });
  222. commands.addCommand(CommandIDs.activatePreviousTab, {
  223. label: trans.__('Activate Previous Tab'),
  224. execute: () => {
  225. labShell.activatePreviousTab();
  226. }
  227. });
  228. commands.addCommand(CommandIDs.activateNextTabBar, {
  229. label: trans.__('Activate Next Tab Bar'),
  230. execute: () => {
  231. labShell.activateNextTabBar();
  232. }
  233. });
  234. commands.addCommand(CommandIDs.activatePreviousTabBar, {
  235. label: trans.__('Activate Previous Tab Bar'),
  236. execute: () => {
  237. labShell.activatePreviousTabBar();
  238. }
  239. });
  240. commands.addCommand(CommandIDs.closeAll, {
  241. label: trans.__('Close All Tabs'),
  242. execute: () => {
  243. labShell.closeAll();
  244. }
  245. });
  246. commands.addCommand(CommandIDs.toggleLeftArea, {
  247. label: () => trans.__('Show Left Sidebar'),
  248. execute: () => {
  249. if (labShell.leftCollapsed) {
  250. labShell.expandLeft();
  251. } else {
  252. labShell.collapseLeft();
  253. if (labShell.currentWidget) {
  254. labShell.activateById(labShell.currentWidget.id);
  255. }
  256. }
  257. },
  258. isToggled: () => !labShell.leftCollapsed,
  259. isVisible: () => !labShell.isEmpty('left')
  260. });
  261. commands.addCommand(CommandIDs.toggleRightArea, {
  262. label: () => trans.__('Show Right Sidebar'),
  263. execute: () => {
  264. if (labShell.rightCollapsed) {
  265. labShell.expandRight();
  266. } else {
  267. labShell.collapseRight();
  268. if (labShell.currentWidget) {
  269. labShell.activateById(labShell.currentWidget.id);
  270. }
  271. }
  272. },
  273. isToggled: () => !labShell.rightCollapsed,
  274. isVisible: () => !labShell.isEmpty('right')
  275. });
  276. commands.addCommand(CommandIDs.togglePresentationMode, {
  277. label: () => trans.__('Presentation Mode'),
  278. execute: () => {
  279. labShell.presentationMode = !labShell.presentationMode;
  280. },
  281. isToggled: () => labShell.presentationMode,
  282. isVisible: () => true
  283. });
  284. commands.addCommand(CommandIDs.setMode, {
  285. isVisible: args => {
  286. const mode = args['mode'] as string;
  287. return mode === 'single-document' || mode === 'multiple-document';
  288. },
  289. execute: args => {
  290. const mode = args['mode'] as string;
  291. if (mode === 'single-document' || mode === 'multiple-document') {
  292. labShell.mode = mode;
  293. return;
  294. }
  295. throw new Error(`Unsupported application shell mode: ${mode}`);
  296. }
  297. });
  298. commands.addCommand(CommandIDs.toggleMode, {
  299. label: trans.__('Simple Interface'),
  300. isToggled: () => labShell.mode === 'single-document',
  301. execute: () => {
  302. const args =
  303. labShell.mode === 'multiple-document'
  304. ? { mode: 'single-document' }
  305. : { mode: 'multiple-document' };
  306. return commands.execute(CommandIDs.setMode, args);
  307. }
  308. });
  309. }
  310. if (palette) {
  311. [
  312. CommandIDs.activateNextTab,
  313. CommandIDs.activatePreviousTab,
  314. CommandIDs.activateNextTabBar,
  315. CommandIDs.activatePreviousTabBar,
  316. CommandIDs.close,
  317. CommandIDs.closeAll,
  318. CommandIDs.closeOtherTabs,
  319. CommandIDs.closeRightTabs,
  320. CommandIDs.toggleLeftArea,
  321. CommandIDs.toggleRightArea,
  322. CommandIDs.togglePresentationMode,
  323. CommandIDs.toggleMode
  324. ].forEach(command => palette.addItem({ command, category }));
  325. }
  326. }
  327. };
  328. /**
  329. * The main extension.
  330. */
  331. const main: JupyterFrontEndPlugin<ITreePathUpdater> = {
  332. id: '@jupyterlab/application-extension:main',
  333. requires: [
  334. IRouter,
  335. IWindowResolver,
  336. ITranslator,
  337. JupyterFrontEnd.ITreeResolver
  338. ],
  339. optional: [IConnectionLost],
  340. provides: ITreePathUpdater,
  341. activate: (
  342. app: JupyterFrontEnd,
  343. router: IRouter,
  344. resolver: IWindowResolver,
  345. translator: ITranslator,
  346. treeResolver: JupyterFrontEnd.ITreeResolver,
  347. connectionLost: IConnectionLost | null
  348. ) => {
  349. const trans = translator.load('jupyterlab');
  350. if (!(app instanceof JupyterLab)) {
  351. throw new Error(`${main.id} must be activated in JupyterLab.`);
  352. }
  353. // These two internal state variables are used to manage the two source
  354. // of the tree part of the URL being updated: 1) path of the active document,
  355. // 2) path of the default browser if the active main area widget isn't a document.
  356. let _docTreePath = '';
  357. let _defaultBrowserTreePath = '';
  358. function updateTreePath(treePath: string) {
  359. // Wait for tree resolver to finish before updating the path because it use the PageConfig['treePath']
  360. void treeResolver.paths.then(() => {
  361. _defaultBrowserTreePath = treePath;
  362. if (!_docTreePath) {
  363. const url = PageConfig.getUrl({ treePath });
  364. const path = URLExt.parse(url).pathname;
  365. router.navigate(path, { skipRouting: true });
  366. // Persist the new tree path to PageConfig as it is used elsewhere at runtime.
  367. PageConfig.setOption('treePath', treePath);
  368. }
  369. });
  370. }
  371. // Requiring the window resolver guarantees that the application extension
  372. // only loads if there is a viable window name. Otherwise, the application
  373. // will short-circuit and ask the user to navigate away.
  374. const workspace = resolver.name;
  375. console.debug(`Starting application in workspace: "${workspace}"`);
  376. // If there were errors registering plugins, tell the user.
  377. if (app.registerPluginErrors.length !== 0) {
  378. const body = (
  379. <pre>{app.registerPluginErrors.map(e => e.message).join('\n')}</pre>
  380. );
  381. void showErrorMessage(trans.__('Error Registering Plugins'), {
  382. message: body
  383. });
  384. }
  385. // If the application shell layout is modified,
  386. // trigger a refresh of the commands.
  387. app.shell.layoutModified.connect(() => {
  388. app.commands.notifyCommandChanged();
  389. });
  390. // Watch the mode and update the page URL to /lab or /doc to reflect the
  391. // change.
  392. app.shell.modeChanged.connect((_, args: DockPanel.Mode) => {
  393. const url = PageConfig.getUrl({ mode: args as string });
  394. const path = URLExt.parse(url).pathname;
  395. router.navigate(path, { skipRouting: true });
  396. // Persist this mode change to PageConfig as it is used elsewhere at runtime.
  397. PageConfig.setOption('mode', args as string);
  398. });
  399. // Wait for tree resolver to finish before updating the path because it use the PageConfig['treePath']
  400. void treeResolver.paths.then(() => {
  401. // Watch the path of the current widget in the main area and update the page
  402. // URL to reflect the change.
  403. app.shell.currentPathChanged.connect((_, args) => {
  404. const maybeTreePath = args.newValue as string;
  405. const treePath = maybeTreePath || _defaultBrowserTreePath;
  406. const url = PageConfig.getUrl({ treePath: treePath });
  407. const path = URLExt.parse(url).pathname;
  408. router.navigate(path, { skipRouting: true });
  409. // Persist the new tree path to PageConfig as it is used elsewhere at runtime.
  410. PageConfig.setOption('treePath', treePath);
  411. _docTreePath = maybeTreePath;
  412. });
  413. });
  414. // If the connection to the server is lost, handle it with the
  415. // connection lost handler.
  416. connectionLost = connectionLost || ConnectionLost;
  417. app.serviceManager.connectionFailure.connect((manager, error) =>
  418. connectionLost!(manager, error, translator)
  419. );
  420. const builder = app.serviceManager.builder;
  421. const build = () => {
  422. return builder
  423. .build()
  424. .then(() => {
  425. return showDialog({
  426. title: trans.__('Build Complete'),
  427. body: (
  428. <div>
  429. {trans.__('Build successfully completed, reload page?')}
  430. <br />
  431. {trans.__('You will lose any unsaved changes.')}
  432. </div>
  433. ),
  434. buttons: [
  435. Dialog.cancelButton({
  436. label: trans.__('Reload Without Saving'),
  437. actions: ['reload']
  438. }),
  439. Dialog.okButton({ label: trans.__('Save and Reload') })
  440. ],
  441. hasClose: true
  442. });
  443. })
  444. .then(({ button: { accept, actions } }) => {
  445. if (accept) {
  446. void app.commands
  447. .execute('docmanager:save')
  448. .then(() => {
  449. router.reload();
  450. })
  451. .catch(err => {
  452. void showErrorMessage(trans.__('Save Failed'), {
  453. message: <pre>{err.message}</pre>
  454. });
  455. });
  456. } else if (actions.includes('reload')) {
  457. router.reload();
  458. }
  459. })
  460. .catch(err => {
  461. void showErrorMessage(trans.__('Build Failed'), {
  462. message: <pre>{err.message}</pre>
  463. });
  464. });
  465. };
  466. if (builder.isAvailable && builder.shouldCheck) {
  467. void builder.getStatus().then(response => {
  468. if (response.status === 'building') {
  469. return build();
  470. }
  471. if (response.status !== 'needed') {
  472. return;
  473. }
  474. const body = (
  475. <div>
  476. {trans.__('JupyterLab build is suggested:')}
  477. <br />
  478. <pre>{response.message}</pre>
  479. </div>
  480. );
  481. void showDialog({
  482. title: trans.__('Build Recommended'),
  483. body,
  484. buttons: [
  485. Dialog.cancelButton(),
  486. Dialog.okButton({ label: trans.__('Build') })
  487. ]
  488. }).then(result => (result.button.accept ? build() : undefined));
  489. });
  490. }
  491. return updateTreePath;
  492. },
  493. autoStart: true
  494. };
  495. /**
  496. * Plugin to build the context menu from the settings.
  497. */
  498. const contextMenuPlugin: JupyterFrontEndPlugin<void> = {
  499. id: '@jupyterlab/application-extension:context-menu',
  500. autoStart: true,
  501. requires: [ISettingRegistry, ITranslator],
  502. activate: (
  503. app: JupyterFrontEnd,
  504. settingRegistry: ISettingRegistry,
  505. translator: ITranslator
  506. ): void => {
  507. const trans = translator.load('jupyterlab');
  508. function createMenu(options: ISettingRegistry.IMenu): RankedMenu {
  509. const menu = new RankedMenu({ ...options, commands: app.commands });
  510. if (options.label) {
  511. menu.title.label = trans.__(options.label);
  512. }
  513. return menu;
  514. }
  515. // Load the context menu lately so plugins are loaded.
  516. app.started
  517. .then(() => {
  518. return Private.loadSettingsContextMenu(
  519. app.contextMenu,
  520. settingRegistry,
  521. createMenu,
  522. translator
  523. );
  524. })
  525. .catch(reason => {
  526. console.error(
  527. 'Failed to load context menu items from settings registry.',
  528. reason
  529. );
  530. });
  531. }
  532. };
  533. /**
  534. * Check if the application is dirty before closing the browser tab.
  535. */
  536. const dirty: JupyterFrontEndPlugin<void> = {
  537. id: '@jupyterlab/application-extension:dirty',
  538. autoStart: true,
  539. requires: [ITranslator],
  540. activate: (app: JupyterFrontEnd, translator: ITranslator): void => {
  541. if (!(app instanceof JupyterLab)) {
  542. throw new Error(`${dirty.id} must be activated in JupyterLab.`);
  543. }
  544. const trans = translator.load('jupyterlab');
  545. const message = trans.__(
  546. 'Are you sure you want to exit JupyterLab?\n\nAny unsaved changes will be lost.'
  547. );
  548. // The spec for the `beforeunload` event is implemented differently by
  549. // the different browser vendors. Consequently, the `event.returnValue`
  550. // attribute needs to set in addition to a return value being returned.
  551. // For more information, see:
  552. // https://developer.mozilla.org/en/docs/Web/Events/beforeunload
  553. window.addEventListener('beforeunload', event => {
  554. if (app.status.isDirty) {
  555. return ((event as any).returnValue = message);
  556. }
  557. });
  558. }
  559. };
  560. /**
  561. * The default layout restorer provider.
  562. */
  563. const layout: JupyterFrontEndPlugin<ILayoutRestorer> = {
  564. id: '@jupyterlab/application-extension:layout',
  565. requires: [IStateDB, ILabShell, ISettingRegistry, ITranslator],
  566. activate: (
  567. app: JupyterFrontEnd,
  568. state: IStateDB,
  569. labShell: ILabShell,
  570. settingRegistry: ISettingRegistry,
  571. translator: ITranslator
  572. ) => {
  573. const first = app.started;
  574. const registry = app.commands;
  575. const restorer = new LayoutRestorer({ connector: state, first, registry });
  576. void restorer.fetch().then(saved => {
  577. labShell.restoreLayout(
  578. PageConfig.getOption('mode') as DockPanel.Mode,
  579. saved
  580. );
  581. labShell.layoutModified.connect(() => {
  582. void restorer.save(labShell.saveLayout());
  583. });
  584. Private.activateSidebarSwitcher(
  585. app,
  586. labShell,
  587. settingRegistry,
  588. translator,
  589. saved
  590. );
  591. });
  592. return restorer;
  593. },
  594. autoStart: true,
  595. provides: ILayoutRestorer
  596. };
  597. /**
  598. * The default URL router provider.
  599. */
  600. const router: JupyterFrontEndPlugin<IRouter> = {
  601. id: '@jupyterlab/application-extension:router',
  602. requires: [JupyterFrontEnd.IPaths],
  603. activate: (app: JupyterFrontEnd, paths: JupyterFrontEnd.IPaths) => {
  604. const { commands } = app;
  605. const base = paths.urls.base;
  606. const router = new Router({ base, commands });
  607. void app.started.then(() => {
  608. // Route the very first request on load.
  609. void router.route();
  610. // Route all pop state events.
  611. window.addEventListener('popstate', () => {
  612. void router.route();
  613. });
  614. });
  615. return router;
  616. },
  617. autoStart: true,
  618. provides: IRouter
  619. };
  620. /**
  621. * The default tree route resolver plugin.
  622. */
  623. const tree: JupyterFrontEndPlugin<JupyterFrontEnd.ITreeResolver> = {
  624. id: '@jupyterlab/application-extension:tree-resolver',
  625. autoStart: true,
  626. requires: [IRouter],
  627. provides: JupyterFrontEnd.ITreeResolver,
  628. activate: (
  629. app: JupyterFrontEnd,
  630. router: IRouter
  631. ): JupyterFrontEnd.ITreeResolver => {
  632. const { commands } = app;
  633. const set = new DisposableSet();
  634. const delegate = new PromiseDelegate<JupyterFrontEnd.ITreeResolver.Paths>();
  635. const treePattern = new RegExp(
  636. '/(lab|doc)(/workspaces/[a-zA-Z0-9-_]+)?(/tree/.*)?'
  637. );
  638. set.add(
  639. commands.addCommand(CommandIDs.tree, {
  640. execute: async (args: IRouter.ILocation) => {
  641. if (set.isDisposed) {
  642. return;
  643. }
  644. const query = URLExt.queryStringToObject(args.search ?? '');
  645. const browser = query['file-browser-path'] || '';
  646. // Remove the file browser path from the query string.
  647. delete query['file-browser-path'];
  648. // Clean up artifacts immediately upon routing.
  649. set.dispose();
  650. delegate.resolve({ browser, file: PageConfig.getOption('treePath') });
  651. }
  652. })
  653. );
  654. set.add(
  655. router.register({ command: CommandIDs.tree, pattern: treePattern })
  656. );
  657. // If a route is handled by the router without the tree command being
  658. // invoked, resolve to `null` and clean up artifacts.
  659. const listener = () => {
  660. if (set.isDisposed) {
  661. return;
  662. }
  663. set.dispose();
  664. delegate.resolve(null);
  665. };
  666. router.routed.connect(listener);
  667. set.add(
  668. new DisposableDelegate(() => {
  669. router.routed.disconnect(listener);
  670. })
  671. );
  672. return { paths: delegate.promise };
  673. }
  674. };
  675. /**
  676. * The default URL not found extension.
  677. */
  678. const notfound: JupyterFrontEndPlugin<void> = {
  679. id: '@jupyterlab/application-extension:notfound',
  680. requires: [JupyterFrontEnd.IPaths, IRouter, ITranslator],
  681. activate: (
  682. _: JupyterFrontEnd,
  683. paths: JupyterFrontEnd.IPaths,
  684. router: IRouter,
  685. translator: ITranslator
  686. ) => {
  687. const trans = translator.load('jupyterlab');
  688. const bad = paths.urls.notFound;
  689. if (!bad) {
  690. return;
  691. }
  692. const base = router.base;
  693. const message = trans.__(
  694. 'The path: %1 was not found. JupyterLab redirected to: %2',
  695. bad,
  696. base
  697. );
  698. // Change the URL back to the base application URL.
  699. router.navigate('');
  700. void showErrorMessage(trans.__('Path Not Found'), { message });
  701. },
  702. autoStart: true
  703. };
  704. /**
  705. * Change the favicon changing based on the busy status;
  706. */
  707. const busy: JupyterFrontEndPlugin<void> = {
  708. id: '@jupyterlab/application-extension:faviconbusy',
  709. requires: [ILabStatus],
  710. activate: async (_: JupyterFrontEnd, status: ILabStatus) => {
  711. status.busySignal.connect((_, isBusy) => {
  712. const favicon = document.querySelector(
  713. `link[rel="icon"]${isBusy ? '.idle.favicon' : '.busy.favicon'}`
  714. ) as HTMLLinkElement;
  715. if (!favicon) {
  716. return;
  717. }
  718. const newFavicon = document.querySelector(
  719. `link${isBusy ? '.busy.favicon' : '.idle.favicon'}`
  720. ) as HTMLLinkElement;
  721. if (!newFavicon) {
  722. return;
  723. }
  724. // If we have the two icons with the special classes, then toggle them.
  725. if (favicon !== newFavicon) {
  726. favicon.rel = '';
  727. newFavicon.rel = 'icon';
  728. // Firefox doesn't seem to recognize just changing rel, so we also
  729. // reinsert the link into the DOM.
  730. newFavicon.parentNode!.replaceChild(newFavicon, newFavicon);
  731. }
  732. });
  733. },
  734. autoStart: true
  735. };
  736. /**
  737. * The default JupyterLab application shell.
  738. */
  739. const shell: JupyterFrontEndPlugin<ILabShell> = {
  740. id: '@jupyterlab/application-extension:shell',
  741. activate: (app: JupyterFrontEnd) => {
  742. if (!(app.shell instanceof LabShell)) {
  743. throw new Error(`${shell.id} did not find a LabShell instance.`);
  744. }
  745. return app.shell;
  746. },
  747. autoStart: true,
  748. provides: ILabShell
  749. };
  750. /**
  751. * The default JupyterLab application status provider.
  752. */
  753. const status: JupyterFrontEndPlugin<ILabStatus> = {
  754. id: '@jupyterlab/application-extension:status',
  755. activate: (app: JupyterFrontEnd) => {
  756. if (!(app instanceof JupyterLab)) {
  757. throw new Error(`${status.id} must be activated in JupyterLab.`);
  758. }
  759. return app.status;
  760. },
  761. autoStart: true,
  762. provides: ILabStatus
  763. };
  764. /**
  765. * The default JupyterLab application-specific information provider.
  766. *
  767. * #### Notes
  768. * This plugin should only be used by plugins that specifically need to access
  769. * JupyterLab application information, e.g., listing extensions that have been
  770. * loaded or deferred within JupyterLab.
  771. */
  772. const info: JupyterFrontEndPlugin<JupyterLab.IInfo> = {
  773. id: '@jupyterlab/application-extension:info',
  774. activate: (app: JupyterFrontEnd) => {
  775. if (!(app instanceof JupyterLab)) {
  776. throw new Error(`${info.id} must be activated in JupyterLab.`);
  777. }
  778. return app.info;
  779. },
  780. autoStart: true,
  781. provides: JupyterLab.IInfo
  782. };
  783. /**
  784. * The default JupyterLab paths dictionary provider.
  785. */
  786. const paths: JupyterFrontEndPlugin<JupyterFrontEnd.IPaths> = {
  787. id: '@jupyterlab/apputils-extension:paths',
  788. activate: (app: JupyterFrontEnd): JupyterFrontEnd.IPaths => {
  789. if (!(app instanceof JupyterLab)) {
  790. throw new Error(`${paths.id} must be activated in JupyterLab.`);
  791. }
  792. return app.paths;
  793. },
  794. autoStart: true,
  795. provides: JupyterFrontEnd.IPaths
  796. };
  797. /**
  798. * The default property inspector provider.
  799. */
  800. const propertyInspector: JupyterFrontEndPlugin<IPropertyInspectorProvider> = {
  801. id: '@jupyterlab/application-extension:property-inspector',
  802. autoStart: true,
  803. requires: [ILabShell, ITranslator],
  804. optional: [ILayoutRestorer],
  805. provides: IPropertyInspectorProvider,
  806. activate: (
  807. app: JupyterFrontEnd,
  808. labshell: ILabShell,
  809. translator: ITranslator,
  810. restorer: ILayoutRestorer | null
  811. ) => {
  812. const trans = translator.load('jupyterlab');
  813. const widget = new SideBarPropertyInspectorProvider(
  814. labshell,
  815. undefined,
  816. translator
  817. );
  818. widget.title.icon = buildIcon;
  819. widget.title.caption = trans.__('Property Inspector');
  820. widget.id = 'jp-property-inspector';
  821. labshell.add(widget, 'right', { rank: 100 });
  822. if (restorer) {
  823. restorer.add(widget, 'jp-property-inspector');
  824. }
  825. return widget;
  826. }
  827. };
  828. const JupyterLogo: JupyterFrontEndPlugin<void> = {
  829. id: '@jupyterlab/application-extension:logo',
  830. autoStart: true,
  831. requires: [ILabShell],
  832. activate: (app: JupyterFrontEnd, shell: ILabShell) => {
  833. const logo = new Widget();
  834. jupyterIcon.element({
  835. container: logo.node,
  836. elementPosition: 'center',
  837. margin: '2px 2px 2px 8px',
  838. height: 'auto',
  839. width: '16px'
  840. });
  841. logo.id = 'jp-MainLogo';
  842. shell.add(logo, 'top', { rank: 0 });
  843. }
  844. };
  845. /**
  846. * Export the plugins as default.
  847. */
  848. const plugins: JupyterFrontEndPlugin<any>[] = [
  849. contextMenuPlugin,
  850. dirty,
  851. main,
  852. mainCommands,
  853. layout,
  854. router,
  855. tree,
  856. notfound,
  857. busy,
  858. shell,
  859. status,
  860. info,
  861. paths,
  862. propertyInspector,
  863. JupyterLogo
  864. ];
  865. export default plugins;
  866. namespace Private {
  867. type SidebarOverrides = { [id: string]: 'left' | 'right' };
  868. async function displayInformation(trans: TranslationBundle): Promise<void> {
  869. const result = await showDialog({
  870. title: trans.__('Information'),
  871. body: trans.__(
  872. 'Context menu customization has changed. You will need to reload JupyterLab to see the changes.'
  873. ),
  874. buttons: [
  875. Dialog.cancelButton(),
  876. Dialog.okButton({ label: trans.__('Reload') })
  877. ]
  878. });
  879. if (result.button.accept) {
  880. location.reload();
  881. }
  882. }
  883. export async function loadSettingsContextMenu(
  884. contextMenu: ContextMenuSvg,
  885. registry: ISettingRegistry,
  886. menuFactory: (options: ISettingRegistry.IMenu) => RankedMenu,
  887. translator: ITranslator
  888. ): Promise<void> {
  889. const trans = translator.load('jupyterlab');
  890. const pluginId = contextMenuPlugin.id;
  891. let canonical: ISettingRegistry.ISchema | null;
  892. let loaded: { [name: string]: ISettingRegistry.IContextMenuItem[] } = {};
  893. /**
  894. * Populate the plugin's schema defaults.
  895. *
  896. * We keep track of disabled entries in case the plugin is loaded
  897. * after the menu initialization.
  898. */
  899. function populate(schema: ISettingRegistry.ISchema) {
  900. loaded = {};
  901. schema.properties!.contextMenu.default = Object.keys(registry.plugins)
  902. .map(plugin => {
  903. const items =
  904. registry.plugins[plugin]!.schema['jupyter.lab.menus']?.context ??
  905. [];
  906. loaded[plugin] = items;
  907. return items;
  908. })
  909. .concat([
  910. schema['jupyter.lab.menus']?.context ?? [],
  911. schema.properties!.contextMenu.default as any[]
  912. ])
  913. .reduceRight(
  914. (
  915. acc: ISettingRegistry.IContextMenuItem[],
  916. val: ISettingRegistry.IContextMenuItem[]
  917. ) => SettingRegistry.reconcileItems(acc, val, true),
  918. []
  919. )! // flatten one level
  920. .sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity));
  921. }
  922. // Transform the plugin object to return different schema than the default.
  923. registry.transform(pluginId, {
  924. compose: plugin => {
  925. // Only override the canonical schema the first time.
  926. if (!canonical) {
  927. canonical = JSONExt.deepCopy(plugin.schema);
  928. populate(canonical);
  929. }
  930. const defaults = canonical.properties?.contextMenu?.default ?? [];
  931. const user = {
  932. contextMenu: plugin.data.user.contextMenu ?? []
  933. };
  934. const composite = {
  935. contextMenu: SettingRegistry.reconcileItems(
  936. defaults as ISettingRegistry.IContextMenuItem[],
  937. user.contextMenu as ISettingRegistry.IContextMenuItem[],
  938. false
  939. )
  940. };
  941. plugin.data = { composite, user };
  942. return plugin;
  943. },
  944. fetch: plugin => {
  945. // Only override the canonical schema the first time.
  946. if (!canonical) {
  947. canonical = JSONExt.deepCopy(plugin.schema);
  948. populate(canonical);
  949. }
  950. return {
  951. data: plugin.data,
  952. id: plugin.id,
  953. raw: plugin.raw,
  954. schema: canonical,
  955. version: plugin.version
  956. };
  957. }
  958. });
  959. // Repopulate the canonical variable after the setting registry has
  960. // preloaded all initial plugins.
  961. canonical = null;
  962. const settings = await registry.load(pluginId);
  963. const contextItems: ISettingRegistry.IContextMenuItem[] =
  964. (settings.composite.contextMenu as any) ?? [];
  965. // Create menu item for non-disabled element
  966. SettingRegistry.filterDisabledItems(contextItems).forEach(item => {
  967. MenuFactory.addContextItem(
  968. {
  969. // We have to set the default rank because Lumino is sorting the visible items
  970. rank: DEFAULT_CONTEXT_ITEM_RANK,
  971. ...item
  972. },
  973. contextMenu,
  974. menuFactory
  975. );
  976. });
  977. settings.changed.connect(() => {
  978. // As extension may change the context menu through API,
  979. // prompt the user to reload if the menu has been updated.
  980. const newItems = (settings.composite.contextMenu as any) ?? [];
  981. if (!JSONExt.deepEqual(contextItems, newItems)) {
  982. void displayInformation(trans);
  983. }
  984. });
  985. registry.pluginChanged.connect(async (sender, plugin) => {
  986. if (plugin !== pluginId) {
  987. // If the plugin changed its menu.
  988. const oldItems = loaded[plugin] ?? [];
  989. const newItems =
  990. registry.plugins[plugin]!.schema['jupyter.lab.menus']?.context ?? [];
  991. if (!JSONExt.deepEqual(oldItems, newItems)) {
  992. if (loaded[plugin]) {
  993. // The plugin has changed, request the user to reload the UI
  994. await displayInformation(trans);
  995. } else {
  996. // The plugin was not yet loaded when the menu was built => update the menu
  997. loaded[plugin] = JSONExt.deepCopy(newItems);
  998. // Merge potential disabled state
  999. const toAdd =
  1000. SettingRegistry.reconcileItems(
  1001. newItems,
  1002. contextItems,
  1003. false,
  1004. false
  1005. ) ?? [];
  1006. SettingRegistry.filterDisabledItems(toAdd).forEach(item => {
  1007. MenuFactory.addContextItem(
  1008. {
  1009. // We have to set the default rank because Lumino is sorting the visible items
  1010. rank: DEFAULT_CONTEXT_ITEM_RANK,
  1011. ...item
  1012. },
  1013. contextMenu,
  1014. menuFactory
  1015. );
  1016. });
  1017. }
  1018. }
  1019. }
  1020. });
  1021. }
  1022. export function activateSidebarSwitcher(
  1023. app: JupyterFrontEnd,
  1024. labShell: ILabShell,
  1025. settingRegistry: ISettingRegistry,
  1026. translator: ITranslator,
  1027. initial: ILabShell.ILayout
  1028. ): void {
  1029. const setting = '@jupyterlab/application-extension:sidebar';
  1030. const trans = translator.load('jupyterlab');
  1031. let overrides: SidebarOverrides = {};
  1032. const update = (_: ILabShell, layout: ILabShell.ILayout | void) => {
  1033. each(labShell.widgets('left'), widget => {
  1034. if (overrides[widget.id] && overrides[widget.id] === 'right') {
  1035. labShell.add(widget, 'right');
  1036. if (layout && layout.rightArea?.currentWidget === widget) {
  1037. labShell.activateById(widget.id);
  1038. }
  1039. }
  1040. });
  1041. each(labShell.widgets('right'), widget => {
  1042. if (overrides[widget.id] && overrides[widget.id] === 'left') {
  1043. labShell.add(widget, 'left');
  1044. if (layout && layout.leftArea?.currentWidget === widget) {
  1045. labShell.activateById(widget.id);
  1046. }
  1047. }
  1048. });
  1049. };
  1050. // Fetch overrides from the settings system.
  1051. void Promise.all([settingRegistry.load(setting), app.restored]).then(
  1052. ([settings]) => {
  1053. overrides = (settings.get('overrides').composite ||
  1054. {}) as SidebarOverrides;
  1055. settings.changed.connect(settings => {
  1056. overrides = (settings.get('overrides').composite ||
  1057. {}) as SidebarOverrides;
  1058. update(labShell);
  1059. });
  1060. labShell.layoutModified.connect(update);
  1061. update(labShell, initial);
  1062. }
  1063. );
  1064. // Add a command to switch a side panels's side
  1065. app.commands.addCommand(CommandIDs.switchSidebar, {
  1066. label: trans.__('Switch Sidebar Side'),
  1067. execute: () => {
  1068. // First, try to find the correct panel based on the application
  1069. // context menu click. Bail if we don't find a sidebar for the widget.
  1070. const contextNode: HTMLElement | undefined = app.contextMenuHitTest(
  1071. node => !!node.dataset.id
  1072. );
  1073. if (!contextNode) {
  1074. return;
  1075. }
  1076. const id = contextNode.dataset['id']!;
  1077. const leftPanel = document.getElementById('jp-left-stack');
  1078. const node = document.getElementById(id);
  1079. let side: 'left' | 'right';
  1080. if (leftPanel && node && leftPanel.contains(node)) {
  1081. side = 'right';
  1082. } else {
  1083. side = 'left';
  1084. }
  1085. // Move the panel to the other side.
  1086. return settingRegistry.set(setting, 'overrides', {
  1087. ...overrides,
  1088. [id]: side
  1089. });
  1090. }
  1091. });
  1092. }
  1093. }