index.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. JupyterLab, JupyterLabPlugin
  5. } from '@jupyterlab/application';
  6. import {
  7. showDialog, showErrorMessage, Dialog, ICommandPalette
  8. } from '@jupyterlab/apputils';
  9. import {
  10. IChangedArgs, ISettingRegistry
  11. } from '@jupyterlab/coreutils';
  12. import {
  13. renameDialog, getOpenPath, DocumentManager, IDocumentManager
  14. } from '@jupyterlab/docmanager';
  15. import {
  16. DocumentRegistry
  17. } from '@jupyterlab/docregistry';
  18. import {
  19. IMainMenu
  20. } from '@jupyterlab/mainmenu';
  21. import {
  22. Contents, Kernel
  23. } from '@jupyterlab/services';
  24. import {
  25. IDisposable
  26. } from '@phosphor/disposable';
  27. /**
  28. * The command IDs used by the document manager plugin.
  29. */
  30. namespace CommandIDs {
  31. export
  32. const clone = 'docmanager:clone';
  33. export
  34. const close = 'docmanager:close';
  35. export
  36. const closeAllFiles = 'docmanager:close-all-files';
  37. export
  38. const deleteFile = 'docmanager:delete-file';
  39. export
  40. const newUntitled = 'docmanager:new-untitled';
  41. export
  42. const open = 'docmanager:open';
  43. export
  44. const openBrowserTab = 'docmanager:open-browser-tab';
  45. export
  46. const openDirect = 'docmanager:open-direct';
  47. export
  48. const reload = 'docmanager:reload';
  49. export
  50. const rename = 'docmanager:rename';
  51. export
  52. const restoreCheckpoint = 'docmanager:restore-checkpoint';
  53. export
  54. const save = 'docmanager:save';
  55. export
  56. const saveAll = 'docmanager:save-all';
  57. export
  58. const saveAs = 'docmanager:save-as';
  59. export
  60. const toggleAutosave = 'docmanager:toggle-autosave';
  61. export
  62. const showInFileBrowser = 'docmanager:show-in-file-browser';
  63. }
  64. const pluginId = '@jupyterlab/docmanager-extension:plugin';
  65. /**
  66. * The default document manager provider.
  67. */
  68. const plugin: JupyterLabPlugin<IDocumentManager> = {
  69. id: pluginId,
  70. provides: IDocumentManager,
  71. requires: [ICommandPalette, IMainMenu, ISettingRegistry],
  72. activate: (app: JupyterLab, palette: ICommandPalette, menu: IMainMenu, settingRegistry: ISettingRegistry): IDocumentManager => {
  73. const manager = app.serviceManager;
  74. const contexts = new WeakSet<DocumentRegistry.Context>();
  75. const opener: DocumentManager.IWidgetOpener = {
  76. open: (widget, options) => {
  77. const shell = app.shell;
  78. if (!widget.id) {
  79. widget.id = `document-manager-${++Private.id}`;
  80. }
  81. widget.title.dataset = {
  82. 'type': 'document-title',
  83. ...widget.title.dataset
  84. };
  85. if (!widget.isAttached) {
  86. app.shell.addToMainArea(widget, options || {});
  87. }
  88. shell.activateById(widget.id);
  89. // Handle dirty state for open documents.
  90. let context = docManager.contextForWidget(widget);
  91. if (!contexts.has(context)) {
  92. handleContext(app, context);
  93. contexts.add(context);
  94. }
  95. }
  96. };
  97. const registry = app.docRegistry;
  98. const when = app.restored.then(() => void 0);
  99. const docManager = new DocumentManager({ registry, manager, opener, when, setBusy: app.setBusy.bind(app) });
  100. // Register the file operations commands.
  101. addCommands(app, docManager, palette, opener, settingRegistry);
  102. // Keep up to date with the settings registry.
  103. const onSettingsUpdated = (settings: ISettingRegistry.ISettings) => {
  104. const autosave = settings.get('autosave').composite as boolean | null;
  105. docManager.autosave = (autosave === true || autosave === false)
  106. ? autosave
  107. : true;
  108. app.commands.notifyCommandChanged(CommandIDs.toggleAutosave);
  109. };
  110. // Fetch the initial state of the settings.
  111. Promise.all([settingRegistry.load(pluginId), app.restored])
  112. .then(([settings]) => {
  113. settings.changed.connect(onSettingsUpdated);
  114. onSettingsUpdated(settings);
  115. }).catch((reason: Error) => {
  116. console.error(reason.message);
  117. });
  118. menu.settingsMenu.addGroup([{ command: CommandIDs.toggleAutosave }], 5);
  119. return docManager;
  120. }
  121. };
  122. /**
  123. * Export the plugin as default.
  124. */
  125. export default plugin;
  126. /**
  127. * Add the file operations commands to the application's command registry.
  128. */
  129. function addCommands(app: JupyterLab, docManager: IDocumentManager, palette: ICommandPalette, opener: DocumentManager.IWidgetOpener, settingRegistry: ISettingRegistry): void {
  130. const { commands, docRegistry } = app;
  131. const category = 'File Operations';
  132. const isEnabled = () => {
  133. const { currentWidget } = app.shell;
  134. return !!(currentWidget && docManager.contextForWidget(currentWidget));
  135. };
  136. const fileType = () => {
  137. const { currentWidget } = app.shell;
  138. if (!currentWidget) {
  139. return 'File';
  140. }
  141. const context = docManager.contextForWidget(currentWidget);
  142. if (!context) {
  143. return '';
  144. }
  145. const fts = docRegistry.getFileTypesForPath(context.path);
  146. return (fts.length && fts[0].displayName) ? fts[0].displayName : 'File';
  147. };
  148. commands.addCommand(CommandIDs.close, {
  149. label: () => {
  150. const widget = app.shell.currentWidget;
  151. let name = 'File';
  152. if (widget) {
  153. const typeName = fileType();
  154. name = typeName || widget.title.label;
  155. }
  156. return `Close ${name}`;
  157. },
  158. isEnabled: () => !!app.shell.currentWidget &&
  159. !!app.shell.currentWidget.title.closable,
  160. execute: () => {
  161. if (app.shell.currentWidget) {
  162. app.shell.currentWidget.close();
  163. }
  164. }
  165. });
  166. commands.addCommand(CommandIDs.closeAllFiles, {
  167. label: 'Close All',
  168. execute: () => { app.shell.closeAll(); }
  169. });
  170. commands.addCommand(CommandIDs.deleteFile, {
  171. label: () => `Delete ${fileType()}`,
  172. execute: args => {
  173. const path = typeof args['path'] === 'undefined' ? ''
  174. : args['path'] as string;
  175. if (!path) {
  176. const command = CommandIDs.deleteFile;
  177. throw new Error(`A non-empty path is required for ${command}.`);
  178. }
  179. return docManager.deleteFile(path);
  180. }
  181. });
  182. commands.addCommand(CommandIDs.newUntitled, {
  183. execute: args => {
  184. const errorTitle = args['error'] as string || 'Error';
  185. const path = typeof args['path'] === 'undefined' ? ''
  186. : args['path'] as string;
  187. let options: Partial<Contents.ICreateOptions> = {
  188. type: args['type'] as Contents.ContentType,
  189. path
  190. };
  191. if (args['type'] === 'file') {
  192. options.ext = args['ext'] as string || '.txt';
  193. }
  194. return docManager.services.contents.newUntitled(options)
  195. .catch(error => showErrorMessage(errorTitle, error));
  196. },
  197. label: args => args['label'] as string || `New ${args['type'] as string}`
  198. });
  199. commands.addCommand(CommandIDs.open, {
  200. execute: args => {
  201. const path = typeof args['path'] === 'undefined' ? ''
  202. : args['path'] as string;
  203. const factory = args['factory'] as string || void 0;
  204. const kernel = args['kernel'] as Kernel.IModel || void 0;
  205. const options = args['options'] as DocumentRegistry.IOpenOptions || void 0;
  206. return docManager.services.contents.get(path, { content: false })
  207. .then(() => docManager.openOrReveal(path, factory, kernel, options));
  208. },
  209. icon: args => args['icon'] as string || '',
  210. label: args => (args['label'] || args['factory']) as string,
  211. mnemonic: args => args['mnemonic'] as number || -1
  212. });
  213. commands.addCommand(CommandIDs.openBrowserTab, {
  214. execute: args => {
  215. const path = typeof args['path'] === 'undefined' ? ''
  216. : args['path'] as string;
  217. if (!path) {
  218. return;
  219. }
  220. return docManager.services.contents.getDownloadUrl(path).then(url => {
  221. window.open(url, '_blank');
  222. });
  223. },
  224. icon: args => args['icon'] as string || '',
  225. label: () => 'Open in New Browser Tab'
  226. });
  227. commands.addCommand(CommandIDs.openDirect, {
  228. label: () => 'Open from Path',
  229. caption: 'Open from path',
  230. isEnabled: () => true,
  231. execute: () => {
  232. return getOpenPath(docManager.services.contents).then(path => {
  233. if (!path) {
  234. return;
  235. }
  236. docManager.services.contents.get(path, { content: false }).then( (args) => {
  237. // exists
  238. return commands.execute(CommandIDs.open, {path: path});
  239. }, () => {
  240. // does not exist
  241. return showDialog({
  242. title: 'Cannot open',
  243. body: 'File not found',
  244. buttons: [Dialog.okButton()]
  245. });
  246. });
  247. return;
  248. });
  249. },
  250. });
  251. commands.addCommand(CommandIDs.reload, {
  252. label: () => `Reload ${fileType()} from Disk`,
  253. caption: 'Reload contents from disk',
  254. isEnabled,
  255. execute: () => {
  256. if (isEnabled()) {
  257. let context = docManager.contextForWidget(app.shell.currentWidget);
  258. return context.revert();
  259. }
  260. }
  261. });
  262. commands.addCommand(CommandIDs.restoreCheckpoint, {
  263. label: () => `Revert ${fileType()} to Checkpoint`,
  264. caption: 'Revert contents to previous checkpoint',
  265. isEnabled,
  266. execute: () => {
  267. if (isEnabled()) {
  268. let context = docManager.contextForWidget(app.shell.currentWidget);
  269. if (context.model.readOnly) {
  270. return context.revert();
  271. }
  272. return context.restoreCheckpoint().then(() => context.revert());
  273. }
  274. }
  275. });
  276. commands.addCommand(CommandIDs.save, {
  277. label: () => `Save ${fileType()}`,
  278. caption: 'Save and create checkpoint',
  279. isEnabled,
  280. execute: () => {
  281. if (isEnabled()) {
  282. let context = docManager.contextForWidget(app.shell.currentWidget);
  283. if (context.model.readOnly) {
  284. return showDialog({
  285. title: 'Cannot Save',
  286. body: 'Document is read-only',
  287. buttons: [Dialog.okButton()]
  288. });
  289. }
  290. return context.save()
  291. .then(() => context.createCheckpoint())
  292. .catch(err => {
  293. // If the save was canceled by user-action, do nothing.
  294. if (err.message === 'Cancel') {
  295. return;
  296. }
  297. throw err;
  298. });
  299. }
  300. }
  301. });
  302. commands.addCommand(CommandIDs.saveAll, {
  303. label: () => 'Save All',
  304. caption: 'Save all open documents',
  305. isEnabled: () => {
  306. const iterator = app.shell.widgets('main');
  307. let widget = iterator.next();
  308. while (widget) {
  309. if (docManager.contextForWidget(widget)) {
  310. return true;
  311. }
  312. widget = iterator.next();
  313. }
  314. return false;
  315. },
  316. execute: () => {
  317. const iterator = app.shell.widgets('main');
  318. const promises: Promise<void>[] = [];
  319. const paths = new Set<string>(); // Cache so we don't double save files.
  320. let widget = iterator.next();
  321. while (widget) {
  322. const context = docManager.contextForWidget(widget);
  323. if (context && !context.model.readOnly && !paths.has(context.path)) {
  324. paths.add(context.path);
  325. promises.push(context.save());
  326. }
  327. widget = iterator.next();
  328. }
  329. return Promise.all(promises);
  330. }
  331. });
  332. commands.addCommand(CommandIDs.saveAs, {
  333. label: () => `Save ${fileType()} As…`,
  334. caption: 'Save with new path',
  335. isEnabled,
  336. execute: () => {
  337. if (isEnabled()) {
  338. let context = docManager.contextForWidget(app.shell.currentWidget);
  339. return context.saveAs();
  340. }
  341. }
  342. });
  343. commands.addCommand(CommandIDs.rename, {
  344. label: () => `Rename ${fileType()}…`,
  345. isEnabled,
  346. execute: () => {
  347. if (isEnabled()) {
  348. let context = docManager.contextForWidget(app.shell.currentWidget);
  349. return renameDialog(docManager, context!.path);
  350. }
  351. }
  352. });
  353. commands.addCommand(CommandIDs.clone, {
  354. label: () => `New View for ${fileType()}`,
  355. isEnabled,
  356. execute: (args) => {
  357. const widget = app.shell.currentWidget;
  358. const options = args['options'] as DocumentRegistry.IOpenOptions || void 0;
  359. if (!widget) {
  360. return;
  361. }
  362. // Clone the widget.
  363. let child = docManager.cloneWidget(widget);
  364. if (child) {
  365. opener.open(child, options);
  366. }
  367. },
  368. });
  369. commands.addCommand(CommandIDs.toggleAutosave, {
  370. label: 'Autosave Documents',
  371. isToggled: () => docManager.autosave,
  372. execute: () => {
  373. const value = !docManager.autosave;
  374. const key = 'autosave';
  375. return settingRegistry.set(pluginId, key, value)
  376. .catch((reason: Error) => {
  377. console.error(`Failed to set ${pluginId}:${key} - ${reason.message}`);
  378. });
  379. }
  380. });
  381. commands.addCommand(CommandIDs.showInFileBrowser, {
  382. label: () => `Show in file browser`,
  383. isEnabled,
  384. execute: () => {
  385. let context = docManager.contextForWidget(app.shell.currentWidget);
  386. if (!context) {
  387. return;
  388. }
  389. // 'activate-main' is needed if this command is selected in the "open tabs" sidebar
  390. commands.execute('filebrowser:activate-main');
  391. commands.execute('filebrowser:navigate-main', {path: context.path});
  392. }
  393. });
  394. app.contextMenu.addItem({
  395. command: CommandIDs.rename,
  396. selector: '[data-type="document-title"]',
  397. rank: 1
  398. });
  399. app.contextMenu.addItem({
  400. command: CommandIDs.clone,
  401. selector: '[data-type="document-title"]',
  402. rank: 2
  403. });
  404. app.contextMenu.addItem({
  405. command: CommandIDs.showInFileBrowser,
  406. selector: '[data-type="document-title"]',
  407. rank: 3
  408. });
  409. [
  410. CommandIDs.openDirect,
  411. CommandIDs.save,
  412. CommandIDs.reload,
  413. CommandIDs.restoreCheckpoint,
  414. CommandIDs.saveAs,
  415. CommandIDs.clone,
  416. CommandIDs.close,
  417. CommandIDs.closeAllFiles,
  418. CommandIDs.toggleAutosave
  419. ].forEach(command => { palette.addItem({ command, category }); });
  420. }
  421. /**
  422. * Handle dirty state for a context.
  423. */
  424. function handleContext(app: JupyterLab, context: DocumentRegistry.Context): void {
  425. let disposable: IDisposable | null = null;
  426. let onStateChanged = (sender: any, args: IChangedArgs<any>) => {
  427. if (args.name === 'dirty') {
  428. if (args.newValue === true) {
  429. if (!disposable) {
  430. disposable = app.setDirty();
  431. }
  432. } else if (disposable) {
  433. disposable.dispose();
  434. disposable = null;
  435. }
  436. }
  437. };
  438. context.ready.then(() => {
  439. context.model.stateChanged.connect(onStateChanged);
  440. if (context.model.dirty) {
  441. disposable = app.setDirty();
  442. }
  443. });
  444. context.disposed.connect(() => {
  445. if (disposable) {
  446. disposable.dispose();
  447. }
  448. });
  449. }
  450. /**
  451. * A namespace for private module data.
  452. */
  453. namespace Private {
  454. /**
  455. * A counter for unique IDs.
  456. */
  457. export
  458. let id = 0;
  459. }