widgetmanager.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. ArrayExt, each, map, find, filter, toArray
  5. } from '@phosphor/algorithm';
  6. import {
  7. DisposableSet, IDisposable
  8. } from '@phosphor/disposable';
  9. import {
  10. IMessageHandler, Message, MessageLoop
  11. } from '@phosphor/messaging';
  12. import {
  13. AttachedProperty
  14. } from '@phosphor/properties';
  15. import {
  16. ISignal, Signal
  17. } from '@phosphor/signaling';
  18. import {
  19. Widget
  20. } from '@phosphor/widgets';
  21. import {
  22. Time
  23. } from '@jupyterlab/coreutils';
  24. import {
  25. showDialog, Dialog
  26. } from '@jupyterlab/apputils';
  27. import {
  28. DocumentRegistry
  29. } from '@jupyterlab/docregistry';
  30. import {
  31. Contents
  32. } from '@jupyterlab/services';
  33. /**
  34. * The class name added to document widgets.
  35. */
  36. const DOCUMENT_CLASS = 'jp-Document';
  37. /**
  38. * A class that maintains the lifecyle of file-backed widgets.
  39. */
  40. export
  41. class DocumentWidgetManager implements IDisposable {
  42. /**
  43. * Construct a new document widget manager.
  44. */
  45. constructor(options: DocumentWidgetManager.IOptions) {
  46. this._registry = options.registry;
  47. }
  48. /**
  49. * A signal emitted when one of the documents is activated.
  50. */
  51. get activateRequested(): ISignal<this, string> {
  52. return this._activateRequested;
  53. }
  54. /**
  55. * Test whether the document widget manager is disposed.
  56. */
  57. get isDisposed(): boolean {
  58. return this._isDisposed;
  59. }
  60. /**
  61. * Dispose of the resources used by the widget manager.
  62. */
  63. dispose(): void {
  64. if (this.isDisposed) {
  65. return;
  66. }
  67. this._isDisposed = true;
  68. Signal.disconnectReceiver(this);
  69. }
  70. /**
  71. * Create a widget for a document and handle its lifecycle.
  72. *
  73. * @param factory - The widget factory.
  74. *
  75. * @param context - The document context object.
  76. *
  77. * @returns A widget created by the factory.
  78. *
  79. * @throws If the factory is not registered.
  80. */
  81. createWidget(factory: DocumentRegistry.WidgetFactory, context: DocumentRegistry.Context): DocumentRegistry.IReadyWidget {
  82. let widget = factory.createNew(context);
  83. Private.factoryProperty.set(widget, factory);
  84. // Handle widget extensions.
  85. let disposables = new DisposableSet();
  86. each(this._registry.widgetExtensions(factory.name), extender => {
  87. disposables.add(extender.createNew(widget, context));
  88. });
  89. Private.disposablesProperty.set(widget, disposables);
  90. widget.disposed.connect(this._onWidgetDisposed, this);
  91. this.adoptWidget(context, widget);
  92. context.fileChanged.connect(this._onFileChanged, this);
  93. context.pathChanged.connect(this._onPathChanged, this);
  94. context.ready.then(() => {
  95. this.setCaption(widget);
  96. });
  97. return widget;
  98. }
  99. /**
  100. * Install the message hook for the widget and add to list
  101. * of known widgets.
  102. *
  103. * @param context - The document context object.
  104. *
  105. * @param widget - The widget to adopt.
  106. */
  107. adoptWidget(context: DocumentRegistry.Context, widget: DocumentRegistry.IReadyWidget): void {
  108. let widgets = Private.widgetsProperty.get(context);
  109. widgets.push(widget);
  110. MessageLoop.installMessageHook(widget, this);
  111. widget.addClass(DOCUMENT_CLASS);
  112. widget.title.closable = true;
  113. widget.disposed.connect(this._widgetDisposed, this);
  114. Private.contextProperty.set(widget, context);
  115. }
  116. /**
  117. * See if a widget already exists for the given context and widget name.
  118. *
  119. * @param context - The document context object.
  120. *
  121. * @returns The found widget, or `undefined`.
  122. *
  123. * #### Notes
  124. * This can be used to use an existing widget instead of opening
  125. * a new widget.
  126. */
  127. findWidget(context: DocumentRegistry.Context, widgetName: string): DocumentRegistry.IReadyWidget | undefined {
  128. let widgets = Private.widgetsProperty.get(context);
  129. if (!widgets) {
  130. return undefined;
  131. }
  132. return find(widgets, widget => {
  133. let factory = Private.factoryProperty.get(widget);
  134. if (!factory) {
  135. return false;
  136. }
  137. return factory.name === widgetName;
  138. });
  139. }
  140. /**
  141. * Get the document context for a widget.
  142. *
  143. * @param widget - The widget of interest.
  144. *
  145. * @returns The context associated with the widget, or `undefined`.
  146. */
  147. contextForWidget(widget: Widget): DocumentRegistry.Context | undefined {
  148. return Private.contextProperty.get(widget);
  149. }
  150. /**
  151. * Clone a widget.
  152. *
  153. * @param widget - The source widget.
  154. *
  155. * @returns A new widget or `undefined`.
  156. *
  157. * #### Notes
  158. * Uses the same widget factory and context as the source, or throws
  159. * if the source widget is not managed by this manager.
  160. */
  161. cloneWidget(widget: Widget): DocumentRegistry.IReadyWidget | undefined {
  162. let context = Private.contextProperty.get(widget);
  163. if (!context) {
  164. return undefined;
  165. }
  166. let factory = Private.factoryProperty.get(widget);
  167. if (!factory) {
  168. return undefined;
  169. }
  170. let newWidget = this.createWidget(factory, context);
  171. this.adoptWidget(context, newWidget);
  172. return newWidget;
  173. }
  174. /**
  175. * Close the widgets associated with a given context.
  176. *
  177. * @param context - The document context object.
  178. */
  179. closeWidgets(context: DocumentRegistry.Context): Promise<void> {
  180. let widgets = Private.widgetsProperty.get(context);
  181. return Promise.all(
  182. toArray(map(widgets, widget => this.onClose(widget)))
  183. ).then(() => undefined);
  184. }
  185. /**
  186. * Dispose of the widgets associated with a given context
  187. * regardless of the widget's dirty state.
  188. *
  189. * @param context - The document context object.
  190. */
  191. deleteWidgets(context: DocumentRegistry.Context): Promise<void> {
  192. let widgets = Private.widgetsProperty.get(context);
  193. return Promise.all(
  194. toArray(map(widgets, widget => this.onDelete(widget)))
  195. ).then(() => undefined);
  196. }
  197. /**
  198. * Filter a message sent to a message handler.
  199. *
  200. * @param handler - The target handler of the message.
  201. *
  202. * @param msg - The message dispatched to the handler.
  203. *
  204. * @returns `false` if the message should be filtered, of `true`
  205. * if the message should be dispatched to the handler as normal.
  206. */
  207. messageHook(handler: IMessageHandler, msg: Message): boolean {
  208. switch (msg.type) {
  209. case 'close-request':
  210. this.onClose(handler as Widget);
  211. return false;
  212. case 'activate-request':
  213. let context = this.contextForWidget(handler as Widget);
  214. if (context) {
  215. this._activateRequested.emit(context.path);
  216. }
  217. break;
  218. default:
  219. break;
  220. }
  221. return true;
  222. }
  223. /**
  224. * Set the caption for widget title.
  225. *
  226. * @param widget - The target widget.
  227. */
  228. protected setCaption(widget: Widget): void {
  229. let context = Private.contextProperty.get(widget);
  230. if (!context) {
  231. return;
  232. }
  233. let model = context.contentsModel;
  234. if (!model) {
  235. widget.title.caption = '';
  236. return;
  237. }
  238. context.listCheckpoints().then((checkpoints: Contents.ICheckpointModel[]) => {
  239. if (widget.isDisposed) {
  240. return;
  241. }
  242. let last = checkpoints[checkpoints.length - 1];
  243. let checkpoint = last ? Time.format(last.last_modified) : 'None';
  244. let caption = `Name: ${model.name}\nPath: ${model.path}\n`;
  245. if (context.model.readOnly) {
  246. caption += 'Read-only';
  247. } else {
  248. caption += (
  249. `Last Saved: ${Time.format(model.last_modified)}\n` +
  250. `Last Checkpoint: ${checkpoint}`
  251. );
  252. }
  253. widget.title.caption = caption;
  254. });
  255. }
  256. /**
  257. * Handle `'close-request'` messages.
  258. *
  259. * @param widget - The target widget.
  260. *
  261. * @returns A promise that resolves with whether the widget was closed.
  262. */
  263. protected onClose(widget: Widget): Promise<boolean> {
  264. // Handle dirty state.
  265. return this._maybeClose(widget).then(result => {
  266. if (widget.isDisposed) {
  267. return true;
  268. }
  269. if (result) {
  270. widget.dispose();
  271. }
  272. return result;
  273. }).catch(error => {
  274. widget.dispose();
  275. throw error;
  276. });
  277. }
  278. /**
  279. * Dispose of widget regardless of widget's dirty state.
  280. *
  281. * @param widget - The target widget.
  282. */
  283. protected onDelete(widget: Widget): Promise<void> {
  284. widget.dispose();
  285. return Promise.resolve(void 0);
  286. }
  287. /**
  288. * Ask the user whether to close an unsaved file.
  289. */
  290. private _maybeClose(widget: Widget): Promise<boolean> {
  291. // Bail if the model is not dirty or other widgets are using the model.)
  292. let context = Private.contextProperty.get(widget);
  293. if (!context) {
  294. return Promise.resolve(true);
  295. }
  296. let widgets = Private.widgetsProperty.get(context);
  297. if (!widgets) {
  298. return Promise.resolve(true);
  299. }
  300. // Filter by whether the factories are read only.
  301. widgets = toArray(filter(widgets, widget => {
  302. let factory = Private.factoryProperty.get(widget);
  303. if (!factory) {
  304. return false;
  305. }
  306. return factory.readOnly === false;
  307. }));
  308. let factory = Private.factoryProperty.get(widget);
  309. if (!factory) {
  310. return Promise.resolve(true);
  311. }
  312. let model = context.model;
  313. if (!model.dirty || widgets.length > 1 || factory.readOnly) {
  314. return Promise.resolve(true);
  315. }
  316. let fileName = widget.title.label;
  317. return showDialog({
  318. title: 'Close without saving?',
  319. body: `File "${fileName}" has unsaved changes, close without saving?`,
  320. buttons: [Dialog.cancelButton(), Dialog.warnButton()]
  321. }).then(result => {
  322. return result.button.accept;
  323. });
  324. }
  325. /**
  326. * Handle the disposal of a widget.
  327. */
  328. private _widgetDisposed(widget: Widget): void {
  329. let context = Private.contextProperty.get(widget);
  330. if (!context) {
  331. return;
  332. }
  333. let widgets = Private.widgetsProperty.get(context);
  334. if (!widgets) {
  335. return;
  336. }
  337. // Remove the widget.
  338. ArrayExt.removeFirstOf(widgets, widget);
  339. // Dispose of the context if this is the last widget using it.
  340. if (!widgets.length) {
  341. context.dispose();
  342. }
  343. }
  344. /**
  345. * Handle the disposal of a widget.
  346. */
  347. private _onWidgetDisposed(widget: Widget): void {
  348. let disposables = Private.disposablesProperty.get(widget);
  349. disposables.dispose();
  350. }
  351. /**
  352. * Handle a file changed signal for a context.
  353. */
  354. private _onFileChanged(context: DocumentRegistry.Context): void {
  355. let widgets = Private.widgetsProperty.get(context);
  356. each(widgets, widget => { this.setCaption(widget); });
  357. }
  358. /**
  359. * Handle a path changed signal for a context.
  360. */
  361. private _onPathChanged(context: DocumentRegistry.Context): void {
  362. let widgets = Private.widgetsProperty.get(context);
  363. each(widgets, widget => { this.setCaption(widget); });
  364. }
  365. private _registry: DocumentRegistry;
  366. private _activateRequested = new Signal<this, string>(this);
  367. private _isDisposed = false;
  368. }
  369. /**
  370. * A namespace for document widget manager statics.
  371. */
  372. export
  373. namespace DocumentWidgetManager {
  374. /**
  375. * The options used to initialize a document widget manager.
  376. */
  377. export
  378. interface IOptions {
  379. /**
  380. * A document registry instance.
  381. */
  382. registry: DocumentRegistry;
  383. }
  384. }
  385. /**
  386. * A private namespace for DocumentManager data.
  387. */
  388. namespace Private {
  389. /**
  390. * A private attached property for a widget context.
  391. */
  392. export
  393. const contextProperty = new AttachedProperty<Widget, DocumentRegistry.Context | undefined>({
  394. name: 'context',
  395. create: () => undefined
  396. });
  397. /**
  398. * A private attached property for a widget factory.
  399. */
  400. export
  401. const factoryProperty = new AttachedProperty<Widget, DocumentRegistry.WidgetFactory | undefined> ({
  402. name: 'factory',
  403. create: () => undefined
  404. });
  405. /**
  406. * A private attached property for the widgets associated with a context.
  407. */
  408. export
  409. const widgetsProperty = new AttachedProperty<DocumentRegistry.Context, DocumentRegistry.IReadyWidget[]>({
  410. name: 'widgets',
  411. create: () => []
  412. });
  413. /**
  414. * A private attached property for a widget's disposables.
  415. */
  416. export
  417. const disposablesProperty = new AttachedProperty<Widget, DisposableSet>({
  418. name: 'disposables',
  419. create: () => new DisposableSet()
  420. });
  421. }