1 |
|
2 |
|
3 |
|
4 | import { Dialog, showDialog } from '@jupyterlab/apputils';
|
5 | import { IChangedArgs, Time } from '@jupyterlab/coreutils';
|
6 | import { DocumentRegistry, IDocumentWidget } from '@jupyterlab/docregistry';
|
7 | import { Contents } from '@jupyterlab/services';
|
8 | import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
9 | import { ArrayExt, find } from '@lumino/algorithm';
|
10 | import { DisposableSet, IDisposable } from '@lumino/disposable';
|
11 | import { IMessageHandler, Message, MessageLoop } from '@lumino/messaging';
|
12 | import { AttachedProperty } from '@lumino/properties';
|
13 | import { ISignal, Signal } from '@lumino/signaling';
|
14 | import { Widget } from '@lumino/widgets';
|
15 | import { IRecentsManager } from './tokens';
|
16 |
|
17 |
|
18 |
|
19 |
|
20 | const DOCUMENT_CLASS = 'jp-Document';
|
21 |
|
22 |
|
23 |
|
24 |
|
25 | export class DocumentWidgetManager implements IDisposable {
|
26 | |
27 |
|
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 |
|
37 |
|
38 | get activateRequested(): ISignal<this, string> {
|
39 | return this._activateRequested;
|
40 | }
|
41 |
|
42 | |
43 |
|
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 |
|
62 |
|
63 | get stateChanged(): ISignal<DocumentWidgetManager, IChangedArgs<any>> {
|
64 | return this._stateChanged;
|
65 | }
|
66 |
|
67 | |
68 |
|
69 |
|
70 | get isDisposed(): boolean {
|
71 | return this._isDisposed;
|
72 | }
|
73 |
|
74 | |
75 |
|
76 |
|
77 | dispose(): void {
|
78 | if (this.isDisposed) {
|
79 | return;
|
80 | }
|
81 | this._isDisposed = true;
|
82 | Signal.disconnectReceiver(this);
|
83 | }
|
84 |
|
85 | |
86 |
|
87 |
|
88 |
|
89 |
|
90 |
|
91 |
|
92 |
|
93 |
|
94 |
|
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 |
|
107 |
|
108 |
|
109 |
|
110 |
|
111 | private _initializeWidget(
|
112 | widget: IDocumentWidget,
|
113 | factory: DocumentRegistry.WidgetFactory,
|
114 | context: DocumentRegistry.Context
|
115 | ) {
|
116 | Private.factoryProperty.set(widget, factory);
|
117 |
|
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 |
|
138 |
|
139 |
|
140 |
|
141 |
|
142 |
|
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 |
|
159 |
|
160 |
|
161 |
|
162 |
|
163 |
|
164 |
|
165 |
|
166 |
|
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 |
|
187 |
|
188 |
|
189 |
|
190 |
|
191 |
|
192 | contextForWidget(widget: Widget): DocumentRegistry.Context | undefined {
|
193 | return Private.contextProperty.get(widget);
|
194 | }
|
195 |
|
196 | |
197 |
|
198 |
|
199 |
|
200 |
|
201 |
|
202 |
|
203 |
|
204 |
|
205 |
|
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 |
|
223 |
|
224 |
|
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 |
|
235 |
|
236 |
|
237 |
|
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 |
|
248 |
|
249 |
|
250 |
|
251 |
|
252 |
|
253 |
|
254 |
|
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 |
|
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 |
|
285 |
|
286 |
|
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 |
|
325 |
|
326 |
|
327 |
|
328 |
|
329 |
|
330 | protected async onClose(widget: Widget): Promise<boolean> {
|
331 |
|
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 |
|
362 |
|
363 |
|
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 |
|
377 |
|
378 |
|
379 |
|
380 | protected onDelete(widget: Widget): Promise<void> {
|
381 | widget.dispose();
|
382 | return Promise.resolve(void 0);
|
383 | }
|
384 |
|
385 | |
386 |
|
387 |
|
388 | private _recordAsRecentlyOpened(
|
389 | widget: Widget,
|
390 | model: Omit<Contents.IModel, 'content'>
|
391 | ) {
|
392 | const recents = this._recentsManager;
|
393 | if (!recents) {
|
394 |
|
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 |
|
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 |
|
412 |
|
413 | private _recordAsRecentlyClosed(
|
414 | widget: Widget,
|
415 | model: Omit<Contents.IModel, 'content'>
|
416 | ) {
|
417 | const recents = this._recentsManager;
|
418 | if (!recents) {
|
419 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
544 | ArrayExt.removeFirstOf(widgets, widget);
|
545 |
|
546 | if (!widgets.length) {
|
547 | context.dispose();
|
548 | }
|
549 | }
|
550 |
|
551 | |
552 |
|
553 |
|
554 | private _onWidgetDisposed(widget: Widget): void {
|
555 | const disposables = Private.disposablesProperty.get(widget);
|
556 | disposables.dispose();
|
557 | }
|
558 |
|
559 | |
560 |
|
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 |
|
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 |
|
592 |
|
593 | export namespace DocumentWidgetManager {
|
594 | |
595 |
|
596 |
|
597 | export interface IOptions {
|
598 | |
599 |
|
600 |
|
601 | registry: DocumentRegistry;
|
602 |
|
603 | |
604 |
|
605 |
|
606 | recentsManager?: IRecentsManager;
|
607 |
|
608 | |
609 |
|
610 |
|
611 | translator?: ITranslator;
|
612 | }
|
613 | }
|
614 |
|
615 |
|
616 |
|
617 |
|
618 | namespace Private {
|
619 | |
620 |
|
621 |
|
622 | export const contextProperty = new AttachedProperty<
|
623 | Widget,
|
624 | DocumentRegistry.Context | undefined
|
625 | >({
|
626 | name: 'context',
|
627 | create: () => undefined
|
628 | });
|
629 |
|
630 | |
631 |
|
632 |
|
633 | export const factoryProperty = new AttachedProperty<
|
634 | Widget,
|
635 | DocumentRegistry.WidgetFactory | undefined
|
636 | >({
|
637 | name: 'factory',
|
638 | create: () => undefined
|
639 | });
|
640 |
|
641 | |
642 |
|
643 |
|
644 | export const widgetsProperty = new AttachedProperty<
|
645 | DocumentRegistry.Context,
|
646 | IDocumentWidget[]
|
647 | >({
|
648 | name: 'widgets',
|
649 | create: () => []
|
650 | });
|
651 |
|
652 | |
653 |
|
654 |
|
655 | export const disposablesProperty = new AttachedProperty<
|
656 | Widget,
|
657 | DisposableSet
|
658 | >({
|
659 | name: 'disposables',
|
660 | create: () => new DisposableSet()
|
661 | });
|
662 | }
|