Parcourir la source

Merge branch 'master' of https://github.com/jupyterlab/jupyterlab

Steven Silvester il y a 6 ans
Parent
commit
f015066f0a

+ 7 - 0
jupyterlab/labapp.py

@@ -198,6 +198,13 @@ class LabApp(NotebookApp):
     watch = Bool(False, config=True,
         help="Whether to serve the app in watch mode")
 
+    def init_webapp(self, *args, **kwargs):
+        super().init_webapp(*args, **kwargs)
+        settings = self.web_app.settings
+        if 'page_config_data' not in settings:
+            settings['page_config_data'] = {}
+        settings['page_config_data']['quit_button'] = self.quit_button
+
     def init_server_extensions(self):
         """Load any extensions specified by config.
 

+ 9 - 5
packages/filebrowser/src/model.ts

@@ -238,7 +238,7 @@ export class FileBrowserModel implements IDisposable {
    *
    * @returns A promise with the contents of the directory.
    */
-  cd(newValue = '.'): Promise<void> {
+  async cd(newValue = '.'): Promise<void> {
     if (newValue !== '.') {
       newValue = Private.normalizePath(
         this.manager.services.contents,
@@ -248,9 +248,13 @@ export class FileBrowserModel implements IDisposable {
     } else {
       newValue = this._pendingPath || this._model.path;
     }
-    // Collapse requests to the same directory.
-    if (newValue === this._pendingPath && this._pending) {
-      return this._pending;
+    if (this._pending) {
+      // Collapse requests to the same directory.
+      if (newValue === this._pendingPath) {
+        return this._pending;
+      }
+      // Otherwise wait for the pending request to complete before continuing.
+      await this._pending;
     }
     let oldValue = this.path;
     let options: Contents.IFetchOptions = { content: true };
@@ -289,7 +293,7 @@ export class FileBrowserModel implements IDisposable {
           error.message = `Directory not found: "${this._model.path}"`;
           console.error(error);
           this._connectionFailure.emit(error);
-          this.cd('/');
+          return this.cd('/');
         } else {
           this._refreshDuration = this._baseRefreshDuration * 10;
           this._connectionFailure.emit(error);

+ 2 - 0
packages/mainmenu-extension/package.json

@@ -30,7 +30,9 @@
   "dependencies": {
     "@jupyterlab/application": "^0.18.4",
     "@jupyterlab/apputils": "^0.18.4",
+    "@jupyterlab/coreutils": "^2.1.4",
     "@jupyterlab/mainmenu": "^0.7.4",
+    "@jupyterlab/services": "^3.1.4",
     "@phosphor/algorithm": "^1.1.2",
     "@phosphor/disposable": "^1.1.2",
     "@phosphor/widgets": "^1.6.0"

+ 59 - 0
packages/mainmenu-extension/src/index.ts

@@ -11,6 +11,8 @@ import { JupyterLab, JupyterLabPlugin } from '@jupyterlab/application';
 
 import { ICommandPalette, showDialog, Dialog } from '@jupyterlab/apputils';
 
+import { PageConfig, URLExt } from '@jupyterlab/coreutils';
+
 import {
   IMainMenu,
   IMenuExtender,
@@ -24,6 +26,8 @@ import {
   TabsMenu
 } from '@jupyterlab/mainmenu';
 
+import { ServerConnection } from '@jupyterlab/services';
+
 /**
  * A namespace for command IDs of semantic extension points.
  */
@@ -49,6 +53,8 @@ export namespace CommandIDs {
 
   export const createConsole = 'filemenu:create-console';
 
+  export const quit = 'filemenu:quit';
+
   export const interruptKernel = 'kernelmenu:interrupt';
 
   export const restartKernel = 'kernelmenu:restart';
@@ -94,6 +100,9 @@ const menuPlugin: JupyterLabPlugin<IMainMenu> = {
     logo.addClass('jp-JupyterIcon');
     logo.id = 'jp-MainLogo';
 
+    let quitButton = PageConfig.getOption('quit_button');
+    menu.fileMenu.quitEntry = quitButton === 'True';
+
     // Create the application menus.
     createEditMenu(app, menu.editMenu);
     createFileMenu(app, menu.fileMenu);
@@ -103,6 +112,13 @@ const menuPlugin: JupyterLabPlugin<IMainMenu> = {
     createViewMenu(app, menu.viewMenu);
     createTabsMenu(app, menu.tabsMenu);
 
+    if (menu.fileMenu.quitEntry) {
+      palette.addItem({
+        command: CommandIDs.quit,
+        category: 'Main Area'
+      });
+    }
+
     palette.addItem({
       command: CommandIDs.shutdownAllKernels,
       category: 'Kernel Operations'
@@ -258,6 +274,43 @@ export function createFileMenu(app: JupyterLab, menu: FileMenu): void {
     execute: Private.delegateExecute(app, menu.consoleCreators, 'createConsole')
   });
 
+  commands.addCommand(CommandIDs.quit, {
+    label: 'Quit',
+    caption: 'Quit JupyterLab',
+    execute: () => {
+      showDialog({
+        title: 'Quit confirmation',
+        body: 'Please confirm you want to quit JupyterLab.',
+        buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Quit' })]
+      }).then(result => {
+        if (result.button.accept) {
+          let setting = ServerConnection.makeSettings();
+          let apiURL = URLExt.join(setting.baseUrl, 'api/shutdown');
+          ServerConnection.makeRequest(apiURL, { method: 'POST' }, setting)
+            .then(result => {
+              if (result.ok) {
+                // Close this window if the shutdown request has been successful
+                let body = document.createElement('div');
+                body.innerHTML = `<p>You have shut down the Jupyter server. You can now close this tab.</p>
+                  <p>To use JupyterLab again, you will need to relaunch it.</p>`;
+                showDialog({
+                  title: 'Server stopped',
+                  body: new Widget({ node: body }),
+                  buttons: []
+                });
+                window.close();
+              } else {
+                throw new ServerConnection.ResponseError(result);
+              }
+            })
+            .catch(data => {
+              throw new ServerConnection.NetworkError(data);
+            });
+        }
+      });
+    }
+  });
+
   // Add the new group
   const newGroup = [
     { type: 'submenu' as Menu.ItemType, submenu: menu.newMenu.menu },
@@ -298,11 +351,17 @@ export function createFileMenu(app: JupyterLab, menu: FileMenu): void {
     return { command };
   });
 
+  // Add the quit group.
+  const quitGroup = [{ command: 'filemenu:quit' }];
+
   menu.addGroup(newGroup, 0);
   menu.addGroup(newViewGroup, 1);
   menu.addGroup(closeGroup, 2);
   menu.addGroup(saveGroup, 3);
   menu.addGroup(reGroup, 4);
+  if (menu.quitEntry) {
+    menu.addGroup(quitGroup, 99);
+  }
 }
 
 /**

+ 12 - 0
packages/mainmenu/src/file.ts

@@ -9,6 +9,11 @@ import { IJupyterLabMenu, IMenuExtender, JupyterLabMenu } from './labmenu';
  * An interface for a File menu.
  */
 export interface IFileMenu extends IJupyterLabMenu {
+  /**
+   * Option to add a `Quit` entry in the File menu
+   */
+  quitEntry: boolean;
+
   /**
    * A submenu for creating new files/launching new activities.
    */
@@ -39,6 +44,8 @@ export class FileMenu extends JupyterLabMenu implements IFileMenu {
 
     this.menu.title.label = 'File';
 
+    this.quitEntry = false;
+
     // Create the "New" submenu.
     this.newMenu = new JupyterLabMenu(options, false);
     this.newMenu.menu.title.label = 'New';
@@ -75,6 +82,11 @@ export class FileMenu extends JupyterLabMenu implements IFileMenu {
     this.consoleCreators.clear();
     super.dispose();
   }
+
+  /**
+   * Option to add a `Quit` entry in File menu
+   */
+  public quitEntry: boolean;
 }
 
 /**

+ 55 - 1
tests/test-filebrowser/src/model.spec.ts

@@ -11,7 +11,11 @@ import { DocumentManager, IDocumentManager } from '@jupyterlab/docmanager';
 
 import { DocumentRegistry, TextModelFactory } from '@jupyterlab/docregistry';
 
-import { ServiceManager } from '@jupyterlab/services';
+import {
+  Contents,
+  ContentsManager,
+  ServiceManager
+} from '@jupyterlab/services';
 
 import {
   FileBrowserModel,
@@ -26,6 +30,29 @@ import {
 } from '@jupyterlab/testutils';
 import { toArray } from '@phosphor/algorithm';
 
+/**
+ * A contents manager that delays requests by less each time it is called
+ * in order to simulate out-of-order responses from the server.
+ */
+class DelayedContentsManager extends ContentsManager {
+  get(
+    path: string,
+    options?: Contents.IFetchOptions
+  ): Promise<Contents.IModel> {
+    return new Promise<Contents.IModel>(resolve => {
+      const delay = this._delay;
+      this._delay -= 500;
+      super.get(path, options).then(contents => {
+        setTimeout(() => {
+          resolve(contents);
+        }, Math.max(delay, 0));
+      });
+    });
+  }
+
+  private _delay = 1000;
+}
+
 describe('filebrowser/model', () => {
   let manager: IDocumentManager;
   let serviceManager: ServiceManager.IManager;
@@ -223,6 +250,33 @@ describe('filebrowser/model', () => {
         await model.cd('..');
         expect(model.path).to.equal('');
       });
+
+      it('should be resilient to a slow initial fetch', async () => {
+        let delayedServiceManager = new ServiceManager();
+        (delayedServiceManager as any).contents = new DelayedContentsManager();
+        let manager = new DocumentManager({
+          registry,
+          opener,
+          manager: delayedServiceManager
+        });
+        model = new FileBrowserModel({ manager, state });
+
+        const paths: string[] = [];
+        // An initial refresh is called in the constructor.
+        // If it is too slow, it can come in after the directory change,
+        // causing a directory set by, e.g., the tree handler to be wrong.
+        // This checks to make sure we are handling that case correctly.
+        const refresh = model.refresh().then(() => paths.push(model.path));
+        const cd = model.cd('src').then(() => paths.push(model.path));
+        await Promise.all([refresh, cd]);
+        expect(model.path).to.equal('src');
+        expect(paths).to.eql(['', 'src']);
+
+        manager.dispose();
+        delayedServiceManager.contents.dispose();
+        delayedServiceManager.dispose();
+        model.dispose();
+      });
     });
 
     describe('#restore()', () => {