index.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import { toArray, iter } from '@phosphor/algorithm';
  4. import { Widget, DockLayout } from '@phosphor/widgets';
  5. import {
  6. ILabShell,
  7. ILabStatus,
  8. JupyterFrontEnd,
  9. JupyterFrontEndPlugin
  10. } from '@jupyterlab/application';
  11. import {
  12. showDialog,
  13. showErrorMessage,
  14. Dialog,
  15. ICommandPalette
  16. } from '@jupyterlab/apputils';
  17. import { IChangedArgs, ISettingRegistry, Time } from '@jupyterlab/coreutils';
  18. import {
  19. renameDialog,
  20. getOpenPath,
  21. DocumentManager,
  22. IDocumentManager,
  23. PathStatus,
  24. SavingStatus
  25. } from '@jupyterlab/docmanager';
  26. import { DocumentRegistry } from '@jupyterlab/docregistry';
  27. import { IMainMenu } from '@jupyterlab/mainmenu';
  28. import { Contents, Kernel } from '@jupyterlab/services';
  29. import { IStatusBar } from '@jupyterlab/statusbar';
  30. import { IDisposable } from '@phosphor/disposable';
  31. /**
  32. * The command IDs used by the document manager plugin.
  33. */
  34. namespace CommandIDs {
  35. export const clone = 'docmanager:clone';
  36. export const close = 'docmanager:close';
  37. export const closeAllFiles = 'docmanager:close-all-files';
  38. export const closeOtherTabs = 'docmanager:close-other-tabs';
  39. export const closeRightTabs = 'docmanager:close-right-tabs';
  40. export const deleteFile = 'docmanager:delete-file';
  41. export const newUntitled = 'docmanager:new-untitled';
  42. export const open = 'docmanager:open';
  43. export const openBrowserTab = 'docmanager:open-browser-tab';
  44. export const openDirect = 'docmanager:open-direct';
  45. export const reload = 'docmanager:reload';
  46. export const rename = 'docmanager:rename';
  47. export const restoreCheckpoint = 'docmanager:restore-checkpoint';
  48. export const save = 'docmanager:save';
  49. export const saveAll = 'docmanager:save-all';
  50. export const saveAs = 'docmanager:save-as';
  51. export const toggleAutosave = 'docmanager:toggle-autosave';
  52. export const showInFileBrowser = 'docmanager:show-in-file-browser';
  53. }
  54. const pluginId = '@jupyterlab/docmanager-extension:plugin';
  55. /**
  56. * The default document manager provider.
  57. */
  58. const docManagerPlugin: JupyterFrontEndPlugin<IDocumentManager> = {
  59. id: pluginId,
  60. provides: IDocumentManager,
  61. requires: [ISettingRegistry],
  62. optional: [ILabStatus, ICommandPalette, ILabShell, IMainMenu],
  63. activate: (
  64. app: JupyterFrontEnd,
  65. settingRegistry: ISettingRegistry,
  66. status: ILabStatus | null,
  67. palette: ICommandPalette | null,
  68. labShell: ILabShell | null,
  69. mainMenu: IMainMenu | null
  70. ): IDocumentManager => {
  71. const { shell } = app;
  72. const manager = app.serviceManager;
  73. const contexts = new WeakSet<DocumentRegistry.Context>();
  74. const opener: DocumentManager.IWidgetOpener = {
  75. open: (widget, options) => {
  76. if (!widget.id) {
  77. widget.id = `document-manager-${++Private.id}`;
  78. }
  79. widget.title.dataset = {
  80. type: 'document-title',
  81. ...widget.title.dataset
  82. };
  83. if (!widget.isAttached) {
  84. shell.add(widget, 'main', options || {});
  85. }
  86. shell.activateById(widget.id);
  87. // Handle dirty state for open documents.
  88. let context = docManager.contextForWidget(widget);
  89. if (!contexts.has(context)) {
  90. handleContext(status, context);
  91. contexts.add(context);
  92. }
  93. }
  94. };
  95. const registry = app.docRegistry;
  96. const when = app.restored.then(() => void 0);
  97. const docManager = new DocumentManager({
  98. registry,
  99. manager,
  100. opener,
  101. when,
  102. setBusy: status.setBusy.bind(app)
  103. });
  104. // Register the file operations commands.
  105. addCommands(
  106. app,
  107. docManager,
  108. opener,
  109. settingRegistry,
  110. labShell,
  111. palette,
  112. mainMenu
  113. );
  114. // Keep up to date with the settings registry.
  115. const onSettingsUpdated = (settings: ISettingRegistry.ISettings) => {
  116. const autosave = settings.get('autosave').composite as boolean | null;
  117. docManager.autosave =
  118. autosave === true || autosave === false ? autosave : true;
  119. app.commands.notifyCommandChanged(CommandIDs.toggleAutosave);
  120. const autosaveInterval = settings.get('autosaveInterval').composite as
  121. | number
  122. | null;
  123. docManager.autosaveInterval = autosaveInterval || 120;
  124. };
  125. // Fetch the initial state of the settings.
  126. Promise.all([settingRegistry.load(pluginId), app.restored])
  127. .then(([settings]) => {
  128. settings.changed.connect(onSettingsUpdated);
  129. onSettingsUpdated(settings);
  130. })
  131. .catch((reason: Error) => {
  132. console.error(reason.message);
  133. });
  134. return docManager;
  135. }
  136. };
  137. /**
  138. * A plugin for adding a saving status item to the status bar.
  139. */
  140. export const savingStatusPlugin: JupyterFrontEndPlugin<void> = {
  141. id: '@jupyterlab/docmanager-extension:saving-status',
  142. autoStart: true,
  143. requires: [IStatusBar, IDocumentManager, ILabShell],
  144. activate: (
  145. _: JupyterFrontEnd,
  146. statusBar: IStatusBar,
  147. docManager: IDocumentManager,
  148. labShell: ILabShell
  149. ) => {
  150. const saving = new SavingStatus({ docManager });
  151. // Keep the currently active widget synchronized.
  152. saving.model!.widget = labShell.currentWidget;
  153. labShell.currentChanged.connect(() => {
  154. saving.model!.widget = labShell.currentWidget;
  155. });
  156. statusBar.registerStatusItem(savingStatusPlugin.id, {
  157. item: saving,
  158. align: 'middle',
  159. isActive: () => true,
  160. activeStateChanged: saving.model!.stateChanged
  161. });
  162. }
  163. };
  164. /**
  165. * A plugin providing a file path widget to the status bar.
  166. */
  167. export const pathStatusPlugin: JupyterFrontEndPlugin<void> = {
  168. id: '@jupyterlab/docmanager-extension:path-status',
  169. autoStart: true,
  170. requires: [IStatusBar, IDocumentManager, ILabShell],
  171. activate: (
  172. _: JupyterFrontEnd,
  173. statusBar: IStatusBar,
  174. docManager: IDocumentManager,
  175. labShell: ILabShell
  176. ) => {
  177. const path = new PathStatus({ docManager });
  178. // Keep the file path widget up-to-date with the application active widget.
  179. path.model!.widget = labShell.currentWidget;
  180. labShell.currentChanged.connect(() => {
  181. path.model!.widget = labShell.currentWidget;
  182. });
  183. statusBar.registerStatusItem(pathStatusPlugin.id, {
  184. item: path,
  185. align: 'right',
  186. rank: 0,
  187. isActive: () => true
  188. });
  189. }
  190. };
  191. /**
  192. * Export the plugins as default.
  193. */
  194. const plugins: JupyterFrontEndPlugin<any>[] = [
  195. docManagerPlugin,
  196. pathStatusPlugin,
  197. savingStatusPlugin
  198. ];
  199. export default plugins;
  200. /* Widget to display the revert to checkpoint confirmation. */
  201. class RevertConfirmWidget extends Widget {
  202. /**
  203. * Construct a new revert confirmation widget.
  204. */
  205. constructor(
  206. checkpoint: Contents.ICheckpointModel,
  207. fileType: string = 'notebook'
  208. ) {
  209. super({ node: Private.createRevertConfirmNode(checkpoint, fileType) });
  210. }
  211. }
  212. // Returns the file type for a widget.
  213. function fileType(widget: Widget, docManager: IDocumentManager): string {
  214. if (!widget) {
  215. return 'File';
  216. }
  217. const context = docManager.contextForWidget(widget);
  218. if (!context) {
  219. return '';
  220. }
  221. const fts = docManager.registry.getFileTypesForPath(context.path);
  222. return fts.length && fts[0].displayName ? fts[0].displayName : 'File';
  223. }
  224. /**
  225. * Add the file operations commands to the application's command registry.
  226. */
  227. function addCommands(
  228. app: JupyterFrontEnd,
  229. docManager: IDocumentManager,
  230. opener: DocumentManager.IWidgetOpener,
  231. settingRegistry: ISettingRegistry,
  232. labShell: ILabShell | null,
  233. palette: ICommandPalette | null,
  234. mainMenu: IMainMenu | null
  235. ): void {
  236. const { commands, shell } = app;
  237. const category = 'File Operations';
  238. const isEnabled = () => {
  239. const { currentWidget } = shell;
  240. return !!(currentWidget && docManager.contextForWidget(currentWidget));
  241. };
  242. const isWritable = () => {
  243. const { currentWidget } = shell;
  244. if (!currentWidget) {
  245. return false;
  246. }
  247. const context = docManager.contextForWidget(currentWidget);
  248. return !!(
  249. context &&
  250. context.contentsModel &&
  251. context.contentsModel.writable
  252. );
  253. };
  254. // If inside a rich application like JupyterLab, add additional functionality.
  255. if (labShell) {
  256. addLabCommands(app, docManager, labShell, opener, palette);
  257. }
  258. commands.addCommand(CommandIDs.close, {
  259. label: () => {
  260. const widget = shell.currentWidget;
  261. let name = 'File';
  262. if (widget) {
  263. const typeName = fileType(widget, docManager);
  264. name = typeName || widget.title.label;
  265. }
  266. return `Close ${name}`;
  267. },
  268. isEnabled: () =>
  269. !!shell.currentWidget && !!shell.currentWidget.title.closable,
  270. execute: () => {
  271. if (shell.currentWidget) {
  272. shell.currentWidget.close();
  273. }
  274. }
  275. });
  276. commands.addCommand(CommandIDs.deleteFile, {
  277. label: () => `Delete ${fileType(shell.currentWidget, docManager)}`,
  278. execute: args => {
  279. const path =
  280. typeof args['path'] === 'undefined' ? '' : (args['path'] as string);
  281. if (!path) {
  282. const command = CommandIDs.deleteFile;
  283. throw new Error(`A non-empty path is required for ${command}.`);
  284. }
  285. return docManager.deleteFile(path);
  286. }
  287. });
  288. commands.addCommand(CommandIDs.newUntitled, {
  289. execute: args => {
  290. const errorTitle = (args['error'] as string) || 'Error';
  291. const path =
  292. typeof args['path'] === 'undefined' ? '' : (args['path'] as string);
  293. let options: Partial<Contents.ICreateOptions> = {
  294. type: args['type'] as Contents.ContentType,
  295. path
  296. };
  297. if (args['type'] === 'file') {
  298. options.ext = (args['ext'] as string) || '.txt';
  299. }
  300. return docManager.services.contents
  301. .newUntitled(options)
  302. .catch(error => showErrorMessage(errorTitle, error));
  303. },
  304. label: args => (args['label'] as string) || `New ${args['type'] as string}`
  305. });
  306. commands.addCommand(CommandIDs.open, {
  307. execute: args => {
  308. const path =
  309. typeof args['path'] === 'undefined' ? '' : (args['path'] as string);
  310. const factory = (args['factory'] as string) || void 0;
  311. const kernel = (args['kernel'] as Kernel.IModel) || void 0;
  312. const options =
  313. (args['options'] as DocumentRegistry.IOpenOptions) || void 0;
  314. return docManager.services.contents
  315. .get(path, { content: false })
  316. .then(() => docManager.openOrReveal(path, factory, kernel, options));
  317. },
  318. icon: args => (args['icon'] as string) || '',
  319. label: args => (args['label'] || args['factory']) as string,
  320. mnemonic: args => (args['mnemonic'] as number) || -1
  321. });
  322. commands.addCommand(CommandIDs.openBrowserTab, {
  323. execute: args => {
  324. const path =
  325. typeof args['path'] === 'undefined' ? '' : (args['path'] as string);
  326. if (!path) {
  327. return;
  328. }
  329. return docManager.services.contents.getDownloadUrl(path).then(url => {
  330. const opened = window.open();
  331. opened.opener = null;
  332. opened.location.href = url;
  333. });
  334. },
  335. icon: args => (args['icon'] as string) || '',
  336. label: () => 'Open in New Browser Tab'
  337. });
  338. commands.addCommand(CommandIDs.openDirect, {
  339. label: () => 'Open From Path...',
  340. caption: 'Open from path',
  341. isEnabled: () => true,
  342. execute: () => {
  343. return getOpenPath(docManager.services.contents).then(path => {
  344. if (!path) {
  345. return;
  346. }
  347. docManager.services.contents.get(path, { content: false }).then(
  348. args => {
  349. // exists
  350. return commands.execute(CommandIDs.open, { path: path });
  351. },
  352. () => {
  353. // does not exist
  354. return showDialog({
  355. title: 'Cannot open',
  356. body: 'File not found',
  357. buttons: [Dialog.okButton()]
  358. });
  359. }
  360. );
  361. return;
  362. });
  363. }
  364. });
  365. commands.addCommand(CommandIDs.reload, {
  366. label: () =>
  367. `Reload ${fileType(shell.currentWidget, docManager)} from Disk`,
  368. caption: 'Reload contents from disk',
  369. isEnabled,
  370. execute: () => {
  371. if (!isEnabled()) {
  372. return;
  373. }
  374. const context = docManager.contextForWidget(shell.currentWidget);
  375. const type = fileType(shell.currentWidget, docManager);
  376. return showDialog({
  377. title: `Reload ${type} from Disk`,
  378. body: `Are you sure you want to reload
  379. the ${type} from the disk?`,
  380. buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Reload' })]
  381. }).then(result => {
  382. if (result.button.accept && !context.isDisposed) {
  383. return context.revert();
  384. }
  385. });
  386. }
  387. });
  388. commands.addCommand(CommandIDs.restoreCheckpoint, {
  389. label: () =>
  390. `Revert ${fileType(shell.currentWidget, docManager)} to Checkpoint`,
  391. caption: 'Revert contents to previous checkpoint',
  392. isEnabled,
  393. execute: () => {
  394. if (!isEnabled()) {
  395. return;
  396. }
  397. const context = docManager.contextForWidget(shell.currentWidget);
  398. return context.listCheckpoints().then(checkpoints => {
  399. if (checkpoints.length < 1) {
  400. return;
  401. }
  402. const lastCheckpoint = checkpoints[checkpoints.length - 1];
  403. if (!lastCheckpoint) {
  404. return;
  405. }
  406. const type = fileType(shell.currentWidget, docManager);
  407. return showDialog({
  408. title: `Revert ${type} to checkpoint`,
  409. body: new RevertConfirmWidget(lastCheckpoint, type),
  410. buttons: [
  411. Dialog.cancelButton(),
  412. Dialog.warnButton({ label: 'Revert' })
  413. ]
  414. }).then(result => {
  415. if (context.isDisposed) {
  416. return;
  417. }
  418. if (result.button.accept) {
  419. if (context.model.readOnly) {
  420. return context.revert();
  421. }
  422. return context.restoreCheckpoint().then(() => context.revert());
  423. }
  424. });
  425. });
  426. }
  427. });
  428. commands.addCommand(CommandIDs.save, {
  429. label: () => `Save ${fileType(shell.currentWidget, docManager)}`,
  430. caption: 'Save and create checkpoint',
  431. isEnabled: isWritable,
  432. execute: () => {
  433. if (isEnabled()) {
  434. let context = docManager.contextForWidget(shell.currentWidget);
  435. if (context.model.readOnly) {
  436. return showDialog({
  437. title: 'Cannot Save',
  438. body: 'Document is read-only',
  439. buttons: [Dialog.okButton()]
  440. });
  441. }
  442. return context
  443. .save()
  444. .then(() => context.createCheckpoint())
  445. .catch(err => {
  446. // If the save was canceled by user-action, do nothing.
  447. if (err.message === 'Cancel') {
  448. return;
  449. }
  450. throw err;
  451. });
  452. }
  453. }
  454. });
  455. commands.addCommand(CommandIDs.saveAll, {
  456. label: () => 'Save All',
  457. caption: 'Save all open documents',
  458. isEnabled: () => {
  459. const iterator = shell.widgets('main');
  460. let widget = iterator.next();
  461. while (widget) {
  462. let context = docManager.contextForWidget(widget);
  463. if (
  464. context &&
  465. context.contentsModel &&
  466. context.contentsModel.writable
  467. ) {
  468. return true;
  469. }
  470. widget = iterator.next();
  471. }
  472. // disable saveAll if all of the widgets models
  473. // have writable === false
  474. return false;
  475. },
  476. execute: () => {
  477. const iterator = shell.widgets('main');
  478. const promises: Promise<void>[] = [];
  479. const paths = new Set<string>(); // Cache so we don't double save files.
  480. let widget = iterator.next();
  481. while (widget) {
  482. const context = docManager.contextForWidget(widget);
  483. if (context && !context.model.readOnly && !paths.has(context.path)) {
  484. paths.add(context.path);
  485. promises.push(context.save());
  486. }
  487. widget = iterator.next();
  488. }
  489. return Promise.all(promises);
  490. }
  491. });
  492. commands.addCommand(CommandIDs.saveAs, {
  493. label: () => `Save ${fileType(shell.currentWidget, docManager)} As…`,
  494. caption: 'Save with new path',
  495. isEnabled,
  496. execute: () => {
  497. if (isEnabled()) {
  498. let context = docManager.contextForWidget(shell.currentWidget);
  499. return context.saveAs();
  500. }
  501. }
  502. });
  503. commands.addCommand(CommandIDs.toggleAutosave, {
  504. label: 'Autosave Documents',
  505. isToggled: () => docManager.autosave,
  506. execute: () => {
  507. const value = !docManager.autosave;
  508. const key = 'autosave';
  509. return settingRegistry
  510. .set(pluginId, key, value)
  511. .catch((reason: Error) => {
  512. console.error(`Failed to set ${pluginId}:${key} - ${reason.message}`);
  513. });
  514. }
  515. });
  516. // .jp-mod-current added so that the console-creation command is only shown
  517. // on the current document.
  518. // Otherwise it will delegate to the wrong widget.
  519. app.contextMenu.addItem({
  520. command: 'filemenu:create-console',
  521. selector: '[data-type="document-title"].jp-mod-current',
  522. rank: 6
  523. });
  524. if (palette) {
  525. [
  526. CommandIDs.close,
  527. CommandIDs.openDirect,
  528. CommandIDs.reload,
  529. CommandIDs.restoreCheckpoint,
  530. CommandIDs.save,
  531. CommandIDs.saveAs,
  532. CommandIDs.toggleAutosave
  533. ].forEach(command => {
  534. palette.addItem({ command, category });
  535. });
  536. }
  537. if (mainMenu) {
  538. mainMenu.settingsMenu.addGroup([{ command: CommandIDs.toggleAutosave }], 5);
  539. }
  540. }
  541. function addLabCommands(
  542. app: JupyterFrontEnd,
  543. docManager: IDocumentManager,
  544. labShell: ILabShell,
  545. opener: DocumentManager.IWidgetOpener,
  546. palette: ICommandPalette | null
  547. ): void {
  548. const { commands } = app;
  549. const category = 'File Operations';
  550. // Returns the doc widget associated with the most recent contextmenu event.
  551. const contextMenuWidget = (): Widget => {
  552. const pathRe = /[Pp]ath:\s?(.*)\n?/;
  553. const test = (node: HTMLElement) =>
  554. node['title'] && !!node['title'].match(pathRe);
  555. const node = app.contextMenuFirst(test);
  556. if (!node) {
  557. // Fall back to active doc widget if path cannot be obtained from event.
  558. return labShell.currentWidget;
  559. }
  560. const pathMatch = node['title'].match(pathRe);
  561. return docManager.findWidget(pathMatch[1]);
  562. };
  563. // Closes an array of widgets.
  564. const closeWidgets = (widgets: Array<Widget>): void => {
  565. widgets.forEach(widget => widget.close());
  566. };
  567. // Find the tab area for a widget within a specific dock area.
  568. const findTab = (
  569. area: DockLayout.AreaConfig,
  570. widget: Widget
  571. ): DockLayout.ITabAreaConfig | null => {
  572. switch (area.type) {
  573. case 'split-area':
  574. const iterator = iter(area.children);
  575. let tab: DockLayout.ITabAreaConfig | null = null;
  576. let value: DockLayout.AreaConfig | null = null;
  577. do {
  578. value = iterator.next();
  579. if (value) {
  580. tab = findTab(value, widget);
  581. }
  582. } while (!tab && value);
  583. return tab;
  584. case 'tab-area':
  585. const { id } = widget;
  586. return area.widgets.some(widget => widget.id === id) ? area : null;
  587. default:
  588. return null;
  589. }
  590. };
  591. // Returns `true` if the current widget has a document context.
  592. const isEnabled = () => {
  593. const { currentWidget } = labShell;
  594. return !!(currentWidget && docManager.contextForWidget(currentWidget));
  595. };
  596. // Find the tab area for a widget within the main dock area.
  597. const tabAreaFor = (widget: Widget): DockLayout.ITabAreaConfig | null => {
  598. const { mainArea } = labShell.saveLayout();
  599. if (mainArea.mode !== 'multiple-document') {
  600. return null;
  601. }
  602. let area = mainArea.dock.main;
  603. if (!area) {
  604. return null;
  605. }
  606. return findTab(area, widget);
  607. };
  608. // Returns an array of all widgets to the right of a widget in a tab area.
  609. const widgetsRightOf = (widget: Widget): Array<Widget> => {
  610. const { id } = widget;
  611. const tabArea = tabAreaFor(widget);
  612. const widgets = tabArea ? tabArea.widgets || [] : [];
  613. const index = widgets.findIndex(widget => widget.id === id);
  614. if (index < 0) {
  615. return [];
  616. }
  617. return widgets.slice(index + 1);
  618. };
  619. commands.addCommand(CommandIDs.clone, {
  620. label: () => `New View for ${fileType(contextMenuWidget(), docManager)}`,
  621. isEnabled,
  622. execute: args => {
  623. const widget = contextMenuWidget();
  624. const options = (args['options'] as DocumentRegistry.IOpenOptions) || {
  625. mode: 'split-right'
  626. };
  627. if (!widget) {
  628. return;
  629. }
  630. // Clone the widget.
  631. let child = docManager.cloneWidget(widget);
  632. if (child) {
  633. opener.open(child, options);
  634. }
  635. }
  636. });
  637. commands.addCommand(CommandIDs.closeAllFiles, {
  638. label: 'Close All',
  639. execute: () => {
  640. labShell.closeAll();
  641. }
  642. });
  643. commands.addCommand(CommandIDs.closeOtherTabs, {
  644. label: () => `Close Other Tabs`,
  645. isEnabled: () => {
  646. // Ensure there are at least two widgets.
  647. const iterator = labShell.widgets('main');
  648. return !!iterator.next() && !!iterator.next();
  649. },
  650. execute: () => {
  651. const widget = contextMenuWidget();
  652. if (!widget) {
  653. return;
  654. }
  655. const { id } = widget;
  656. const otherWidgets = toArray(labShell.widgets('main')).filter(
  657. widget => widget.id !== id
  658. );
  659. closeWidgets(otherWidgets);
  660. }
  661. });
  662. commands.addCommand(CommandIDs.closeRightTabs, {
  663. label: () => `Close Tabs to Right`,
  664. isEnabled: () =>
  665. contextMenuWidget() && widgetsRightOf(contextMenuWidget()).length > 0,
  666. execute: () => {
  667. const widget = contextMenuWidget();
  668. if (!widget) {
  669. return;
  670. }
  671. closeWidgets(widgetsRightOf(widget));
  672. }
  673. });
  674. commands.addCommand(CommandIDs.rename, {
  675. label: () => `Rename ${fileType(contextMenuWidget(), docManager)}…`,
  676. isEnabled,
  677. execute: () => {
  678. if (isEnabled()) {
  679. let context = docManager.contextForWidget(contextMenuWidget());
  680. return renameDialog(docManager, context!.path);
  681. }
  682. }
  683. });
  684. commands.addCommand(CommandIDs.showInFileBrowser, {
  685. label: () => `Show in File Browser`,
  686. isEnabled,
  687. execute: () => {
  688. let context = docManager.contextForWidget(contextMenuWidget());
  689. if (!context) {
  690. return;
  691. }
  692. // 'activate' is needed if this command is selected in the "open tabs" sidebar
  693. commands.execute('filebrowser:activate', { path: context.path });
  694. commands.execute('filebrowser:navigate', { path: context.path });
  695. }
  696. });
  697. app.contextMenu.addItem({
  698. command: CommandIDs.closeRightTabs,
  699. selector: '[data-type="document-title"]',
  700. rank: 5
  701. });
  702. app.contextMenu.addItem({
  703. command: CommandIDs.rename,
  704. selector: '[data-type="document-title"]',
  705. rank: 1
  706. });
  707. app.contextMenu.addItem({
  708. command: CommandIDs.clone,
  709. selector: '[data-type="document-title"]',
  710. rank: 2
  711. });
  712. app.contextMenu.addItem({
  713. command: CommandIDs.showInFileBrowser,
  714. selector: '[data-type="document-title"]',
  715. rank: 3
  716. });
  717. app.contextMenu.addItem({
  718. command: CommandIDs.closeOtherTabs,
  719. selector: '[data-type="document-title"]',
  720. rank: 4
  721. });
  722. if (palette) {
  723. [
  724. CommandIDs.closeAllFiles,
  725. CommandIDs.closeOtherTabs,
  726. CommandIDs.closeRightTabs
  727. ].forEach(command => {
  728. palette.addItem({ command, category });
  729. });
  730. }
  731. }
  732. /**
  733. * Handle dirty state for a context.
  734. */
  735. function handleContext(
  736. status: ILabStatus,
  737. context: DocumentRegistry.Context
  738. ): void {
  739. let disposable: IDisposable | null = null;
  740. let onStateChanged = (sender: any, args: IChangedArgs<any>) => {
  741. if (args.name === 'dirty') {
  742. if (args.newValue === true) {
  743. if (!disposable) {
  744. disposable = status.setDirty();
  745. }
  746. } else if (disposable) {
  747. disposable.dispose();
  748. disposable = null;
  749. }
  750. }
  751. };
  752. context.ready.then(() => {
  753. context.model.stateChanged.connect(onStateChanged);
  754. if (context.model.dirty) {
  755. disposable = status.setDirty();
  756. }
  757. });
  758. context.disposed.connect(() => {
  759. if (disposable) {
  760. disposable.dispose();
  761. }
  762. });
  763. }
  764. /**
  765. * A namespace for private module data.
  766. */
  767. namespace Private {
  768. /**
  769. * A counter for unique IDs.
  770. */
  771. export let id = 0;
  772. export function createRevertConfirmNode(
  773. checkpoint: Contents.ICheckpointModel,
  774. fileType: string
  775. ): HTMLElement {
  776. let body = document.createElement('div');
  777. let confirmMessage = document.createElement('p');
  778. let confirmText = document.createTextNode(`Are you sure you want to revert
  779. the ${fileType} to the latest checkpoint? `);
  780. let cannotUndoText = document.createElement('strong');
  781. cannotUndoText.textContent = 'This cannot be undone.';
  782. confirmMessage.appendChild(confirmText);
  783. confirmMessage.appendChild(cannotUndoText);
  784. let lastCheckpointMessage = document.createElement('p');
  785. let lastCheckpointText = document.createTextNode(
  786. 'The checkpoint was last updated at: '
  787. );
  788. let lastCheckpointDate = document.createElement('p');
  789. let date = new Date(checkpoint.last_modified);
  790. lastCheckpointDate.style.textAlign = 'center';
  791. lastCheckpointDate.textContent =
  792. Time.format(date, 'dddd, MMMM Do YYYY, h:mm:ss a') +
  793. ' (' +
  794. Time.formatHuman(date) +
  795. ')';
  796. lastCheckpointMessage.appendChild(lastCheckpointText);
  797. lastCheckpointMessage.appendChild(lastCheckpointDate);
  798. body.appendChild(confirmMessage);
  799. body.appendChild(lastCheckpointMessage);
  800. return body;
  801. }
  802. }