UNPKG

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