UNPKG

23.2 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { ISessionContext, SessionContextDialogs } from '@jupyterlab/apputils';
5import { IChangedArgs, PathExt } from '@jupyterlab/coreutils';
6import {
7 Context,
8 DocumentRegistry,
9 IDocumentWidget
10} from '@jupyterlab/docregistry';
11import { Contents, Kernel, ServiceManager } from '@jupyterlab/services';
12import { ITranslator, nullTranslator } from '@jupyterlab/translation';
13import { ArrayExt, find } from '@lumino/algorithm';
14import { UUID } from '@lumino/coreutils';
15import { IDisposable } from '@lumino/disposable';
16import { AttachedProperty } from '@lumino/properties';
17import { ISignal, Signal } from '@lumino/signaling';
18import { Widget } from '@lumino/widgets';
19import { SaveHandler } from './savehandler';
20import {
21 IDocumentManager,
22 IDocumentWidgetOpener,
23 IRecentsManager
24} from './tokens';
25import { DocumentWidgetManager } from './widgetmanager';
26
27/**
28 * The document manager.
29 *
30 * #### Notes
31 * The document manager is used to register model and widget creators,
32 * and the file browser uses the document manager to create widgets. The
33 * document manager maintains a context for each path and model type that is
34 * open, and a list of widgets for each context. The document manager is in
35 * control of the proper closing and disposal of the widgets and contexts.
36 */
37export class DocumentManager implements IDocumentManager {
38 /**
39 * Construct a new document manager.
40 */
41 constructor(options: DocumentManager.IOptions) {
42 this.translator = options.translator || nullTranslator;
43 this.registry = options.registry;
44 this.services = options.manager;
45 this._dialogs =
46 options.sessionDialogs ??
47 new SessionContextDialogs({ translator: options.translator });
48 this._isConnectedCallback = options.isConnectedCallback || (() => true);
49
50 this._opener = options.opener;
51 this._when = options.when || options.manager.ready;
52
53 const widgetManager = new DocumentWidgetManager({
54 registry: this.registry,
55 translator: this.translator,
56 recentsManager: options.recentsManager
57 });
58 widgetManager.activateRequested.connect(this._onActivateRequested, this);
59 widgetManager.stateChanged.connect(this._onWidgetStateChanged, this);
60 this._widgetManager = widgetManager;
61 this._setBusy = options.setBusy;
62 }
63
64 /**
65 * The registry used by the manager.
66 */
67 readonly registry: DocumentRegistry;
68
69 /**
70 * The service manager used by the manager.
71 */
72 readonly services: ServiceManager.IManager;
73
74 /**
75 * A signal emitted when one of the documents is activated.
76 */
77 get activateRequested(): ISignal<this, string> {
78 return this._activateRequested;
79 }
80
81 /**
82 * Whether to autosave documents.
83 */
84 get autosave(): boolean {
85 return this._autosave;
86 }
87
88 set autosave(value: boolean) {
89 if (this._autosave !== value) {
90 const oldValue = this._autosave;
91 this._autosave = value;
92
93 // For each existing context, start/stop the autosave handler as needed.
94 this._contexts.forEach(context => {
95 const handler = Private.saveHandlerProperty.get(context);
96 if (!handler) {
97 return;
98 }
99 if (value === true && !handler.isActive) {
100 handler.start();
101 } else if (value === false && handler.isActive) {
102 handler.stop();
103 }
104 });
105 this._stateChanged.emit({
106 name: 'autosave',
107 oldValue,
108 newValue: value
109 });
110 }
111 }
112
113 /**
114 * Determines the time interval for autosave in seconds.
115 */
116 get autosaveInterval(): number {
117 return this._autosaveInterval;
118 }
119
120 set autosaveInterval(value: number) {
121 if (this._autosaveInterval !== value) {
122 const oldValue = this._autosaveInterval;
123 this._autosaveInterval = value;
124
125 // For each existing context, set the save interval as needed.
126 this._contexts.forEach(context => {
127 const handler = Private.saveHandlerProperty.get(context);
128 if (!handler) {
129 return;
130 }
131 handler.saveInterval = value || 120;
132 });
133 this._stateChanged.emit({
134 name: 'autosaveInterval',
135 oldValue,
136 newValue: value
137 });
138 }
139 }
140
141 /**
142 * Whether to ask confirmation to close a tab or not.
143 */
144 get confirmClosingDocument(): boolean {
145 return this._widgetManager.confirmClosingDocument;
146 }
147 set confirmClosingDocument(value: boolean) {
148 if (this._widgetManager.confirmClosingDocument !== value) {
149 const oldValue = this._widgetManager.confirmClosingDocument;
150 this._widgetManager.confirmClosingDocument = value;
151 this._stateChanged.emit({
152 name: 'confirmClosingDocument',
153 oldValue,
154 newValue: value
155 });
156 }
157 }
158
159 /**
160 * Defines max acceptable difference, in milliseconds, between last modified timestamps on disk and client
161 */
162 get lastModifiedCheckMargin(): number {
163 return this._lastModifiedCheckMargin;
164 }
165
166 set lastModifiedCheckMargin(value: number) {
167 if (this._lastModifiedCheckMargin !== value) {
168 const oldValue = this._lastModifiedCheckMargin;
169 this._lastModifiedCheckMargin = value;
170
171 // For each existing context, update the margin value.
172 this._contexts.forEach(context => {
173 context.lastModifiedCheckMargin = value;
174 });
175 this._stateChanged.emit({
176 name: 'lastModifiedCheckMargin',
177 oldValue,
178 newValue: value
179 });
180 }
181 }
182
183 /**
184 * Whether to ask the user to rename untitled file on first manual save.
185 */
186 get renameUntitledFileOnSave(): boolean {
187 return this._renameUntitledFileOnSave;
188 }
189
190 set renameUntitledFileOnSave(value: boolean) {
191 if (this._renameUntitledFileOnSave !== value) {
192 const oldValue = this._renameUntitledFileOnSave;
193 this._renameUntitledFileOnSave = value;
194 this._stateChanged.emit({
195 name: 'renameUntitledFileOnSave',
196 oldValue,
197 newValue: value
198 });
199 }
200 }
201
202 /**
203 * Signal triggered when an attribute changes.
204 */
205 get stateChanged(): ISignal<IDocumentManager, IChangedArgs<any>> {
206 return this._stateChanged;
207 }
208
209 /**
210 * Get whether the document manager has been disposed.
211 */
212 get isDisposed(): boolean {
213 return this._isDisposed;
214 }
215
216 /**
217 * Dispose of the resources held by the document manager.
218 */
219 dispose(): void {
220 if (this.isDisposed) {
221 return;
222 }
223 this._isDisposed = true;
224
225 // Clear any listeners for our signals.
226 Signal.clearData(this);
227
228 // Close all the widgets for our contexts and dispose the widget manager.
229 this._contexts.forEach(context => {
230 return this._widgetManager.closeWidgets(context);
231 });
232 this._widgetManager.dispose();
233
234 // Clear the context list.
235 this._contexts.length = 0;
236 }
237
238 /**
239 * Clone a widget.
240 *
241 * @param widget - The source widget.
242 *
243 * @returns A new widget or `undefined`.
244 *
245 * #### Notes
246 * Uses the same widget factory and context as the source, or returns
247 * `undefined` if the source widget is not managed by this manager.
248 */
249 cloneWidget(widget: Widget): IDocumentWidget | undefined {
250 return this._widgetManager.cloneWidget(widget);
251 }
252
253 /**
254 * Close all of the open documents.
255 *
256 * @returns A promise resolving when the widgets are closed.
257 */
258 closeAll(): Promise<void> {
259 return Promise.all(
260 this._contexts.map(context => this._widgetManager.closeWidgets(context))
261 ).then(() => undefined);
262 }
263
264 /**
265 * Close the widgets associated with a given path.
266 *
267 * @param path - The target path.
268 *
269 * @returns A promise resolving when the widgets are closed.
270 */
271 closeFile(path: string): Promise<void> {
272 const close = this._contextsForPath(path).map(c =>
273 this._widgetManager.closeWidgets(c)
274 );
275 return Promise.all(close).then(x => undefined);
276 }
277
278 /**
279 * Get the document context for a widget.
280 *
281 * @param widget - The widget of interest.
282 *
283 * @returns The context associated with the widget, or `undefined` if no such
284 * context exists.
285 */
286 contextForWidget(widget: Widget): DocumentRegistry.Context | undefined {
287 return this._widgetManager.contextForWidget(widget);
288 }
289
290 /**
291 * Copy a file.
292 *
293 * @param fromFile - The full path of the original file.
294 *
295 * @param toDir - The full path to the target directory.
296 *
297 * @returns A promise which resolves to the contents of the file.
298 */
299 copy(fromFile: string, toDir: string): Promise<Contents.IModel> {
300 return this.services.contents.copy(fromFile, toDir);
301 }
302
303 /**
304 * Create a new file and return the widget used to view it.
305 *
306 * @param path - The file path to create.
307 *
308 * @param widgetName - The name of the widget factory to use. 'default' will use the default widget.
309 *
310 * @param kernel - An optional kernel name/id to override the default.
311 *
312 * @returns The created widget, or `undefined`.
313 *
314 * #### Notes
315 * This function will return `undefined` if a valid widget factory
316 * cannot be found.
317 */
318 createNew(
319 path: string,
320 widgetName = 'default',
321 kernel?: Partial<Kernel.IModel>
322 ): Widget | undefined {
323 return this._createOrOpenDocument('create', path, widgetName, kernel);
324 }
325
326 /**
327 * Delete a file.
328 *
329 * @param path - The full path to the file to be deleted.
330 *
331 * @returns A promise which resolves when the file is deleted.
332 *
333 * #### Notes
334 * If there is a running session associated with the file and no other
335 * sessions are using the kernel, the session will be shut down.
336 */
337 deleteFile(path: string): Promise<void> {
338 return this.services.sessions
339 .stopIfNeeded(path)
340 .then(() => {
341 return this.services.contents.delete(path);
342 })
343 .then(() => {
344 this._contextsForPath(path).forEach(context =>
345 this._widgetManager.deleteWidgets(context)
346 );
347 return Promise.resolve(void 0);
348 });
349 }
350
351 /**
352 * Duplicate a file.
353 *
354 * @param path - The full path to the file to be duplicated.
355 *
356 * @returns A promise which resolves when the file is duplicated.
357 */
358 duplicate(path: string): Promise<Contents.IModel> {
359 const basePath = PathExt.dirname(path);
360 return this.services.contents.copy(path, basePath);
361 }
362
363 /**
364 * See if a widget already exists for the given path and widget name.
365 *
366 * @param path - The file path to use.
367 *
368 * @param widgetName - The name of the widget factory to use. 'default' will use the default widget.
369 *
370 * @returns The found widget, or `undefined`.
371 *
372 * #### Notes
373 * This can be used to find an existing widget instead of opening
374 * a new widget.
375 */
376 findWidget(
377 path: string,
378 widgetName: string | null = 'default'
379 ): IDocumentWidget | undefined {
380 const newPath = PathExt.normalize(path);
381 let widgetNames = [widgetName];
382 if (widgetName === 'default') {
383 const factory = this.registry.defaultWidgetFactory(newPath);
384 if (!factory) {
385 return undefined;
386 }
387 widgetNames = [factory.name];
388 } else if (widgetName === null) {
389 widgetNames = this.registry
390 .preferredWidgetFactories(newPath)
391 .map(f => f.name);
392 }
393
394 for (const context of this._contextsForPath(newPath)) {
395 for (const widgetName of widgetNames) {
396 if (widgetName !== null) {
397 const widget = this._widgetManager.findWidget(context, widgetName);
398 if (widget) {
399 return widget;
400 }
401 }
402 }
403 }
404 return undefined;
405 }
406
407 /**
408 * Create a new untitled file.
409 *
410 * @param options - The file content creation options.
411 */
412 newUntitled(options: Contents.ICreateOptions): Promise<Contents.IModel> {
413 if (options.type === 'file') {
414 options.ext = options.ext || '.txt';
415 }
416 return this.services.contents.newUntitled(options);
417 }
418
419 /**
420 * Open a file and return the widget used to view it.
421 *
422 * @param path - The file path to open.
423 *
424 * @param widgetName - The name of the widget factory to use. 'default' will use the default widget.
425 *
426 * @param kernel - An optional kernel name/id to override the default.
427 *
428 * @returns The created widget, or `undefined`.
429 *
430 * #### Notes
431 * This function will return `undefined` if a valid widget factory
432 * cannot be found.
433 */
434 open(
435 path: string,
436 widgetName = 'default',
437 kernel?: Partial<Kernel.IModel>,
438 options?: DocumentRegistry.IOpenOptions
439 ): IDocumentWidget | undefined {
440 return this._createOrOpenDocument(
441 'open',
442 path,
443 widgetName,
444 kernel,
445 options
446 );
447 }
448
449 /**
450 * Open a file and return the widget used to view it.
451 * Reveals an already existing editor.
452 *
453 * @param path - The file path to open.
454 *
455 * @param widgetName - The name of the widget factory to use. 'default' will use the default widget.
456 *
457 * @param kernel - An optional kernel name/id to override the default.
458 *
459 * @returns The created widget, or `undefined`.
460 *
461 * #### Notes
462 * This function will return `undefined` if a valid widget factory
463 * cannot be found.
464 */
465 openOrReveal(
466 path: string,
467 widgetName = 'default',
468 kernel?: Partial<Kernel.IModel>,
469 options?: DocumentRegistry.IOpenOptions
470 ): IDocumentWidget | undefined {
471 const widget = this.findWidget(path, widgetName);
472 if (widget) {
473 this._opener.open(widget, {
474 type: widgetName,
475 ...options
476 });
477 return widget;
478 }
479 return this.open(path, widgetName, kernel, options ?? {});
480 }
481
482 /**
483 * Overwrite a file.
484 *
485 * @param oldPath - The full path to the original file.
486 *
487 * @param newPath - The full path to the new file.
488 *
489 * @returns A promise containing the new file contents model.
490 */
491 overwrite(oldPath: string, newPath: string): Promise<Contents.IModel> {
492 // Cleanly overwrite the file by moving it, making sure the original does
493 // not exist, and then renaming to the new path.
494 const tempPath = `${newPath}.${UUID.uuid4()}`;
495 const cb = () => this.rename(tempPath, newPath);
496 return this.rename(oldPath, tempPath)
497 .then(() => {
498 return this.deleteFile(newPath);
499 })
500 .then(cb, cb);
501 }
502
503 /**
504 * Rename a file or directory.
505 *
506 * @param oldPath - The full path to the original file.
507 *
508 * @param newPath - The full path to the new file.
509 *
510 * @returns A promise containing the new file contents model. The promise
511 * will reject if the newPath already exists. Use [[overwrite]] to overwrite
512 * a file.
513 */
514 rename(oldPath: string, newPath: string): Promise<Contents.IModel> {
515 return this.services.contents.rename(oldPath, newPath);
516 }
517
518 /**
519 * Find a context for a given path and factory name.
520 */
521 private _findContext(
522 path: string,
523 factoryName: string
524 ): Private.IContext | undefined {
525 const normalizedPath = this.services.contents.normalize(path);
526 return find(this._contexts, context => {
527 return (
528 context.path === normalizedPath && context.factoryName === factoryName
529 );
530 });
531 }
532
533 /**
534 * Get the contexts for a given path.
535 *
536 * #### Notes
537 * There may be more than one context for a given path if the path is open
538 * with multiple model factories (for example, a notebook can be open with a
539 * notebook model factory and a text model factory).
540 */
541 private _contextsForPath(path: string): Private.IContext[] {
542 const normalizedPath = this.services.contents.normalize(path);
543 return this._contexts.filter(context => context.path === normalizedPath);
544 }
545
546 /**
547 * Create a context from a path and a model factory.
548 */
549 private _createContext(
550 path: string,
551 factory: DocumentRegistry.ModelFactory,
552 kernelPreference?: ISessionContext.IKernelPreference
553 ): Private.IContext {
554 // TODO: Make it impossible to open two different contexts for the same
555 // path. Or at least prompt the closing of all widgets associated with the
556 // old context before opening the new context. This will make things much
557 // more consistent for the users, at the cost of some confusion about what
558 // models are and why sometimes they cannot open the same file in different
559 // widgets that have different models.
560
561 // Allow options to be passed when adding a sibling.
562 const adopter = (
563 widget: IDocumentWidget,
564 options?: DocumentRegistry.IOpenOptions
565 ) => {
566 this._widgetManager.adoptWidget(context, widget);
567 // TODO should we pass the type for layout customization
568 this._opener.open(widget, options);
569 };
570 const context = new Context({
571 opener: adopter,
572 manager: this.services,
573 factory,
574 path,
575 kernelPreference,
576 setBusy: this._setBusy,
577 sessionDialogs: this._dialogs,
578 lastModifiedCheckMargin: this._lastModifiedCheckMargin,
579 translator: this.translator
580 });
581 const handler = new SaveHandler({
582 context,
583 isConnectedCallback: this._isConnectedCallback,
584 saveInterval: this.autosaveInterval
585 });
586 Private.saveHandlerProperty.set(context, handler);
587 void context.ready.then(() => {
588 if (this.autosave) {
589 handler.start();
590 }
591 });
592 context.disposed.connect(this._onContextDisposed, this);
593 this._contexts.push(context);
594 return context;
595 }
596
597 /**
598 * Handle a context disposal.
599 */
600 private _onContextDisposed(context: Private.IContext): void {
601 ArrayExt.removeFirstOf(this._contexts, context);
602 }
603
604 /**
605 * Get the widget factory for a given widget name.
606 */
607 private _widgetFactoryFor(
608 path: string,
609 widgetName: string
610 ): DocumentRegistry.WidgetFactory | undefined {
611 const { registry } = this;
612 if (widgetName === 'default') {
613 const factory = registry.defaultWidgetFactory(path);
614 if (!factory) {
615 return undefined;
616 }
617 widgetName = factory.name;
618 }
619 return registry.getWidgetFactory(widgetName);
620 }
621
622 /**
623 * Creates a new document, or loads one from disk, depending on the `which` argument.
624 * If `which==='create'`, then it creates a new document. If `which==='open'`,
625 * then it loads the document from disk.
626 *
627 * The two cases differ in how the document context is handled, but the creation
628 * of the widget and launching of the kernel are identical.
629 */
630 private _createOrOpenDocument(
631 which: 'open' | 'create',
632 path: string,
633 widgetName = 'default',
634 kernel?: Partial<Kernel.IModel>,
635 options?: DocumentRegistry.IOpenOptions
636 ): IDocumentWidget | undefined {
637 const widgetFactory = this._widgetFactoryFor(path, widgetName);
638 if (!widgetFactory) {
639 return undefined;
640 }
641 const modelName = widgetFactory.modelName || 'text';
642 const factory = this.registry.getModelFactory(modelName);
643 if (!factory) {
644 return undefined;
645 }
646
647 // Handle the kernel preference.
648 const preference = this.registry.getKernelPreference(
649 path,
650 widgetFactory.name,
651 kernel
652 );
653
654 let context: Private.IContext | null;
655 let ready: Promise<void> = Promise.resolve(undefined);
656
657 // Handle the load-from-disk case
658 if (which === 'open') {
659 // Use an existing context if available.
660 context = this._findContext(path, factory.name) || null;
661 if (!context) {
662 context = this._createContext(path, factory, preference);
663 // Populate the model, either from disk or a
664 // model backend.
665 ready = this._when.then(() => context!.initialize(false));
666 }
667 } else if (which === 'create') {
668 context = this._createContext(path, factory, preference);
669 // Immediately save the contents to disk.
670 ready = this._when.then(() => context!.initialize(true));
671 } else {
672 throw new Error(`Invalid argument 'which': ${which}`);
673 }
674
675 const widget = this._widgetManager.createWidget(widgetFactory, context);
676 this._opener.open(widget, { type: widgetFactory.name, ...options });
677
678 // If the initial opening of the context fails, dispose of the widget.
679 ready.catch(err => {
680 console.error(
681 `Failed to initialize the context with '${factory.name}' for ${path}`,
682 err
683 );
684 widget.close();
685 });
686
687 return widget;
688 }
689
690 /**
691 * Handle an activateRequested signal from the widget manager.
692 */
693 private _onActivateRequested(
694 sender: DocumentWidgetManager,
695 args: string
696 ): void {
697 this._activateRequested.emit(args);
698 }
699
700 protected _onWidgetStateChanged(
701 sender: DocumentWidgetManager,
702 args: IChangedArgs<any>
703 ): void {
704 if (args.name === 'confirmClosingDocument') {
705 this._stateChanged.emit(args);
706 }
707 }
708
709 protected translator: ITranslator;
710 private _activateRequested = new Signal<this, string>(this);
711 private _contexts: Private.IContext[] = [];
712 private _opener: IDocumentWidgetOpener;
713 private _widgetManager: DocumentWidgetManager;
714 private _isDisposed = false;
715 private _autosave = true;
716 private _autosaveInterval = 120;
717 private _lastModifiedCheckMargin = 500;
718 private _renameUntitledFileOnSave = true;
719 private _when: Promise<void>;
720 private _setBusy: (() => IDisposable) | undefined;
721 private _dialogs: ISessionContext.IDialogs;
722 private _isConnectedCallback: () => boolean;
723 private _stateChanged = new Signal<DocumentManager, IChangedArgs<any>>(this);
724}
725
726/**
727 * A namespace for document manager statics.
728 */
729export namespace DocumentManager {
730 /**
731 * The options used to initialize a document manager.
732 */
733 export interface IOptions {
734 /**
735 * A document registry instance.
736 */
737 registry: DocumentRegistry;
738
739 /**
740 * A service manager instance.
741 */
742 manager: ServiceManager.IManager;
743
744 /**
745 * A widget opener for sibling widgets.
746 */
747 opener: IDocumentWidgetOpener;
748
749 /**
750 * A promise for when to start using the manager.
751 */
752 when?: Promise<void>;
753
754 /**
755 * A function called when a kernel is busy.
756 */
757 setBusy?: () => IDisposable;
758
759 /**
760 * The provider for session dialogs.
761 */
762 sessionDialogs?: ISessionContext.IDialogs;
763
764 /**
765 * The application language translator.
766 */
767 translator?: ITranslator;
768
769 /**
770 * Autosaving should be paused while this callback function returns `false`.
771 * By default, it always returns `true`.
772 */
773 isConnectedCallback?: () => boolean;
774
775 /**
776 * The manager for recent documents.
777 */
778 recentsManager?: IRecentsManager;
779 }
780}
781
782/**
783 * A namespace for private data.
784 */
785namespace Private {
786 /**
787 * An attached property for a context save handler.
788 */
789 export const saveHandlerProperty = new AttachedProperty<
790 DocumentRegistry.Context,
791 SaveHandler | undefined
792 >({
793 name: 'saveHandler',
794 create: () => undefined
795 });
796
797 /**
798 * A type alias for a standard context.
799 *
800 * #### Notes
801 * We define this as an interface of a specific implementation so that we can
802 * use the implementation-specific functions.
803 */
804 export interface IContext extends Context<DocumentRegistry.IModel> {
805 /* no op */
806 }
807}