UNPKG

18.8 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { Dialog, showDialog } from '@jupyterlab/apputils';
5import { IChangedArgs, Time } from '@jupyterlab/coreutils';
6import { DocumentRegistry, IDocumentWidget } from '@jupyterlab/docregistry';
7import { Contents } from '@jupyterlab/services';
8import { ITranslator, nullTranslator } from '@jupyterlab/translation';
9import { ArrayExt, find } from '@lumino/algorithm';
10import { DisposableSet, IDisposable } from '@lumino/disposable';
11import { IMessageHandler, Message, MessageLoop } from '@lumino/messaging';
12import { AttachedProperty } from '@lumino/properties';
13import { ISignal, Signal } from '@lumino/signaling';
14import { Widget } from '@lumino/widgets';
15import { IRecentsManager } from './tokens';
16
17/**
18 * The class name added to document widgets.
19 */
20const DOCUMENT_CLASS = 'jp-Document';
21
22/**
23 * A class that maintains the lifecycle of file-backed widgets.
24 */
25export class DocumentWidgetManager implements IDisposable {
26 /**
27 * Construct a new document widget manager.
28 */
29 constructor(options: DocumentWidgetManager.IOptions) {
30 this._registry = options.registry;
31 this.translator = options.translator || nullTranslator;
32 this._recentsManager = options.recentsManager || null;
33 }
34
35 /**
36 * A signal emitted when one of the documents is activated.
37 */
38 get activateRequested(): ISignal<this, string> {
39 return this._activateRequested;
40 }
41
42 /**
43 * Whether to ask confirmation to close a tab or not.
44 */
45 get confirmClosingDocument(): boolean {
46 return this._confirmClosingTab;
47 }
48 set confirmClosingDocument(v: boolean) {
49 if (this._confirmClosingTab !== v) {
50 const oldValue = this._confirmClosingTab;
51 this._confirmClosingTab = v;
52 this._stateChanged.emit({
53 name: 'confirmClosingDocument',
54 oldValue,
55 newValue: v
56 });
57 }
58 }
59
60 /**
61 * Signal triggered when an attribute changes.
62 */
63 get stateChanged(): ISignal<DocumentWidgetManager, IChangedArgs<any>> {
64 return this._stateChanged;
65 }
66
67 /**
68 * Test whether the document widget manager is disposed.
69 */
70 get isDisposed(): boolean {
71 return this._isDisposed;
72 }
73
74 /**
75 * Dispose of the resources used by the widget manager.
76 */
77 dispose(): void {
78 if (this.isDisposed) {
79 return;
80 }
81 this._isDisposed = true;
82 Signal.disconnectReceiver(this);
83 }
84
85 /**
86 * Create a widget for a document and handle its lifecycle.
87 *
88 * @param factory - The widget factory.
89 *
90 * @param context - The document context object.
91 *
92 * @returns A widget created by the factory.
93 *
94 * @throws If the factory is not registered.
95 */
96 createWidget(
97 factory: DocumentRegistry.WidgetFactory,
98 context: DocumentRegistry.Context
99 ): IDocumentWidget {
100 const widget = factory.createNew(context);
101 this._initializeWidget(widget, factory, context);
102 return widget;
103 }
104
105 /**
106 * When a new widget is created, we need to hook it up
107 * with some signals, update the widget extensions (for
108 * this kind of widget) in the docregistry, among
109 * other things.
110 */
111 private _initializeWidget(
112 widget: IDocumentWidget,
113 factory: DocumentRegistry.WidgetFactory,
114 context: DocumentRegistry.Context
115 ) {
116 Private.factoryProperty.set(widget, factory);
117 // Handle widget extensions.
118 const disposables = new DisposableSet();
119 for (const extender of this._registry.widgetExtensions(factory.name)) {
120 const disposable = extender.createNew(widget, context);
121 if (disposable) {
122 disposables.add(disposable);
123 }
124 }
125 Private.disposablesProperty.set(widget, disposables);
126 widget.disposed.connect(this._onWidgetDisposed, this);
127
128 this.adoptWidget(context, widget);
129 context.fileChanged.connect(this._onFileChanged, this);
130 context.pathChanged.connect(this._onPathChanged, this);
131 void context.ready.then(() => {
132 void this.setCaption(widget);
133 });
134 }
135
136 /**
137 * Install the message hook for the widget and add to list
138 * of known widgets.
139 *
140 * @param context - The document context object.
141 *
142 * @param widget - The widget to adopt.
143 */
144 adoptWidget(
145 context: DocumentRegistry.Context,
146 widget: IDocumentWidget
147 ): void {
148 const widgets = Private.widgetsProperty.get(context);
149 widgets.push(widget);
150 MessageLoop.installMessageHook(widget, this);
151 widget.addClass(DOCUMENT_CLASS);
152 widget.title.closable = true;
153 widget.disposed.connect(this._widgetDisposed, this);
154 Private.contextProperty.set(widget, context);
155 }
156
157 /**
158 * See if a widget already exists for the given context and widget name.
159 *
160 * @param context - The document context object.
161 *
162 * @returns The found widget, or `undefined`.
163 *
164 * #### Notes
165 * This can be used to use an existing widget instead of opening
166 * a new widget.
167 */
168 findWidget(
169 context: DocumentRegistry.Context,
170 widgetName: string
171 ): IDocumentWidget | undefined {
172 const widgets = Private.widgetsProperty.get(context);
173 if (!widgets) {
174 return undefined;
175 }
176 return find(widgets, widget => {
177 const factory = Private.factoryProperty.get(widget);
178 if (!factory) {
179 return false;
180 }
181 return factory.name === widgetName;
182 });
183 }
184
185 /**
186 * Get the document context for a widget.
187 *
188 * @param widget - The widget of interest.
189 *
190 * @returns The context associated with the widget, or `undefined`.
191 */
192 contextForWidget(widget: Widget): DocumentRegistry.Context | undefined {
193 return Private.contextProperty.get(widget);
194 }
195
196 /**
197 * Clone a widget.
198 *
199 * @param widget - The source widget.
200 *
201 * @returns A new widget or `undefined`.
202 *
203 * #### Notes
204 * Uses the same widget factory and context as the source, or throws
205 * if the source widget is not managed by this manager.
206 */
207 cloneWidget(widget: Widget): IDocumentWidget | undefined {
208 const context = Private.contextProperty.get(widget);
209 if (!context) {
210 return undefined;
211 }
212 const factory = Private.factoryProperty.get(widget);
213 if (!factory) {
214 return undefined;
215 }
216 const newWidget = factory.createNew(context, widget as IDocumentWidget);
217 this._initializeWidget(newWidget, factory, context);
218 return newWidget;
219 }
220
221 /**
222 * Close the widgets associated with a given context.
223 *
224 * @param context - The document context object.
225 */
226 closeWidgets(context: DocumentRegistry.Context): Promise<void> {
227 const widgets = Private.widgetsProperty.get(context);
228 return Promise.all(widgets.map(widget => this.onClose(widget))).then(
229 () => undefined
230 );
231 }
232
233 /**
234 * Dispose of the widgets associated with a given context
235 * regardless of the widget's dirty state.
236 *
237 * @param context - The document context object.
238 */
239 deleteWidgets(context: DocumentRegistry.Context): Promise<void> {
240 const widgets = Private.widgetsProperty.get(context);
241 return Promise.all(widgets.map(widget => this.onDelete(widget))).then(
242 () => undefined
243 );
244 }
245
246 /**
247 * Filter a message sent to a message handler.
248 *
249 * @param handler - The target handler of the message.
250 *
251 * @param msg - The message dispatched to the handler.
252 *
253 * @returns `false` if the message should be filtered, of `true`
254 * if the message should be dispatched to the handler as normal.
255 */
256 messageHook(handler: IMessageHandler, msg: Message): boolean {
257 switch (msg.type) {
258 case 'close-request':
259 void this.onClose(handler as Widget);
260 return false;
261 case 'activate-request': {
262 const widget = handler as Widget;
263 const context = this.contextForWidget(widget);
264 if (context) {
265 context.ready
266 .then(() => {
267 // contentsModel is null until the context is ready
268 this._recordAsRecentlyOpened(widget, context.contentsModel!);
269 })
270 .catch(() => {
271 console.warn('Could not record the recents status for', context);
272 });
273 this._activateRequested.emit(context.path);
274 }
275 break;
276 }
277 default:
278 break;
279 }
280 return true;
281 }
282
283 /**
284 * Set the caption for widget title.
285 *
286 * @param widget - The target widget.
287 */
288 protected async setCaption(widget: Widget): Promise<void> {
289 const trans = this.translator.load('jupyterlab');
290 const context = Private.contextProperty.get(widget);
291 if (!context) {
292 return;
293 }
294 const model = context.contentsModel;
295 if (!model) {
296 widget.title.caption = '';
297 return;
298 }
299 return context
300 .listCheckpoints()
301 .then((checkpoints: Contents.ICheckpointModel[]) => {
302 if (widget.isDisposed) {
303 return;
304 }
305 const last = checkpoints[checkpoints.length - 1];
306 const checkpoint = last ? Time.format(last.last_modified) : 'None';
307 let caption = trans.__(
308 'Name: %1\nPath: %2\n',
309 model!.name,
310 model!.path
311 );
312 if (context!.model.readOnly) {
313 caption += trans.__('Read-only');
314 } else {
315 caption +=
316 trans.__('Last Saved: %1\n', Time.format(model!.last_modified)) +
317 trans.__('Last Checkpoint: %1', checkpoint);
318 }
319 widget.title.caption = caption;
320 });
321 }
322
323 /**
324 * Handle `'close-request'` messages.
325 *
326 * @param widget - The target widget.
327 *
328 * @returns A promise that resolves with whether the widget was closed.
329 */
330 protected async onClose(widget: Widget): Promise<boolean> {
331 // Handle dirty state.
332 const [shouldClose, ignoreSave] = await this._maybeClose(
333 widget,
334 this.translator
335 );
336 if (widget.isDisposed) {
337 return true;
338 }
339 if (shouldClose) {
340 const context = Private.contextProperty.get(widget);
341 if (!ignoreSave) {
342 if (!context) {
343 return true;
344 }
345 if (context.contentsModel?.writable) {
346 await context.save();
347 } else {
348 await context.saveAs();
349 }
350 }
351 if (context) {
352 const result = await Promise.race([
353 context.ready,
354 new Promise(resolve => setTimeout(resolve, 3000, 'timeout'))
355 ]);
356 if (result === 'timeout') {
357 console.warn(
358 'Could not record the widget as recently closed because the context did not become ready in 3 seconds'
359 );
360 } else {
361 // Note: `contentsModel` is null until the the context is ready;
362 // we have to handle it after `await` rather than in a `then`
363 // to ensure we record it as recent before the widget gets disposed.
364 this._recordAsRecentlyClosed(widget, context.contentsModel!);
365 }
366 }
367 if (widget.isDisposed) {
368 return true;
369 }
370 widget.dispose();
371 }
372 return shouldClose;
373 }
374
375 /**
376 * Dispose of widget regardless of widget's dirty state.
377 *
378 * @param widget - The target widget.
379 */
380 protected onDelete(widget: Widget): Promise<void> {
381 widget.dispose();
382 return Promise.resolve(void 0);
383 }
384
385 /**
386 * Record the activated file, and its parent directory, as recently opened.
387 */
388 private _recordAsRecentlyOpened(
389 widget: Widget,
390 model: Omit<Contents.IModel, 'content'>
391 ) {
392 const recents = this._recentsManager;
393 if (!recents) {
394 // no-op
395 return;
396 }
397 const path = model.path;
398 const fileType = this._registry.getFileTypeForModel(model);
399 const contentType = fileType.contentType;
400 const factory = Private.factoryProperty.get(widget)?.name;
401 recents.addRecent({ path, contentType, factory }, 'opened');
402 // Add the containing directory, too
403 if (contentType !== 'directory') {
404 const parent =
405 path.lastIndexOf('/') > 0 ? path.slice(0, path.lastIndexOf('/')) : '';
406 recents.addRecent({ path: parent, contentType: 'directory' }, 'opened');
407 }
408 }
409
410 /**
411 * Record the activated file, and its parent directory, as recently opened.
412 */
413 private _recordAsRecentlyClosed(
414 widget: Widget,
415 model: Omit<Contents.IModel, 'content'>
416 ) {
417 const recents = this._recentsManager;
418 if (!recents) {
419 // no-op
420 return;
421 }
422 const path = model.path;
423 const fileType = this._registry.getFileTypeForModel(model);
424 const contentType = fileType.contentType;
425 const factory = Private.factoryProperty.get(widget)?.name;
426 recents.addRecent({ path, contentType, factory }, 'closed');
427 }
428
429 /**
430 * Ask the user whether to close an unsaved file.
431 */
432 private async _maybeClose(
433 widget: Widget,
434 translator?: ITranslator
435 ): Promise<[boolean, boolean]> {
436 translator = translator || nullTranslator;
437 const trans = translator.load('jupyterlab');
438 // Bail if the model is not dirty or other widgets are using the model.)
439 const context = Private.contextProperty.get(widget);
440 if (!context) {
441 return Promise.resolve([true, true]);
442 }
443 let widgets = Private.widgetsProperty.get(context);
444 if (!widgets) {
445 return Promise.resolve([true, true]);
446 }
447 // Filter by whether the factories are read only.
448 widgets = widgets.filter(widget => {
449 const factory = Private.factoryProperty.get(widget);
450 if (!factory) {
451 return false;
452 }
453 return factory.readOnly === false;
454 });
455 const fileName = widget.title.label;
456
457 const factory = Private.factoryProperty.get(widget);
458 const isDirty =
459 context.model.dirty &&
460 widgets.length <= 1 &&
461 !(factory?.readOnly ?? true);
462
463 // Ask confirmation
464 if (this.confirmClosingDocument) {
465 const buttons = [
466 Dialog.cancelButton(),
467 Dialog.okButton({
468 label: isDirty ? trans.__('Close and save') : trans.__('Close'),
469 ariaLabel: isDirty
470 ? trans.__('Close and save Document')
471 : trans.__('Close Document')
472 })
473 ];
474 if (isDirty) {
475 buttons.splice(
476 1,
477 0,
478 Dialog.warnButton({
479 label: trans.__('Close without saving'),
480 ariaLabel: trans.__('Close Document without saving')
481 })
482 );
483 }
484
485 const confirm = await showDialog({
486 title: trans.__('Confirmation'),
487 body: trans.__('Please confirm you want to close "%1".', fileName),
488 checkbox: isDirty
489 ? null
490 : {
491 label: trans.__('Do not ask me again.'),
492 caption: trans.__(
493 'If checked, no confirmation to close a document will be asked in the future.'
494 )
495 },
496 buttons
497 });
498
499 if (confirm.isChecked) {
500 this.confirmClosingDocument = false;
501 }
502
503 return Promise.resolve([
504 confirm.button.accept,
505 isDirty ? confirm.button.displayType === 'warn' : true
506 ]);
507 } else {
508 if (!isDirty) {
509 return Promise.resolve([true, true]);
510 }
511
512 const saveLabel = context.contentsModel?.writable
513 ? trans.__('Save')
514 : trans.__('Save as');
515 const result = await showDialog({
516 title: trans.__('Save your work'),
517 body: trans.__('Save changes in "%1" before closing?', fileName),
518 buttons: [
519 Dialog.cancelButton(),
520 Dialog.warnButton({
521 label: trans.__('Discard'),
522 ariaLabel: trans.__('Discard changes to file')
523 }),
524 Dialog.okButton({ label: saveLabel })
525 ]
526 });
527 return [result.button.accept, result.button.displayType === 'warn'];
528 }
529 }
530
531 /**
532 * Handle the disposal of a widget.
533 */
534 private _widgetDisposed(widget: Widget): void {
535 const context = Private.contextProperty.get(widget);
536 if (!context) {
537 return;
538 }
539 const widgets = Private.widgetsProperty.get(context);
540 if (!widgets) {
541 return;
542 }
543 // Remove the widget.
544 ArrayExt.removeFirstOf(widgets, widget);
545 // Dispose of the context if this is the last widget using it.
546 if (!widgets.length) {
547 context.dispose();
548 }
549 }
550
551 /**
552 * Handle the disposal of a widget.
553 */
554 private _onWidgetDisposed(widget: Widget): void {
555 const disposables = Private.disposablesProperty.get(widget);
556 disposables.dispose();
557 }
558
559 /**
560 * Handle a file changed signal for a context.
561 */
562 private _onFileChanged(context: DocumentRegistry.Context): void {
563 const widgets = Private.widgetsProperty.get(context);
564 for (const widget of widgets) {
565 void this.setCaption(widget);
566 }
567 }
568
569 /**
570 * Handle a path changed signal for a context.
571 */
572 private _onPathChanged(context: DocumentRegistry.Context): void {
573 const widgets = Private.widgetsProperty.get(context);
574 for (const widget of widgets) {
575 void this.setCaption(widget);
576 }
577 }
578
579 protected translator: ITranslator;
580 private _registry: DocumentRegistry;
581 private _activateRequested = new Signal<this, string>(this);
582 private _confirmClosingTab = false;
583 private _isDisposed = false;
584 private _stateChanged = new Signal<DocumentWidgetManager, IChangedArgs<any>>(
585 this
586 );
587 private _recentsManager: IRecentsManager | null;
588}
589
590/**
591 * A namespace for document widget manager statics.
592 */
593export namespace DocumentWidgetManager {
594 /**
595 * The options used to initialize a document widget manager.
596 */
597 export interface IOptions {
598 /**
599 * A document registry instance.
600 */
601 registry: DocumentRegistry;
602
603 /**
604 * The manager for recent documents.
605 */
606 recentsManager?: IRecentsManager;
607
608 /**
609 * The application language translator.
610 */
611 translator?: ITranslator;
612 }
613}
614
615/**
616 * A private namespace for DocumentManager data.
617 */
618namespace Private {
619 /**
620 * A private attached property for a widget context.
621 */
622 export const contextProperty = new AttachedProperty<
623 Widget,
624 DocumentRegistry.Context | undefined
625 >({
626 name: 'context',
627 create: () => undefined
628 });
629
630 /**
631 * A private attached property for a widget factory.
632 */
633 export const factoryProperty = new AttachedProperty<
634 Widget,
635 DocumentRegistry.WidgetFactory | undefined
636 >({
637 name: 'factory',
638 create: () => undefined
639 });
640
641 /**
642 * A private attached property for the widgets associated with a context.
643 */
644 export const widgetsProperty = new AttachedProperty<
645 DocumentRegistry.Context,
646 IDocumentWidget[]
647 >({
648 name: 'widgets',
649 create: () => []
650 });
651
652 /**
653 * A private attached property for a widget's disposables.
654 */
655 export const disposablesProperty = new AttachedProperty<
656 Widget,
657 DisposableSet
658 >({
659 name: 'disposables',
660 create: () => new DisposableSet()
661 });
662}