瀏覽代碼

Rationalize path handling and workspaces. (#5005)

* Fixes https://github.com/jupyterlab/jupyterlab/issues/5002

* Fix workspace URL construction.

* Fix tree URLs

* Improve URL join() function.

* Fix public URLs (static resources).

* Rationalize /tree URLs.

* More URL cleanup. Add page config URLs to application.

* Minor performance tweak.

* Add `workspace` to application info.

* Fix Router#current test to reflect rationalized path handling.

* Make sure window name candidate accounts for base URL.

* Add `defaultWorkspace` to application info.

* Remove extraneous import.

* Simplify state loading behavior, remove distinction between named and unnamed workspaces, update state recovery behavior, modernize promises.

* Modernize tree command.

* Partially fix tree handling, redirect still not correct.

* Fix tree handling in hub and lab.

* Fallthrough is unnecessary, clean up state on unload.

* Fix default workspace setting.

* Clean up default workspace construction.

* Fix apputils test compilation issue, removing an unused variable. Tweak a test.

* Add missing dev dependency @types/text-encoding

* Add workspace manager test that fails without https://github.com/jupyterlab/jupyterlab_launcher/pull/51

* Suppress errors on unload if state database clearing fails.

* Update jupyterlab_launcher version.

* Fix broken test.
Afshin Darian 6 年之前
父節點
當前提交
5c19f25028

+ 5 - 2
dev_mode/index.js

@@ -6,10 +6,13 @@
 require('es6-promise/auto');  // polyfill Promise on IE
 
 import {
-  PageConfig
+  PageConfig, URLExt
 } from '@jupyterlab/coreutils';
 
-__webpack_public_path__ = PageConfig.getOption('publicUrl');
+__webpack_public_path__ = URLExt.join(
+  PageConfig.getOption('baseUrl'),
+  PageConfig.getOption('publicUrl')
+);
 
 // This needs to come after __webpack_public_path__ is set.
 require('font-awesome/css/font-awesome.min.css');

+ 0 - 1
jupyterlab/extension_manager_handler.py

@@ -11,7 +11,6 @@ from concurrent.futures import ThreadPoolExecutor
 
 from notebook.base.handlers import APIHandler
 from tornado import gen, web
-from tornado.ioloop import IOLoop
 
 from .commands import (
     get_app_info, install_extension, uninstall_extension,

+ 30 - 15
packages/application-extension/src/index.tsx

@@ -18,7 +18,7 @@ import {
   showErrorMessage
 } from '@jupyterlab/apputils';
 
-import { IStateDB, PageConfig } from '@jupyterlab/coreutils';
+import { IStateDB, PageConfig, PathExt, URLExt } from '@jupyterlab/coreutils';
 
 import * as React from 'react';
 
@@ -51,7 +51,13 @@ namespace CommandIDs {
  * The routing regular expressions used by the application plugin.
  */
 namespace Patterns {
-  export const tree = /[^?]*(\/tree\/([^?]+))/;
+  export const tree = new RegExp(
+    `^${PageConfig.getOption('treeUrl')}([^?\/]+)`
+  );
+
+  export const workspace = new RegExp(
+    `^${PageConfig.getOption('workspacesUrl')}[^?\/]+/tree/([^?\/]+)`
+  );
 }
 
 /**
@@ -69,7 +75,7 @@ const main: JupyterLabPlugin<void> = {
     // Requiring the window resolver guarantees that the application extension
     // only loads if there is a viable window name. Otherwise, the application
     // will short-circuit and ask the user to navigate away.
-    const workspace = resolver.name ? `"${resolver.name}"` : '[default: /lab]';
+    const workspace = resolver.name;
 
     console.log(`Starting application in workspace: ${workspace}`);
 
@@ -193,7 +199,7 @@ const router: JupyterLabPlugin<IRouter> = {
   id: '@jupyterlab/application-extension:router',
   activate: (app: JupyterLab) => {
     const { commands } = app;
-    const base = PageConfig.getOption('pageUrl');
+    const base = PageConfig.getOption('baseUrl');
     const router = new Router({ base, commands });
 
     app.started.then(() => {
@@ -223,25 +229,34 @@ const tree: JupyterLabPlugin<void> = {
     const { commands } = app;
 
     commands.addCommand(CommandIDs.tree, {
-      execute: (args: IRouter.ILocation) => {
-        const { request } = args;
-        const path = decodeURIComponent(args.path.match(Patterns.tree)[2]);
-        const url = request.replace(request.match(Patterns.tree)[1], '');
+      execute: async (args: IRouter.ILocation) => {
+        const treeMatch = args.path.match(Patterns.tree);
+        const workspaceMatch = args.path.match(Patterns.workspace);
+        const match = treeMatch || workspaceMatch;
+        const path = decodeURIComponent(match[1]);
+        const { page, workspaces } = app.info.urls;
+        const workspace = PathExt.basename(app.info.workspace);
+        const url =
+          (workspaceMatch ? URLExt.join(workspaces, workspace) : page) +
+          args.search +
+          args.hash;
         const immediate = true;
+        const silent = true;
 
         // Silently remove the tree portion of the URL leaving the rest intact.
-        router.navigate(url, { silent: true });
+        router.navigate(url, { silent });
 
-        return commands
-          .execute('filebrowser:navigate', { path })
-          .then(() => commands.execute('apputils:save-statedb', { immediate }))
-          .catch(reason => {
-            console.warn(`Tree routing failed:`, reason);
-          });
+        try {
+          await commands.execute('filebrowser:navigate', { path });
+          await commands.execute('apputils:save-statedb', { immediate });
+        } catch (error) {
+          console.warn('Tree routing failed.', error);
+        }
       }
     });
 
     router.register({ command: CommandIDs.tree, pattern: Patterns.tree });
+    router.register({ command: CommandIDs.tree, pattern: Patterns.workspace });
   }
 };
 

+ 53 - 13
packages/application/src/index.ts

@@ -4,7 +4,7 @@
 // Local CSS must be loaded prior to loading other libs.
 import '../style/index.css';
 
-import { PageConfig } from '@jupyterlab/coreutils';
+import { PageConfig, URLExt } from '@jupyterlab/coreutils';
 
 import { CommandLinker } from '@jupyterlab/apputils';
 
@@ -44,24 +44,44 @@ export class JupyterLab extends Application<ApplicationShell> {
     super({ shell: new ApplicationShell() });
     this._busySignal = new Signal(this);
     this._dirtySignal = new Signal(this);
-    this._info = { ...JupyterLab.defaultInfo, ...options };
+
+    // Construct the default workspace name.
+    const defaultWorkspace = URLExt.join(
+      PageConfig.getOption('baseUrl'),
+      PageConfig.getOption('pageUrl')
+    );
+
+    // Set default workspace in page config.
+    PageConfig.setOption('defaultWorkspace', defaultWorkspace);
+
+    // Populate application info.
+    this._info = {
+      ...JupyterLab.defaultInfo,
+      ...options,
+      ...{ defaultWorkspace }
+    };
+
     if (this._info.devMode) {
       this.shell.addClass('jp-mod-devMode');
     }
 
-    this.serviceManager = new ServiceManager();
+    // Make workspace accessible via a getter because it is set at runtime.
+    Object.defineProperty(this._info, 'workspace', {
+      get: () => PageConfig.getOption('workspace') || ''
+    });
 
-    let linker = new CommandLinker({ commands: this.commands });
-    this.commandLinker = linker;
+    // Instantiate public resources.
+    this.serviceManager = new ServiceManager();
+    this.commandLinker = new CommandLinker({ commands: this.commands });
+    this.docRegistry = new DocumentRegistry();
 
-    let registry = (this.docRegistry = new DocumentRegistry());
-    registry.addModelFactory(new Base64ModelFactory());
+    // Add initial model factory.
+    this.docRegistry.addModelFactory(new Base64ModelFactory());
 
     if (options.mimeExtensions) {
-      let plugins = createRendermimePlugins(options.mimeExtensions);
-      plugins.forEach(plugin => {
+      for (let plugin of createRendermimePlugins(options.mimeExtensions)) {
         this.registerPlugin(plugin);
-      });
+      }
     }
   }
 
@@ -353,10 +373,13 @@ export namespace JupyterLab {
      * The urls used by the application.
      */
     readonly urls: {
+      readonly base: string;
       readonly page: string;
       readonly public: string;
       readonly settings: string;
       readonly themes: string;
+      readonly tree: string;
+      readonly workspaces: string;
     };
 
     /**
@@ -370,12 +393,23 @@ export namespace JupyterLab {
       readonly themes: string;
       readonly userSettings: string;
       readonly serverRoot: string;
+      readonly workspaces: string;
     };
 
     /**
      * Whether files are cached on the server.
      */
     readonly filesCached: boolean;
+
+    /**
+     * The name of the current workspace.
+     */
+    readonly workspace: string;
+
+    /**
+     * The name of the default workspace.
+     */
+    readonly defaultWorkspace: string;
   }
 
   /**
@@ -390,10 +424,13 @@ export namespace JupyterLab {
     disabled: { patterns: [], matches: [] },
     mimeExtensions: [],
     urls: {
+      base: PageConfig.getOption('baseUrl'),
       page: PageConfig.getOption('pageUrl'),
       public: PageConfig.getOption('publicUrl'),
       settings: PageConfig.getOption('settingsUrl'),
-      themes: PageConfig.getOption('themesUrl')
+      themes: PageConfig.getOption('themesUrl'),
+      tree: PageConfig.getOption('treeUrl'),
+      workspaces: PageConfig.getOption('workspacesUrl')
     },
     directories: {
       appSettings: PageConfig.getOption('appSettingsDir'),
@@ -402,9 +439,12 @@ export namespace JupyterLab {
       templates: PageConfig.getOption('templatesDir'),
       themes: PageConfig.getOption('themesDir'),
       userSettings: PageConfig.getOption('userSettingsDir'),
-      serverRoot: PageConfig.getOption('serverRoot')
+      serverRoot: PageConfig.getOption('serverRoot'),
+      workspaces: PageConfig.getOption('workspacesDir')
     },
-    filesCached: PageConfig.getOption('cacheFiles').toLowerCase() === 'true'
+    filesCached: PageConfig.getOption('cacheFiles').toLowerCase() === 'true',
+    workspace: '',
+    defaultWorkspace: ''
   };
 
   /**

+ 4 - 2
packages/application/src/router.ts

@@ -185,7 +185,7 @@ export class Router implements IRouter {
     const { base } = this;
     const parsed = URLExt.parse(window.location.href);
     const { search, hash } = parsed;
-    const path = parsed.pathname.replace(base, '');
+    const path = parsed.pathname.replace(base, '/');
     const request = path + search + hash;
 
     return { hash, path, request, search };
@@ -212,9 +212,11 @@ export class Router implements IRouter {
    * @param options - The navigation options.
    */
   navigate(path: string, options: IRouter.INavOptions = {}): void {
-    const url = path ? URLExt.join(this.base, path) : this.base;
+    const { base } = this;
     const { history } = window;
     const { hard, silent } = options;
+    const url =
+      path && path.indexOf(base) === 0 ? path : URLExt.join(base, path);
 
     if (silent) {
       history.replaceState({}, '', url);

+ 153 - 172
packages/apputils-extension/src/index.ts

@@ -81,11 +81,11 @@ namespace CommandIDs {
  * The routing regular expressions used by the apputils plugin.
  */
 namespace Patterns {
-  export const cloneState = /[?&]clone([=&]|$)/;
-
-  export const loadState = /^\/workspaces\/([^?\/]+)/;
-
   export const resetOnLoad = /(\?reset|\&reset)($|&)/;
+
+  export const workspace = new RegExp(
+    `^${PageConfig.getOption('workspacesUrl')}([^?\/]+)`
+  );
 }
 
 /**
@@ -181,7 +181,7 @@ const themes: JupyterLabPlugin<IThemeManager> = {
   ): IThemeManager => {
     const host = app.shell;
     const commands = app.commands;
-    const url = app.info.urls.themes;
+    const url = URLExt.join(app.info.urls.base, app.info.urls.themes);
     const key = themes.id;
     const manager = new ThemeManager({ key, host, settings, splash, url });
 
@@ -264,22 +264,32 @@ const resolver: JupyterLabPlugin<IWindowResolver> = {
   autoStart: true,
   provides: IWindowResolver,
   requires: [IRouter],
-  activate: (app: JupyterLab, router: IRouter) => {
-    const candidate = Private.getWorkspace(router) || '';
+  activate: async (app: JupyterLab, router: IRouter) => {
     const resolver = new WindowResolver();
+    const match = router.current.path.match(Patterns.workspace);
+    const workspace = (match && decodeURIComponent(match[1])) || '';
+    const candidate = workspace
+      ? URLExt.join(
+          PageConfig.getOption('baseUrl'),
+          PageConfig.getOption('workspacesUrl'),
+          workspace
+        )
+      : app.info.defaultWorkspace;
+
+    try {
+      await resolver.resolve(candidate);
+    } catch (error) {
+      console.warn('Window resolution failed:', error);
+
+      // Return a promise that never resolves.
+      return new Promise<IWindowResolver>(() => {
+        Private.redirect(router);
+      });
+    }
 
-    return resolver
-      .resolve(candidate)
-      .catch(reason => {
-        console.warn('Window resolution failed:', reason);
-
-        return Private.redirect(router);
-      })
-      .then(() => {
-        PageConfig.setOption('workspace', resolver.name);
+    PageConfig.setOption('workspace', resolver.name);
 
-        return resolver;
-      });
+    return resolver;
   }
 };
 
@@ -328,15 +338,30 @@ const state: JupyterLabPlugin<IStateDB> = {
     });
 
     commands.addCommand(CommandIDs.recoverState, {
-      execute: () => {
+      execute: async ({ global }) => {
         const immediate = true;
         const silent = true;
 
         // Clear the state silently so that the state changed signal listener
         // will not be triggered as it causes a save state.
-        return state
-          .clear(silent)
-          .then(() => commands.execute(CommandIDs.saveState, { immediate }));
+        await state.clear(silent);
+
+        // If the user explictly chooses to recover state, all of local storage
+        // should be cleared.
+        if (global) {
+          try {
+            window.localStorage.clear();
+            console.log('Cleared local storage');
+          } catch (error) {
+            console.warn('Clearing local storage failed.', error);
+
+            // To give the user time to see the console warning before redirect,
+            // do not set the `immediate` flag.
+            return commands.execute(CommandIDs.saveState);
+          }
+        }
+
+        return commands.execute(CommandIDs.saveState, { immediate });
       }
     });
 
@@ -345,16 +370,10 @@ const state: JupyterLabPlugin<IStateDB> = {
     let conflated: PromiseDelegate<void> | null = null;
 
     commands.addCommand(CommandIDs.saveState, {
-      label: () => `Save Workspace (${Private.getWorkspace(router)})`,
-      isEnabled: () => !!Private.getWorkspace(router),
-      execute: args => {
-        const workspace = Private.getWorkspace(router);
-
-        if (!workspace) {
-          return;
-        }
-
-        const timeout = args.immediate ? 0 : WORKSPACE_SAVE_DEBOUNCE_INTERVAL;
+      label: () => `Save Workspace (${app.info.workspace})`,
+      execute: ({ immediate }) => {
+        const { workspace } = app.info;
+        const timeout = immediate ? 0 : WORKSPACE_SAVE_DEBOUNCE_INTERVAL;
         const id = workspace;
         const metadata = { id };
 
@@ -367,23 +386,21 @@ const state: JupyterLabPlugin<IStateDB> = {
           window.clearTimeout(debouncer);
         }
 
-        debouncer = window.setTimeout(() => {
+        debouncer = window.setTimeout(async () => {
           // Prevent a race condition between the timeout and saving.
           if (!conflated) {
             return;
           }
 
-          state
-            .toJSON()
-            .then(data => workspaces.save(id, { data, metadata }))
-            .then(() => {
-              conflated.resolve(undefined);
-              conflated = null;
-            })
-            .catch(reason => {
-              conflated.reject(reason);
-              conflated = null;
-            });
+          const data = await state.toJSON();
+
+          try {
+            await workspaces.save(id, { data, metadata });
+            conflated.resolve(undefined);
+          } catch (error) {
+            conflated.reject(error);
+          }
+          conflated = null;
         }, timeout);
 
         return conflated.promise;
@@ -395,7 +412,7 @@ const state: JupyterLabPlugin<IStateDB> = {
     };
 
     commands.addCommand(CommandIDs.loadState, {
-      execute: (args: IRouter.ILocation) => {
+      execute: async (args: IRouter.ILocation) => {
         // Since the command can be executed an arbitrary number of times, make
         // sure it is safe to call multiple times.
         if (resolved) {
@@ -403,110 +420,87 @@ const state: JupyterLabPlugin<IStateDB> = {
         }
 
         const { hash, path, search } = args;
-        const workspace = Private.getWorkspace(router);
+        const { defaultWorkspace, workspace } = app.info;
         const query = URLExt.queryStringToObject(search || '');
-        const clone = query['clone'];
-        const source = typeof clone === 'string' ? clone : workspace;
-
-        let promise: Promise<any>;
-
-        // If the default /lab workspace is being cloned, copy it out of local
-        // storage instead of making a round trip to the server because it
-        // does not exist on the server.
-        if (source === clone && source === '') {
-          const prefix = `${source}:${info.namespace}:`;
-          const mask = (key: string) => key.replace(prefix, '');
-          const contents = StateDB.toJSON(prefix, mask);
-
-          resolved = true;
-          transform.resolve({ type: 'overwrite', contents });
-          promise = Promise.resolve();
+        const clone =
+          typeof query['clone'] === 'string'
+            ? query['clone'] === ''
+              ? defaultWorkspace
+              : URLExt.join(
+                  PageConfig.getOption('baseUrl'),
+                  PageConfig.getOption('workspacesUrl'),
+                  query['clone']
+                )
+            : null;
+        const source = clone || workspace;
+
+        try {
+          const saved = await workspaces.fetch(source);
+
+          // If this command is called after a reset, the state database
+          // will already be resolved.
+          if (!resolved) {
+            resolved = true;
+            transform.resolve({ type: 'overwrite', contents: saved.data });
+          }
+        } catch (error) {
+          console.warn(`Fetching workspace (${workspace}) failed:`, error);
+
+          // If the workspace does not exist, cancel the data transformation
+          // and save a workspace with the current user state data.
+          if (!resolved) {
+            resolved = true;
+            transform.resolve({ type: 'cancel', contents: null });
+          }
         }
 
-        // If there is no promise, fetch the source and overwrite the database.
-        promise =
-          promise ||
-          workspaces
-            .fetch(source)
-            .then(saved => {
-              // If this command is called after a reset, the state database
-              // will already be resolved.
-              if (!resolved) {
-                resolved = true;
-                transform.resolve({ type: 'overwrite', contents: saved.data });
-              }
-            })
-            .catch(reason => {
-              console.warn(`Fetching workspace (${workspace}) failed:`, reason);
-
-              // If the workspace does not exist, cancel the data transformation
-              // and save a workspace with the current user state data.
-              if (!resolved) {
-                resolved = true;
-                transform.resolve({ type: 'cancel', contents: null });
-              }
-            })
-            .then(() => {
-              // Any time the local state database changes, save the workspace.
-              if (workspace) {
-                state.changed.connect(listener, state);
-              }
-            });
-
-        return promise
-          .catch(reason => {
-            console.warn(`${CommandIDs.loadState} failed:`, reason);
-          })
-          .then(() => {
-            const immediate = true;
-
-            if (source === clone) {
-              // Maintain the query string parameters but remove `clone`.
-              delete query['clone'];
-
-              const url = path + URLExt.objectToQueryString(query) + hash;
-              const cloned = commands
-                .execute(CommandIDs.saveState, { immediate })
-                .then(() => router.stop);
-
-              // After the state has been cloned, navigate to the URL.
-              cloned.then(() => {
-                router.navigate(url, { silent: true });
-              });
-
-              return cloned;
-            }
-
-            // After the state database has finished loading, save it.
-            return commands.execute(CommandIDs.saveState, { immediate });
+        // Any time the local state database changes, save the workspace.
+        if (workspace) {
+          state.changed.connect(listener, state);
+        }
+
+        const immediate = true;
+
+        if (source === clone) {
+          // Maintain the query string parameters but remove `clone`.
+          delete query['clone'];
+
+          const url = path + URLExt.objectToQueryString(query) + hash;
+          const cloned = commands
+            .execute(CommandIDs.saveState, { immediate })
+            .then(() => router.stop);
+
+          // After the state has been cloned, navigate to the URL.
+          cloned.then(() => {
+            console.log(`HERE: ${url}`);
+            router.navigate(url, { silent: true });
           });
+
+          return cloned;
+        }
+
+        // After the state database has finished loading, save it.
+        return commands.execute(CommandIDs.saveState, { immediate });
       }
     });
-    // Both the load state and clone state patterns should trigger the load
-    // state command if the URL matches one of them, but cloning a workspace
-    // outranks loading it because it is an explicit user action.
-    router.register({
-      command: CommandIDs.loadState,
-      pattern: Patterns.cloneState,
-      rank: 20 // Set loading rank at a higher priority than the default 100.
-    });
+
     router.register({
       command: CommandIDs.loadState,
-      pattern: Patterns.loadState,
-      rank: 30 // Set loading rank at a higher priority than the default 100.
+      pattern: /.?/,
+      rank: 20 // Very high priority: 20/100.
     });
 
     commands.addCommand(CommandIDs.reset, {
       label: 'Reset Application State',
-      execute: () => {
-        commands
-          .execute(CommandIDs.recoverState)
-          .then(() => {
-            router.reload();
-          })
-          .catch(() => {
-            router.reload();
-          });
+      execute: async () => {
+        const global = true;
+
+        try {
+          await commands.execute(CommandIDs.recoverState, { global });
+        } catch (error) {
+          /* Ignore failures and redirect. */
+        }
+        router.reload();
       }
     });
 
@@ -558,23 +552,21 @@ const state: JupyterLabPlugin<IStateDB> = {
         return cleared;
       }
     });
+
     router.register({
       command: CommandIDs.resetOnLoad,
       pattern: Patterns.resetOnLoad,
       rank: 10 // Set reset rank at a higher priority than the default 100.
     });
 
-    const fallthrough = () => {
-      // If the state database is still unresolved after the first URL has been
-      // routed, leave it intact.
-      if (!resolved) {
-        resolved = true;
-        transform.resolve({ type: 'cancel', contents: null });
-      }
-      router.routed.disconnect(fallthrough, state);
-    };
+    // Clean up state database when the window unloads.
+    window.addEventListener('beforeunload', () => {
+      const silent = true;
 
-    router.routed.connect(fallthrough, state);
+      state.clear(silent).catch(() => {
+        /* no-op */
+      });
+    });
 
     return state;
   }
@@ -598,15 +590,6 @@ export default plugins;
  * The namespace for module private data.
  */
 namespace Private {
-  /**
-   * Returns the workspace name from the URL, if it exists.
-   */
-  export function getWorkspace(router: IRouter): string {
-    const match = router.current.path.match(Patterns.loadState);
-
-    return (match && decodeURIComponent(match[1])) || '';
-  }
-
   /**
    * Create a splash element.
    */
@@ -688,7 +671,7 @@ namespace Private {
   /**
    * Allows the user to clear state if splash screen takes too long.
    */
-  export function redirect(router: IRouter, warn = false): Promise<void> {
+  export async function redirect(router: IRouter, warn = false): Promise<void> {
     const form = createRedirectForm(warn);
     const dialog = new Dialog({
       title: 'Please use a different workspace.',
@@ -697,25 +680,23 @@ namespace Private {
       buttons: [Dialog.okButton({ label: 'Switch Workspace' })]
     });
 
-    return dialog.launch().then(result => {
-      dialog.dispose();
+    const result = await dialog.launch();
 
-      if (result.value) {
-        const url = `workspaces/${result.value}`;
+    dialog.dispose();
+    if (!result.value) {
+      return redirect(router, true);
+    }
 
-        // Navigate to a new workspace URL and abandon this session altogether.
-        router.navigate(url, { hard: true, silent: true });
+    // Navigate to a new workspace URL and abandon this session altogether.
+    const workspaces = PageConfig.getOption('workspacesUrl');
+    const url = URLExt.join(workspaces, result.value);
 
-        // This promise will never resolve because the application navigates
-        // away to a new location. It only exists to satisfy the return type
-        // of the `redirect` function.
-        return new Promise<void>(() => {
-          /* no-op */
-        });
-      }
+    router.navigate(url, { hard: true, silent: true });
 
-      return redirect(router, true);
-    });
+    // This promise will never resolve because the application navigates
+    // away to a new location. It only exists to satisfy the return type
+    // of the `redirect` function.
+    return new Promise<void>(() => undefined);
   }
 
   /**

+ 7 - 7
packages/coreutils/src/pageconfig.ts

@@ -5,6 +5,8 @@ import { JSONExt } from '@phosphor/coreutils';
 
 import minimist from 'minimist';
 
+import { PathExt } from './path';
+
 import { URLExt } from './url';
 
 /**
@@ -130,16 +132,14 @@ export namespace PageConfig {
    */
   export function getTreeUrl(options: ITreeOptions = {}): string {
     const base = getBaseUrl();
-    const page = getOption('pageUrl');
+    const tree = getOption('treeUrl');
+    const defaultWorkspace = getOption('defaultWorkspace');
     const workspaces = getOption('workspacesUrl');
     const workspace = getOption('workspace');
-    const includeWorkspace = !!options.workspace;
 
-    if (includeWorkspace && workspace) {
-      return URLExt.join(base, workspaces, workspace, 'tree');
-    } else {
-      return URLExt.join(base, page, 'tree');
-    }
+    return !!options.workspace && workspace && workspace !== defaultWorkspace
+      ? URLExt.join(base, workspaces, PathExt.basename(workspace), 'tree')
+      : URLExt.join(base, tree);
   }
 
   /**

+ 30 - 18
packages/coreutils/src/url.ts

@@ -33,24 +33,36 @@ export namespace URLExt {
    * @returns the joined url.
    */
   export function join(...parts: string[]): string {
-    // Adapted from url-join.
-    // Copyright (c) 2016 José F. Romaniello, MIT License.
-    // https://github.com/jfromaniello/url-join/blob/v1.1.0/lib/url-join.js
-    let str = [].slice.call(parts, 0).join('/');
-
-    // make sure protocol is followed by two slashes
-    str = str.replace(/:\//g, '://');
-
-    // remove consecutive slashes
-    str = str.replace(/([^:\s])\/+/g, '$1/');
-
-    // remove trailing slash before parameters or hash
-    str = str.replace(/\/(\?|&|#[^!])/g, '$1');
-
-    // replace ? in parameters with &
-    str = str.replace(/(\?.+)\?/g, '$1&');
-
-    return str;
+    parts = parts || [];
+
+    // Isolate the top element.
+    const top = parts[0] || '';
+
+    // Check whether protocol shorthand is being used.
+    const shorthand = top.indexOf('//') === 0;
+
+    // Parse the top element into a header collection.
+    const header = top.match(/(\w+)(:)(\/\/)?/);
+    const protocol = header && header[1];
+    const colon = protocol && header[2];
+    const slashes = colon && header[3];
+
+    // Construct the URL prefix.
+    const prefix = shorthand
+      ? '//'
+      : [protocol, colon, slashes].filter(str => str).join('');
+
+    // Construct the URL body omitting the prefix of the top value.
+    const body = [top.indexOf(prefix) === 0 ? top.replace(prefix, '') : top]
+      // Filter out top value if empty.
+      .filter(str => str)
+      // Remove leading slashes in all subsequent URL body elements.
+      .concat(parts.slice(1).map(str => str.replace(/^\//, '')))
+      .join('/')
+      // Replace multiple slashes with one.
+      .replace(/\/+/g, '/');
+
+    return prefix + body;
   }
 
   /**

+ 1 - 1
packages/filebrowser-extension/src/index.ts

@@ -458,7 +458,7 @@ function addCommands(
 
       Clipboard.copyToSystem(item.path);
     },
-    isVisible: () => toArray(browser.selectedItems()).length === 1,
+    isVisible: () => browser.selectedItems().next !== undefined,
     iconClass: 'jp-MaterialIcon jp-FileIcon',
     label: 'Copy Path'
   });

+ 1 - 0
packages/services/package.json

@@ -57,6 +57,7 @@
     "@types/chai": "~4.0.10",
     "@types/mocha": "~2.2.44",
     "@types/node-fetch": "~1.6.7",
+    "@types/text-encoding": "0.0.33",
     "@types/ws": "~0.0.39",
     "chai": "~4.1.2",
     "istanbul": "~0.3.22",

+ 2 - 2
packages/services/test/src/workspace/manager.spec.ts

@@ -50,8 +50,8 @@ describe('workspace', () => {
     });
 
     describe('#list()', async () => {
-      it('should fetch a list of workspaces', async () => {
-        const ids = ['foo', 'bar', 'baz'];
+      it('should fetch a workspace list supporting arbitrary IDs', async () => {
+        const ids = ['foo', 'bar', 'baz', 'f/o/o', 'b/a/r', 'b/a/z'];
 
         ids.forEach(async id => {
           await manager.save(id, { data: {}, metadata: { id } });

+ 1 - 1
setup.py

@@ -131,7 +131,7 @@ setup_args = dict(
 
 setup_args['install_requires'] = [
     'notebook>=4.3.1',
-    'jupyterlab_launcher>=0.11.2,<0.12.0',
+    'jupyterlab_launcher>=0.12.0,<0.13.0',
     'ipython_genutils',
     'futures;python_version<"3.0"',
     'subprocess32;python_version<"3.0"'

+ 2 - 2
tests/test-application/src/router.spec.ts

@@ -43,9 +43,9 @@ describe('apputils', () => {
 
     describe('#current', () => {
       it('should return the current window location as an object', () => {
-        // The karma test window location is a file called `context.html`
+        // The karma test window location is the path `/context.html`
         // without any query string parameters or hash.
-        const path = 'context.html';
+        const path = '/context.html';
         const request = path;
         const search = '';
         const hash = '';

+ 1 - 1
tests/test-apputils/src/clientsession.spec.ts

@@ -372,12 +372,12 @@ describe('@jupyterlab/apputils', () => {
       it('should restart if the user accepts the dialog', async () => {
         let called = false;
 
-        await session.initialize();
         session.statusChanged.connect((sender, args) => {
           if (args === 'restarting') {
             called = true;
           }
         });
+        await session.initialize();
 
         const restart = ClientSession.restartKernel(session.kernel);
 

+ 1 - 1
tests/test-apputils/src/instancetracker.spec.ts

@@ -62,7 +62,7 @@ describe('@jupyterlab/apputils', () => {
         const widget = new Widget();
         let promise = signalToPromise(tracker.widgetAdded);
         tracker.add(widget);
-        const [sender, args] = await promise;
+        const args = (await promise)[1];
         expect(args).to.equal(widget);
       });
 

+ 9 - 4
tests/test-cells/src/widget.spec.ts

@@ -7,7 +7,7 @@ import { Message, MessageLoop } from '@phosphor/messaging';
 
 import { Widget } from '@phosphor/widgets';
 
-import { IClientSession } from '@jupyterlab/apputils';
+import { ClientSession, IClientSession } from '@jupyterlab/apputils';
 
 import { CodeEditor, CodeEditorWrapper } from '@jupyterlab/codeeditor';
 
@@ -425,7 +425,7 @@ describe('cells/widget', () => {
 
       beforeEach(async () => {
         session = await createClientSession();
-        await session.initialize();
+        await (session as ClientSession).initialize();
         await session.kernel.ready;
       });
 
@@ -433,9 +433,14 @@ describe('cells/widget', () => {
         return session.shutdown();
       });
 
-      it('should fulfill a promise if there is no code to execute', () => {
+      it('should fulfill a promise if there is no code to execute', async () => {
         const widget = new CodeCell({ model, rendermime, contentFactory });
-        return CodeCell.execute(widget, session);
+        try {
+          await CodeCell.execute(widget, session);
+        } catch (error) {
+          console.log('IT BREAKS HERE');
+          throw error;
+        }
       });
 
       it('should fulfill a promise if there is code to execute', async () => {