UNPKG

@jupyterlab/docmanager

Version:
544 lines 18.9 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { Dialog, showDialog } from '@jupyterlab/apputils'; import { Time } from '@jupyterlab/coreutils'; import { nullTranslator } from '@jupyterlab/translation'; import { ArrayExt, find } from '@lumino/algorithm'; import { DisposableSet } from '@lumino/disposable'; import { MessageLoop } from '@lumino/messaging'; import { AttachedProperty } from '@lumino/properties'; import { Signal } from '@lumino/signaling'; /** * The class name added to document widgets. */ const DOCUMENT_CLASS = 'jp-Document'; /** * A class that maintains the lifecycle of file-backed widgets. */ export class DocumentWidgetManager { /** * Construct a new document widget manager. */ constructor(options) { this._activateRequested = new Signal(this); this._confirmClosingTab = false; this._isDisposed = false; this._stateChanged = new Signal(this); this._registry = options.registry; this.translator = options.translator || nullTranslator; this._recentsManager = options.recentsManager || null; } /** * A signal emitted when one of the documents is activated. */ get activateRequested() { return this._activateRequested; } /** * Whether to ask confirmation to close a tab or not. */ get confirmClosingDocument() { return this._confirmClosingTab; } set confirmClosingDocument(v) { if (this._confirmClosingTab !== v) { const oldValue = this._confirmClosingTab; this._confirmClosingTab = v; this._stateChanged.emit({ name: 'confirmClosingDocument', oldValue, newValue: v }); } } /** * Signal triggered when an attribute changes. */ get stateChanged() { return this._stateChanged; } /** * Test whether the document widget manager is disposed. */ get isDisposed() { return this._isDisposed; } /** * Dispose of the resources used by the widget manager. */ dispose() { if (this.isDisposed) { return; } this._isDisposed = true; Signal.disconnectReceiver(this); } /** * Create a widget for a document and handle its lifecycle. * * @param factory - The widget factory. * * @param context - The document context object. * * @returns A widget created by the factory. * * @throws If the factory is not registered. */ createWidget(factory, context) { const widget = factory.createNew(context); this._initializeWidget(widget, factory, context); return widget; } /** * When a new widget is created, we need to hook it up * with some signals, update the widget extensions (for * this kind of widget) in the docregistry, among * other things. */ _initializeWidget(widget, factory, context) { Private.factoryProperty.set(widget, factory); // Handle widget extensions. const disposables = new DisposableSet(); for (const extender of this._registry.widgetExtensions(factory.name)) { const disposable = extender.createNew(widget, context); if (disposable) { disposables.add(disposable); } } Private.disposablesProperty.set(widget, disposables); widget.disposed.connect(this._onWidgetDisposed, this); this.adoptWidget(context, widget); context.fileChanged.connect(this._onFileChanged, this); context.pathChanged.connect(this._onPathChanged, this); void context.ready.then(() => { void this.setCaption(widget); }); } /** * Install the message hook for the widget and add to list * of known widgets. * * @param context - The document context object. * * @param widget - The widget to adopt. */ adoptWidget(context, widget) { const widgets = Private.widgetsProperty.get(context); widgets.push(widget); MessageLoop.installMessageHook(widget, this); widget.addClass(DOCUMENT_CLASS); widget.title.closable = true; widget.disposed.connect(this._widgetDisposed, this); Private.contextProperty.set(widget, context); } /** * See if a widget already exists for the given context and widget name. * * @param context - The document context object. * * @returns The found widget, or `undefined`. * * #### Notes * This can be used to use an existing widget instead of opening * a new widget. */ findWidget(context, widgetName) { const widgets = Private.widgetsProperty.get(context); if (!widgets) { return undefined; } return find(widgets, widget => { const factory = Private.factoryProperty.get(widget); if (!factory) { return false; } return factory.name === widgetName; }); } /** * Get the document context for a widget. * * @param widget - The widget of interest. * * @returns The context associated with the widget, or `undefined`. */ contextForWidget(widget) { return Private.contextProperty.get(widget); } /** * Clone a widget. * * @param widget - The source widget. * * @returns A new widget or `undefined`. * * #### Notes * Uses the same widget factory and context as the source, or throws * if the source widget is not managed by this manager. */ cloneWidget(widget) { const context = Private.contextProperty.get(widget); if (!context) { return undefined; } const factory = Private.factoryProperty.get(widget); if (!factory) { return undefined; } const newWidget = factory.createNew(context, widget); this._initializeWidget(newWidget, factory, context); return newWidget; } /** * Close the widgets associated with a given context. * * @param context - The document context object. */ closeWidgets(context) { const widgets = Private.widgetsProperty.get(context); return Promise.all(widgets.map(widget => this.onClose(widget))).then(() => undefined); } /** * Dispose of the widgets associated with a given context * regardless of the widget's dirty state. * * @param context - The document context object. */ deleteWidgets(context) { const widgets = Private.widgetsProperty.get(context); return Promise.all(widgets.map(widget => this.onDelete(widget))).then(() => undefined); } /** * Filter a message sent to a message handler. * * @param handler - The target handler of the message. * * @param msg - The message dispatched to the handler. * * @returns `false` if the message should be filtered, of `true` * if the message should be dispatched to the handler as normal. */ messageHook(handler, msg) { switch (msg.type) { case 'close-request': void this.onClose(handler); return false; case 'activate-request': { const widget = handler; const context = this.contextForWidget(widget); if (context) { context.ready .then(() => { // contentsModel is null until the context is ready this._recordAsRecentlyOpened(widget, context.contentsModel); }) .catch(() => { console.warn('Could not record the recents status for', context); }); this._activateRequested.emit(context.path); } break; } default: break; } return true; } /** * Set the caption for widget title. * * @param widget - The target widget. */ async setCaption(widget) { const trans = this.translator.load('jupyterlab'); const context = Private.contextProperty.get(widget); if (!context) { return; } const model = context.contentsModel; if (!model) { widget.title.caption = ''; return; } return context .listCheckpoints() .then((checkpoints) => { if (widget.isDisposed) { return; } const last = checkpoints[checkpoints.length - 1]; const checkpoint = last ? Time.format(last.last_modified) : 'None'; let caption = trans.__('Name: %1\nPath: %2\n', model.name, model.path); if (context.model.readOnly) { caption += trans.__('Read-only'); } else { caption += trans.__('Last Saved: %1\n', Time.format(model.last_modified)) + trans.__('Last Checkpoint: %1', checkpoint); } widget.title.caption = caption; }); } /** * Handle `'close-request'` messages. * * @param widget - The target widget. * * @returns A promise that resolves with whether the widget was closed. */ async onClose(widget) { var _a; // Handle dirty state. const [shouldClose, ignoreSave] = await this._maybeClose(widget, this.translator); if (widget.isDisposed) { return true; } if (shouldClose) { const context = Private.contextProperty.get(widget); if (!ignoreSave) { if (!context) { return true; } if ((_a = context.contentsModel) === null || _a === void 0 ? void 0 : _a.writable) { await context.save(); } else { await context.saveAs(); } } if (context) { const result = await Promise.race([ context.ready, new Promise(resolve => setTimeout(resolve, 3000, 'timeout')) ]); if (result === 'timeout') { console.warn('Could not record the widget as recently closed because the context did not become ready in 3 seconds'); } else { // Note: `contentsModel` is null until the the context is ready; // we have to handle it after `await` rather than in a `then` // to ensure we record it as recent before the widget gets disposed. this._recordAsRecentlyClosed(widget, context.contentsModel); } } if (widget.isDisposed) { return true; } widget.dispose(); } return shouldClose; } /** * Dispose of widget regardless of widget's dirty state. * * @param widget - The target widget. */ onDelete(widget) { widget.dispose(); return Promise.resolve(void 0); } /** * Record the activated file, and its parent directory, as recently opened. */ _recordAsRecentlyOpened(widget, model) { var _a; const recents = this._recentsManager; if (!recents) { // no-op return; } const path = model.path; const fileType = this._registry.getFileTypeForModel(model); const contentType = fileType.contentType; const factory = (_a = Private.factoryProperty.get(widget)) === null || _a === void 0 ? void 0 : _a.name; recents.addRecent({ path, contentType, factory }, 'opened'); // Add the containing directory, too if (contentType !== 'directory') { const parent = path.lastIndexOf('/') > 0 ? path.slice(0, path.lastIndexOf('/')) : ''; recents.addRecent({ path: parent, contentType: 'directory' }, 'opened'); } } /** * Record the activated file, and its parent directory, as recently opened. */ _recordAsRecentlyClosed(widget, model) { var _a; const recents = this._recentsManager; if (!recents) { // no-op return; } const path = model.path; const fileType = this._registry.getFileTypeForModel(model); const contentType = fileType.contentType; const factory = (_a = Private.factoryProperty.get(widget)) === null || _a === void 0 ? void 0 : _a.name; recents.addRecent({ path, contentType, factory }, 'closed'); } /** * Ask the user whether to close an unsaved file. */ async _maybeClose(widget, translator) { var _a, _b; translator = translator || nullTranslator; const trans = translator.load('jupyterlab'); // Bail if the model is not dirty or other widgets are using the model.) const context = Private.contextProperty.get(widget); if (!context) { return Promise.resolve([true, true]); } let widgets = Private.widgetsProperty.get(context); if (!widgets) { return Promise.resolve([true, true]); } // Filter by whether the factories are read only. widgets = widgets.filter(widget => { const factory = Private.factoryProperty.get(widget); if (!factory) { return false; } return factory.readOnly === false; }); const fileName = widget.title.label; const factory = Private.factoryProperty.get(widget); const isDirty = context.model.dirty && widgets.length <= 1 && !((_a = factory === null || factory === void 0 ? void 0 : factory.readOnly) !== null && _a !== void 0 ? _a : true); // Ask confirmation if (this.confirmClosingDocument) { const buttons = [ Dialog.cancelButton(), Dialog.okButton({ label: isDirty ? trans.__('Close and save') : trans.__('Close'), ariaLabel: isDirty ? trans.__('Close and save Document') : trans.__('Close Document') }) ]; if (isDirty) { buttons.splice(1, 0, Dialog.warnButton({ label: trans.__('Close without saving'), ariaLabel: trans.__('Close Document without saving') })); } const confirm = await showDialog({ title: trans.__('Confirmation'), body: trans.__('Please confirm you want to close "%1".', fileName), checkbox: isDirty ? null : { label: trans.__('Do not ask me again.'), caption: trans.__('If checked, no confirmation to close a document will be asked in the future.') }, buttons }); if (confirm.isChecked) { this.confirmClosingDocument = false; } return Promise.resolve([ confirm.button.accept, isDirty ? confirm.button.displayType === 'warn' : true ]); } else { if (!isDirty) { return Promise.resolve([true, true]); } const saveLabel = ((_b = context.contentsModel) === null || _b === void 0 ? void 0 : _b.writable) ? trans.__('Save') : trans.__('Save as'); const result = await showDialog({ title: trans.__('Save your work'), body: trans.__('Save changes in "%1" before closing?', fileName), buttons: [ Dialog.cancelButton(), Dialog.warnButton({ label: trans.__('Discard'), ariaLabel: trans.__('Discard changes to file') }), Dialog.okButton({ label: saveLabel }) ] }); return [result.button.accept, result.button.displayType === 'warn']; } } /** * Handle the disposal of a widget. */ _widgetDisposed(widget) { const context = Private.contextProperty.get(widget); if (!context) { return; } const widgets = Private.widgetsProperty.get(context); if (!widgets) { return; } // Remove the widget. ArrayExt.removeFirstOf(widgets, widget); // Dispose of the context if this is the last widget using it. if (!widgets.length) { context.dispose(); } } /** * Handle the disposal of a widget. */ _onWidgetDisposed(widget) { const disposables = Private.disposablesProperty.get(widget); disposables.dispose(); } /** * Handle a file changed signal for a context. */ _onFileChanged(context) { const widgets = Private.widgetsProperty.get(context); for (const widget of widgets) { void this.setCaption(widget); } } /** * Handle a path changed signal for a context. */ _onPathChanged(context) { const widgets = Private.widgetsProperty.get(context); for (const widget of widgets) { void this.setCaption(widget); } } } /** * A private namespace for DocumentManager data. */ var Private; (function (Private) { /** * A private attached property for a widget context. */ Private.contextProperty = new AttachedProperty({ name: 'context', create: () => undefined }); /** * A private attached property for a widget factory. */ Private.factoryProperty = new AttachedProperty({ name: 'factory', create: () => undefined }); /** * A private attached property for the widgets associated with a context. */ Private.widgetsProperty = new AttachedProperty({ name: 'widgets', create: () => [] }); /** * A private attached property for a widget's disposables. */ Private.disposablesProperty = new AttachedProperty({ name: 'disposables', create: () => new DisposableSet() }); })(Private || (Private = {})); //# sourceMappingURL=widgetmanager.js.map