Explorar el Código

Merge pull request #8715 from ellisonbg/doc-mode-routing

Improved URL scheme, state, interactions for single document mode
Afshin Taylor Darian hace 4 años
padre
commit
67de374d15

+ 1 - 0
jupyterlab/browser_check.py

@@ -175,6 +175,7 @@ class BrowserApp(LabApp):
         self.settings.setdefault('page_config_data', dict())
         self.settings['page_config_data']['browserTest'] = True
         self.settings['page_config_data']['buildAvailable'] = False
+        self.settings['page_config_data']['exposeAppInBrowser'] = True
         super().initialize_settings()
 
     def initialize_handlers(self):

+ 4 - 1
jupyterlab/chrome-test.js

@@ -6,7 +6,10 @@ async function main() {
   /* eslint-disable no-console */
   console.info('Starting Chrome Headless');
 
-  const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
+  const browser = await puppeteer.launch({
+    headless: true,
+    args: ['--no-sandbox']
+  });
   const page = await browser.newPage();
 
   console.info('Navigating to page:', URL);

+ 61 - 31
packages/application-extension/src/index.tsx

@@ -7,6 +7,7 @@ import {
   ILabStatus,
   ILayoutRestorer,
   IRouter,
+  ITreePathUpdater,
   ConnectionLost,
   JupyterFrontEnd,
   JupyterFrontEndPlugin,
@@ -24,7 +25,7 @@ import {
   showErrorMessage
 } from '@jupyterlab/apputils';
 
-import { PathExt, URLExt } from '@jupyterlab/coreutils';
+import { URLExt, PageConfig } from '@jupyterlab/coreutils';
 
 import {
   IPropertyInspectorProvider,
@@ -43,7 +44,7 @@ import { PromiseDelegate } from '@lumino/coreutils';
 
 import { DisposableDelegate, DisposableSet } from '@lumino/disposable';
 
-import { Widget, DockLayout } from '@lumino/widgets';
+import { Widget, DockLayout, DockPanel } from '@lumino/widgets';
 
 import * as React from 'react';
 
@@ -88,9 +89,10 @@ namespace CommandIDs {
 /**
  * The main extension.
  */
-const main: JupyterFrontEndPlugin<void> = {
+const main: JupyterFrontEndPlugin<ITreePathUpdater> = {
   id: '@jupyterlab/application-extension:main',
   requires: [IRouter, IWindowResolver],
+  provides: ITreePathUpdater,
   optional: [ICommandPalette, IConnectionLost],
   activate: (
     app: JupyterFrontEnd,
@@ -103,6 +105,22 @@ const main: JupyterFrontEndPlugin<void> = {
       throw new Error(`${main.id} must be activated in JupyterLab.`);
     }
 
+    // These two internal state variables are used to manage the two source
+    // of the tree part of the URL being updated: 1) path of the active document,
+    // 2) path of the default browser if the active main area widget isn't a document.
+    let _docTreePath = '';
+    let _defaultBrowserTreePath = '';
+
+    function updateTreePath(treePath: string) {
+      _defaultBrowserTreePath = treePath;
+      if (!_docTreePath) {
+        const path = PageConfig.getUrl({ treePath });
+        router.navigate(path, { skipRouting: true });
+        // Persist the new tree path to PageConfig as it is used elsewhere at runtime.
+        PageConfig.setOption('treePath', treePath);
+      }
+    }
+
     // 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.
@@ -127,6 +145,27 @@ const main: JupyterFrontEndPlugin<void> = {
       app.commands.notifyCommandChanged();
     });
 
+    // Watch the mode and update the page URL to /lab or /doc to reflect the
+    // change.
+    app.shell.modeChanged.connect((_, args: DockPanel.Mode) => {
+      const path = PageConfig.getUrl({ mode: args as string });
+      router.navigate(path, { skipRouting: true });
+      // Persist this mode change to PageConfig as it is used elsewhere at runtime.
+      PageConfig.setOption('mode', args as string);
+    });
+
+    // Watch the path of the current widget in the main area and update the page
+    // URL to reflect the change.
+    app.shell.currentPathChanged.connect((_, args) => {
+      const maybeTreePath = args.newValue as string;
+      const treePath = maybeTreePath || _defaultBrowserTreePath;
+      const path = PageConfig.getUrl({ treePath: treePath });
+      router.navigate(path, { skipRouting: true });
+      // Persist the new tree path to PageConfig as it is used elsewhere at runtime.
+      PageConfig.setOption('treePath', treePath);
+      _docTreePath = maybeTreePath;
+    });
+
     // If the connection to the server is lost, handle it with the
     // connection lost handler.
     connectionLost = connectionLost || ConnectionLost;
@@ -219,6 +258,7 @@ const main: JupyterFrontEndPlugin<void> = {
         return ((event as any).returnValue = message);
       }
     });
+    return updateTreePath;
   },
   autoStart: true
 };
@@ -229,13 +269,21 @@ const main: JupyterFrontEndPlugin<void> = {
 const layout: JupyterFrontEndPlugin<ILayoutRestorer> = {
   id: '@jupyterlab/application-extension:layout',
   requires: [IStateDB, ILabShell],
-  activate: (app: JupyterFrontEnd, state: IStateDB, labShell: ILabShell) => {
+  activate: (
+    app: JupyterFrontEnd,
+    state: IStateDB,
+    labShell: ILabShell,
+    info: JupyterLab.IInfo
+  ) => {
     const first = app.started;
     const registry = app.commands;
     const restorer = new LayoutRestorer({ connector: state, first, registry });
 
     void restorer.fetch().then(saved => {
-      labShell.restoreLayout(saved);
+      labShell.restoreLayout(
+        PageConfig.getOption('mode') as DockPanel.Mode,
+        saved
+      );
       labShell.layoutModified.connect(() => {
         void restorer.save(labShell.saveLayout());
       });
@@ -289,13 +337,13 @@ const tree: JupyterFrontEndPlugin<JupyterFrontEnd.ITreeResolver> = {
     resolver: IWindowResolver
   ): JupyterFrontEnd.ITreeResolver => {
     const { commands } = app;
-    const treePattern = new RegExp(`^${paths.urls.tree}([^?]+)`);
-    const workspacePattern = new RegExp(
-      `^${paths.urls.workspaces}/[^?/]+/tree/([^?]+)`
-    );
     const set = new DisposableSet();
     const delegate = new PromiseDelegate<JupyterFrontEnd.ITreeResolver.Paths>();
 
+    const treePattern = new RegExp(
+      '/(lab|doc)(/workspaces/[a-zA-Z0-9-_]+)?(/tree/.*)?'
+    );
+
     set.add(
       commands.addCommand(CommandIDs.tree, {
         execute: async (args: IRouter.ILocation) => {
@@ -303,41 +351,22 @@ const tree: JupyterFrontEndPlugin<JupyterFrontEnd.ITreeResolver> = {
             return;
           }
 
-          const treeMatch = args.path.match(treePattern);
-          const workspaceMatch = args.path.match(workspacePattern);
-          const match = treeMatch || workspaceMatch;
-          const file = match ? decodeURI(match[1]) : '';
-          const workspace = PathExt.basename(resolver.name);
           const query = URLExt.queryStringToObject(args.search ?? '');
           const browser = query['file-browser-path'] || '';
 
           // Remove the file browser path from the query string.
           delete query['file-browser-path'];
 
-          // Remove the tree portion of the URL.
-          const url =
-            (workspaceMatch
-              ? URLExt.join(paths.urls.workspaces, workspace)
-              : paths.urls.app) +
-            URLExt.objectToQueryString(query) +
-            args.hash;
-
-          // Route to the cleaned URL.
-          router.navigate(url);
-
           // Clean up artifacts immediately upon routing.
           set.dispose();
 
-          delegate.resolve({ browser, file });
+          delegate.resolve({ browser, file: PageConfig.getOption('treePath') });
         }
       })
     );
     set.add(
       router.register({ command: CommandIDs.tree, pattern: treePattern })
     );
-    set.add(
-      router.register({ command: CommandIDs.tree, pattern: workspacePattern })
-    );
 
     // If a route is handled by the router without the tree command being
     // invoked, resolve to `null` and clean up artifacts.
@@ -433,7 +462,8 @@ const sidebar: JupyterFrontEndPlugin<void> = {
   activate: (
     app: JupyterFrontEnd,
     settingRegistry: ISettingRegistry,
-    labShell: ILabShell
+    labShell: ILabShell,
+    info: JupyterLab.IInfo
   ) => {
     type overrideMap = { [id: string]: 'left' | 'right' };
     let overrides: overrideMap = {};
@@ -567,7 +597,7 @@ function addCommands(app: JupyterLab, palette: ICommandPalette | null): void {
   // Find the tab area for a widget within the main dock area.
   const tabAreaFor = (widget: Widget): DockLayout.ITabAreaConfig | null => {
     const { mainArea } = shell.saveLayout();
-    if (!mainArea || mainArea.mode !== 'multiple-document') {
+    if (!mainArea || PageConfig.getOption('mode') !== 'multiple-document') {
       return null;
     }
     const area = mainArea.dock?.main;

+ 17 - 2
packages/application/src/frontend.ts

@@ -303,6 +303,22 @@ export namespace JupyterFrontEnd {
     widgets(area?: string): IIterator<Widget>;
   }
 
+  /**
+   * Is JupyterLab in document mode?
+   *
+   * @param path - Full URL of JupyterLab
+   * @param paths - The current IPaths object hydrated from PageConfig.
+   */
+  export function inDocMode(path: string, paths: IPaths) {
+    const docPattern = new RegExp(`^${paths.urls.doc}`);
+    const match = path.match(docPattern);
+    if (match) {
+      return true;
+    } else {
+      return false;
+    }
+  }
+
   /**
    * The application paths dictionary token.
    */
@@ -319,11 +335,10 @@ export namespace JupyterFrontEnd {
       readonly base: string;
       readonly notFound?: string;
       readonly app: string;
+      readonly doc: string;
       readonly static: string;
       readonly settings: string;
       readonly themes: string;
-      readonly tree: string;
-      readonly workspaces: string;
       readonly hubPrefix?: string;
       readonly hubHost?: string;
       readonly hubUser?: string;

+ 2 - 0
packages/application/src/index.ts

@@ -17,4 +17,6 @@ export { ILabShell, LabShell } from './shell';
 
 export { ILabStatus } from './status';
 
+export { ITreePathUpdater } from './treepathupdater';
+
 export * from './tokens';

+ 1 - 7
packages/application/src/lab.ts

@@ -94,11 +94,6 @@ export class JupyterLab extends JupyterFrontEnd<ILabShell> {
           this.shell.collapseRight();
           return;
         }
-        if (this.shell.mode === 'single-document') {
-          this.shell.collapseLeft();
-        } else {
-          this.shell.expandLeft();
-        }
       }, this);
       Private.setFormat(this);
     });
@@ -269,11 +264,10 @@ export namespace JupyterLab {
       base: PageConfig.getOption('baseUrl'),
       notFound: PageConfig.getOption('notFoundUrl'),
       app: PageConfig.getOption('appUrl'),
+      doc: PageConfig.getOption('docUrl'),
       static: PageConfig.getOption('staticUrl'),
       settings: PageConfig.getOption('settingsUrl'),
       themes: PageConfig.getOption('themesUrl'),
-      tree: PageConfig.getOption('treeUrl'),
-      workspaces: PageConfig.getOption('workspacesUrl'),
       hubHost: PageConfig.getOption('hubHost') || undefined,
       hubPrefix: PageConfig.getOption('hubPrefix') || undefined,
       hubUser: PageConfig.getOption('hubUser') || undefined,

+ 1 - 5
packages/application/src/layoutrestorer.ts

@@ -582,7 +582,6 @@ namespace Private {
       dock: (area && area.dock && serializeArea(area.dock.main)) || null
     };
     if (area) {
-      dehydrated.mode = area.mode;
       if (area.currentWidget) {
         const current = Private.nameProperty.get(area.currentWidget);
         if (current) {
@@ -675,13 +674,10 @@ namespace Private {
 
     const name = (area as any).current || null;
     const dock = (area as any).dock || null;
-    const mode = (area as any).mode || null;
 
     return {
       currentWidget: (name && names.has(name) && names.get(name)) || null,
-      dock: dock ? { main: deserializeArea(dock, names) } : null,
-      mode:
-        mode === 'multiple-document' || mode === 'single-document' ? mode : null
+      dock: dock ? { main: deserializeArea(dock, names) } : null
     };
   }
 }

+ 7 - 5
packages/application/src/router.ts

@@ -88,11 +88,13 @@ export class Router implements IRouter {
       return this.reload();
     }
 
-    // Because a `route()` call may still be in the stack after having received
-    // a `stop` token, wait for the next stack frame before calling `route()`.
-    requestAnimationFrame(() => {
-      void this.route();
-    });
+    if (!options.skipRouting) {
+      // Because a `route()` call may still be in the stack after having received
+      // a `stop` token, wait for the next stack frame before calling `route()`.
+      requestAnimationFrame(() => {
+        void this.route();
+      });
+    }
   }
 
   /**

+ 82 - 13
packages/application/src/shell.ts

@@ -3,7 +3,7 @@
 
 import { MainAreaWidget } from '@jupyterlab/apputils';
 
-import { DocumentRegistry } from '@jupyterlab/docregistry';
+import { DocumentRegistry, DocumentWidget } from '@jupyterlab/docregistry';
 
 import { classes, DockPanelSvg, LabIcon } from '@jupyterlab/ui-components';
 
@@ -98,6 +98,21 @@ export namespace ILabShell {
    */
   export type IChangedArgs = FocusTracker.IChangedArgs<Widget>;
 
+  /**
+   * The args for the current path change signal.
+   */
+  export interface ICurrentPathChangedArgs {
+    /**
+     * The new value of the tree path, not including '/tree'.
+     */
+    oldValue: string;
+
+    /**
+     * The old value of the tree path, not including '/tree'.
+     */
+    newValue: string;
+  }
+
   /**
    * A description of the application's user interface layout.
    */
@@ -142,11 +157,6 @@ export namespace ILabShell {
      * The contents of the main application dock panel.
      */
     readonly dock: DockLayout.ILayoutConfig | null;
-
-    /**
-     * The document mode (i.e., multiple/single) of the main dock panel.
-     */
-    readonly mode: DockPanel.Mode | null;
   }
 
   /**
@@ -310,6 +320,11 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
         );
         this._updateTitlePanelTitle();
       }
+
+      if (newValue && newValue instanceof DocumentWidget) {
+        newValue.context.pathChanged.connect(this._updateCurrentPath, this);
+      }
+      this._updateCurrentPath();
     });
   }
 
@@ -334,6 +349,22 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     return this._currentChanged;
   }
 
+  /**
+   * A signal emitted when the shell/dock panel change modes (single/mutiple document).
+   */
+  get modeChanged(): ISignal<this, DockPanel.Mode> {
+    return this._modeChanged;
+  }
+
+  /**
+   * A signal emitted when the path of the current document changes.
+   *
+   * This also fires when the current document itself changes.
+   */
+  get currentPathChanged(): ISignal<this, ILabShell.ICurrentPathChangedArgs> {
+    return this._currentPathChanged;
+  }
+
   /**
    * The current widget in the shell's main area.
    */
@@ -408,6 +439,7 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
       this._titleHandler.panel.show();
       this._updateTitlePanelTitle();
 
+      this._modeChanged.emit(mode);
       return;
     }
 
@@ -450,6 +482,8 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     this.node.dataset.shellMode = mode;
     // Hide the title panel
     this._titleHandler.panel.hide();
+    // Emit the mode changed signal
+    this._modeChanged.emit(mode);
   }
 
   /**
@@ -685,12 +719,11 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
   /**
    * Restore the layout state for the application shell.
    */
-  restoreLayout(layout: ILabShell.ILayout): void {
+  restoreLayout(mode: DockPanel.Mode, layout: ILabShell.ILayout): void {
     const { mainArea, leftArea, rightArea } = layout;
-
     // Rehydrate the main area.
     if (mainArea) {
-      const { currentWidget, dock, mode } = mainArea;
+      const { currentWidget, dock } = mainArea;
 
       if (dock) {
         this._dockPanel.restoreLayout(dock);
@@ -701,16 +734,29 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
       if (currentWidget) {
         this.activateById(currentWidget.id);
       }
+    } else {
+      // This is needed when loading in an empty workspace in single doc mode
+      if (mode) {
+        this.mode = mode;
+      }
     }
 
     // Rehydrate the left area.
     if (leftArea) {
       this._leftHandler.rehydrate(leftArea);
+    } else {
+      if (mode === 'single-document') {
+        this.collapseLeft();
+      }
     }
 
     // Rehydrate the right area.
     if (rightArea) {
       this._rightHandler.rehydrate(rightArea);
+    } else {
+      if (mode === 'single-document') {
+        this.collapseRight();
+      }
     }
 
     if (!this._isRestored) {
@@ -728,18 +774,18 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
   saveLayout(): ILabShell.ILayout {
     // If the application is in single document mode, use the cached layout if
     // available. Otherwise, default to querying the dock panel for layout.
-    return {
+    const layout = {
       mainArea: {
         currentWidget: this._tracker.currentWidget,
         dock:
           this.mode === 'single-document'
             ? this._cachedLayout || this._dockPanel.saveLayout()
-            : this._dockPanel.saveLayout(),
-        mode: this._dockPanel.mode
+            : this._dockPanel.saveLayout()
       },
       leftArea: this._leftHandler.dehydrate(),
       rightArea: this._rightHandler.dehydrate()
     };
+    return layout;
   }
 
   /**
@@ -782,6 +828,22 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     }
   }
 
+  /**
+   * The path of the current widget changed, fire the _currentPathChanged signal.
+   */
+  private _updateCurrentPath() {
+    let current = this.currentWidget;
+    let newValue = '';
+    if (current && current instanceof DocumentWidget) {
+      newValue = current.context.path;
+    }
+    this._currentPathChanged.emit({
+      newValue: newValue,
+      oldValue: this._currentPath
+    });
+    this._currentPath = newValue;
+  }
+
   /**
    * Add a widget to the left content area.
    *
@@ -1087,6 +1149,12 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
   private _activeChanged = new Signal<this, ILabShell.IChangedArgs>(this);
   private _cachedLayout: DockLayout.ILayoutConfig | null = null;
   private _currentChanged = new Signal<this, ILabShell.IChangedArgs>(this);
+  private _currentPath = '';
+  private _currentPathChanged = new Signal<
+    this,
+    ILabShell.ICurrentPathChangedArgs
+  >(this);
+  private _modeChanged = new Signal<this, DockPanel.Mode>(this);
   private _dockPanel: DockPanel;
   private _isRestored = false;
   private _layoutModified = new Signal<this, void>(this);
@@ -1305,7 +1373,8 @@ namespace Private {
     rehydrate(data: ILabShell.ISideArea): void {
       if (data.currentWidget) {
         this.activate(data.currentWidget.id);
-      } else if (data.collapsed) {
+      }
+      if (data.collapsed) {
         this.collapse();
       }
     }

+ 6 - 0
packages/application/src/tokens.ts

@@ -144,6 +144,12 @@ export namespace IRouter {
      * history API change.
      */
     hard?: boolean;
+
+    /**
+     * Should the routing stage be skipped when navigating? This will simply rewrite the URL
+     * and push the new state to the history API, no routing commands will be triggered.
+     */
+    skipRouting?: boolean;
   }
 
   /**

+ 20 - 0
packages/application/src/treepathupdater.ts

@@ -0,0 +1,20 @@
+/* -----------------------------------------------------------------------------
+| Copyright (c) Jupyter Development Team.
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+import { Token } from '@lumino/coreutils';
+
+/**
+ * The tree path updater token.
+ */
+export const ITreePathUpdater = new Token<ITreePathUpdater>(
+  '@jupyterlab/application:ITreePathUpdater'
+);
+
+/**
+ * A function to call to update the tree path.
+ */
+export interface ITreePathUpdater {
+  (treePath: string): void;
+}

+ 5 - 7
packages/application/test/layoutrestorer.spec.ts

@@ -13,7 +13,7 @@ import { CommandRegistry } from '@lumino/commands';
 
 import { PromiseDelegate } from '@lumino/coreutils';
 
-import { DockPanel, Widget } from '@lumino/widgets';
+import { Widget } from '@lumino/widgets';
 
 describe('apputils', () => {
   describe('LayoutRestorer', () => {
@@ -60,9 +60,8 @@ describe('apputils', () => {
           registry: new CommandRegistry()
         });
         const currentWidget = new Widget();
-        const mode: DockPanel.Mode = 'single-document';
         const dehydrated: ILabShell.ILayout = {
-          mainArea: { currentWidget, dock: null, mode },
+          mainArea: { currentWidget, dock: null },
           leftArea: { collapsed: true, currentWidget: null, widgets: null },
           rightArea: { collapsed: true, currentWidget: null, widgets: null }
         };
@@ -72,7 +71,6 @@ describe('apputils', () => {
         await restorer.save(dehydrated);
         const layout = await restorer.fetch();
         expect(layout.mainArea?.currentWidget).toBe(currentWidget);
-        expect(layout.mainArea?.mode).toBe(mode);
       });
     });
 
@@ -98,7 +96,7 @@ describe('apputils', () => {
         // The `fresh` attribute is only here to check against the return value.
         const dehydrated: ILabShell.ILayout = {
           fresh: false,
-          mainArea: { currentWidget: null, dock: null, mode: null },
+          mainArea: { currentWidget: null, dock: null },
           leftArea: {
             currentWidget,
             collapsed: true,
@@ -155,7 +153,7 @@ describe('apputils', () => {
           registry: new CommandRegistry()
         });
         const dehydrated: ILabShell.ILayout = {
-          mainArea: { currentWidget: null, dock: null, mode: null },
+          mainArea: { currentWidget: null, dock: null },
           leftArea: { currentWidget: null, collapsed: true, widgets: null },
           rightArea: { collapsed: true, currentWidget: null, widgets: null }
         };
@@ -177,7 +175,7 @@ describe('apputils', () => {
         // The `fresh` attribute is only here to check against the return value.
         const dehydrated: ILabShell.ILayout = {
           fresh: false,
-          mainArea: { currentWidget: null, dock: null, mode: null },
+          mainArea: { currentWidget: null, dock: null },
           leftArea: {
             currentWidget,
             collapsed: true,

+ 7 - 5
packages/application/test/shell.spec.ts

@@ -9,7 +9,7 @@ import { toArray } from '@lumino/algorithm';
 
 import { Message } from '@lumino/messaging';
 
-import { Widget } from '@lumino/widgets';
+import { Widget, DockPanel } from '@lumino/widgets';
 
 import { simulate } from 'simulate-event';
 
@@ -121,7 +121,8 @@ describe('LabShell', () => {
   describe('#restored', () => {
     it('should resolve when the app is restored for the first time', () => {
       const state = shell.saveLayout();
-      shell.restoreLayout(state);
+      const mode: DockPanel.Mode = 'multiple-document';
+      shell.restoreLayout(mode, state);
       return shell.restored;
     });
   });
@@ -393,7 +394,7 @@ describe('LabShell', () => {
       shell.add(foo, 'main');
       const state = shell.saveLayout();
       shell.activateById('foo');
-      expect(state.mainArea?.mode).toBe('multiple-document');
+      expect(shell.mode).toBe('multiple-document');
       expect(state.mainArea?.currentWidget).toBe(null);
     });
   });
@@ -401,9 +402,10 @@ describe('LabShell', () => {
   describe('#restoreLayout', () => {
     it('should restore the layout of the shell', () => {
       const state = shell.saveLayout();
+      const mode: DockPanel.Mode = 'multiple-document';
       shell.mode = 'single-document';
-      shell.restoreLayout(state);
-      expect(state.mainArea?.mode).toBe('multiple-document');
+      shell.restoreLayout(mode, state);
+      expect(shell.mode).toBe('multiple-document');
     });
   });
 

+ 18 - 40
packages/apputils-extension/src/index.ts

@@ -21,7 +21,7 @@ import {
   sessionContextDialogs
 } from '@jupyterlab/apputils';
 
-import { URLExt } from '@jupyterlab/coreutils';
+import { URLExt, PageConfig } from '@jupyterlab/coreutils';
 
 import { IStateDB, StateDB } from '@jupyterlab/statedb';
 
@@ -96,20 +96,21 @@ const resolver: JupyterFrontEndPlugin<IWindowResolver> = {
   provides: IWindowResolver,
   requires: [JupyterFrontEnd.IPaths, IRouter],
   activate: async (
-    _: JupyterFrontEnd,
+    app: JupyterFrontEnd,
     paths: JupyterFrontEnd.IPaths,
     router: IRouter
   ) => {
-    const { hash, path, search } = router.current;
+    const { hash, search } = router.current;
     const query = URLExt.queryStringToObject(search || '');
     const solver = new WindowResolver();
-    const { urls } = paths;
-    const match = path.match(new RegExp(`^${urls.workspaces}\/([^?\/]+)`));
-    const workspace = (match && decodeURIComponent(match[1])) || '';
-    const candidate = Private.candidate(paths, workspace);
-    const rest = workspace
-      ? path.replace(new RegExp(`^${urls.workspaces}\/${workspace}`), '')
-      : path.replace(new RegExp(`^${urls.app}\/?`), '');
+    const workspace = PageConfig.getOption('workspace');
+    const treePath = PageConfig.getOption('treePath');
+    const mode =
+      PageConfig.getOption('mode') === 'multiple-document' ? 'lab' : 'doc';
+    // This is used as a key in local storage to refer to workspaces, either the name
+    // of the workspace or the string PageConfig.defaultWorkspace. Both lab and doc modes share the same workspace.
+    const candidate = workspace ? workspace : PageConfig.defaultWorkspace;
+    const rest = treePath ? URLExt.join('tree', treePath) : '';
     try {
       await solver.resolve(candidate);
       return solver;
@@ -118,14 +119,15 @@ const resolver: JupyterFrontEndPlugin<IWindowResolver> = {
       // that never resolves to prevent the application from loading plugins
       // that rely on `IWindowResolver`.
       return new Promise<IWindowResolver>(() => {
-        const { base, workspaces } = paths.urls;
+        const { base } = paths.urls;
         const pool =
           'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
         const random = pool[Math.floor(Math.random() * pool.length)];
-        const path = URLExt.join(base, workspaces, `auto-${random}`, rest);
+        let path = URLExt.join(base, mode, 'workspaces', `auto-${random}`);
+        path = rest ? URLExt.join(path, URLExt.encodeParts(rest)) : path;
 
-        // Clone the originally requested workspace after redirecting.
-        query['clone'] = workspace;
+        // Reset the workspace on load.
+        query['reset'] = '';
 
         const url = path + URLExt.objectToQueryString(query) + (hash || '');
         router.navigate(url, { hard: true });
@@ -324,7 +326,7 @@ const state: JupyterFrontEndPlugin<IStateDB> = {
           typeof query['clone'] === 'string'
             ? query['clone'] === ''
               ? URLExt.join(urls.base, urls.app)
-              : URLExt.join(urls.base, urls.workspaces, query['clone'])
+              : URLExt.join(urls.base, urls.app, 'workspaces', query['clone'])
             : null;
         const source = clone || workspace || null;
 
@@ -414,10 +416,7 @@ const state: JupyterFrontEndPlugin<IStateDB> = {
         delete query['reset'];
 
         const url = path + URLExt.objectToQueryString(query) + hash;
-        const cleared = db
-          .clear()
-          .then(() => save.invoke())
-          .then(() => router.stop);
+        const cleared = db.clear().then(() => save.invoke());
 
         // After the state has been reset, navigate to the URL.
         if (clone) {
@@ -507,24 +506,3 @@ const plugins: JupyterFrontEndPlugin<any>[] = [
   workspacesPlugin
 ];
 export default plugins;
-
-/**
- * The namespace for module private data.
- */
-namespace Private {
-  /**
-   * Generate a workspace name candidate.
-   *
-   * @param workspace - A potential workspace name parsed from the URL.
-   *
-   * @returns A workspace name candidate.
-   */
-  export function candidate(
-    { urls }: JupyterFrontEnd.IPaths,
-    workspace = ''
-  ): string {
-    return workspace
-      ? URLExt.join(urls.workspaces, workspace)
-      : URLExt.join(urls.app);
-  }
-}

+ 18 - 1
packages/apputils/src/toolbar.tsx

@@ -457,6 +457,14 @@ export namespace ToolbarButtonComponent {
     tooltip?: string;
     onClick?: () => void;
     enabled?: boolean;
+
+    /**
+     * Trigger the button on the actual onClick event rather than onMouseDown.
+     *
+     * See note in ToolbarButtonComponent below as to why the default is to
+     * trigger on onMouseDown.
+     */
+    actualOnClick?: boolean;
   }
 }
 
@@ -485,6 +493,12 @@ export function ToolbarButtonComponent(props: ToolbarButtonComponent.IProps) {
     }
   };
 
+  const handleClick = (event: React.MouseEvent) => {
+    if (event.button === 0) {
+      props.onClick?.();
+    }
+  };
+
   return (
     <Button
       className={
@@ -493,7 +507,10 @@ export function ToolbarButtonComponent(props: ToolbarButtonComponent.IProps) {
           : 'jp-ToolbarButtonComponent'
       }
       disabled={props.enabled === false}
-      onMouseDown={handleMouseDown}
+      onClick={props.actualOnClick ?? false ? handleClick : undefined}
+      onMouseDown={
+        !(props.actualOnClick ?? false) ? handleMouseDown : undefined
+      }
       onKeyDown={handleKeyDown}
       title={props.tooltip || props.iconLabel}
       minimal

+ 57 - 0
packages/coreutils/src/pageconfig.ts

@@ -132,6 +132,63 @@ export namespace PageConfig {
     return URLExt.normalize(URLExt.join(getShareUrl(), getOption('treeUrl')));
   }
 
+  /**
+   * Create a new URL given an optional mode and tree path.
+   *
+   * This is used to create URLS when the mode or tree path change as the user
+   * changes mode or the current document in the main area. If fields in
+   * options are omitted, the value in PageConfig will be used.
+   *
+   * @param options - IGetUrlOptions for the new path.
+   */
+  export function getUrl(options: IGetUrlOptions): string {
+    let path = getOption('baseUrl') || '/';
+    const mode = options.mode ?? getOption('mode');
+    const workspace = options.workspace ?? getOption('workspace');
+    const labOrDoc = mode === 'multiple-document' ? 'lab' : 'doc';
+    path = URLExt.join(path, labOrDoc);
+    if (workspace !== defaultWorkspace) {
+      path = URLExt.join(
+        path,
+        'workspaces',
+        encodeURIComponent(getOption('workspace'))
+      );
+    }
+    const treePath = options.treePath ?? getOption('treePath');
+    if (treePath) {
+      path = URLExt.join(path, 'tree', URLExt.encodeParts(treePath));
+    }
+    return path;
+  }
+
+  export const defaultWorkspace: string = 'default';
+
+  /**
+   * Options for getUrl
+   */
+
+  export interface IGetUrlOptions {
+    /**
+     * The optional mode as a string 'single-document' or 'multiple-document'. If
+     * the mode argument is missing, it will be provided from the PageConfig.
+     */
+    mode?: string;
+
+    /**
+     * The optional workspace as a string. If this argument is missing, the value will
+     * be pulled from PageConfig. To use the default workspace (no /workspaces/<name>
+     * URL segment will be included) pass the string PageConfig.defaultWorkspace.
+     */
+    workspace?: string;
+
+    /**
+     * The optional tree path as as string. If treePath is not provided it will be
+     * provided from the PageConfig. If an empty string, the resulting path will not
+     * contain a tree portion.
+     */
+    treePath?: string;
+  }
+
   /**
    * Get the base websocket url for a Jupyter application, or an empty string.
    */

+ 7 - 3
packages/docmanager-extension/src/index.ts

@@ -104,7 +104,6 @@ const docManagerPlugin: JupyterFrontEndPlugin<IDocumentManager> = {
     mainMenu: IMainMenu | null,
     sessionDialogs: ISessionContextDialogs | null
   ): IDocumentManager => {
-    const { shell } = app;
     const manager = app.serviceManager;
     const contexts = new WeakSet<DocumentRegistry.Context>();
     const opener: DocumentManager.IWidgetOpener = {
@@ -117,9 +116,9 @@ const docManagerPlugin: JupyterFrontEndPlugin<IDocumentManager> = {
           ...widget.title.dataset
         };
         if (!widget.isAttached) {
-          shell.add(widget, 'main', options || {});
+          app.shell.add(widget, 'main', options || {});
         }
-        shell.activateById(widget.id);
+        app.shell.activateById(widget.id);
 
         // Handle dirty state for open documents.
         const context = docManager.contextForWidget(widget);
@@ -248,6 +247,11 @@ ${fileTypes}`;
     // regenerate the settings description with the available options.
     registry.changed.connect(() => settingRegistry.reload(pluginId));
 
+    docManager.mode = labShell!.mode;
+    labShell!.modeChanged.connect((_, args) => {
+      docManager.mode = args as string;
+    });
+
     return docManager;
   }
 };

+ 36 - 6
packages/docmanager/src/manager.ts

@@ -3,7 +3,7 @@
 
 import { ISessionContext, sessionContextDialogs } from '@jupyterlab/apputils';
 
-import { PathExt } from '@jupyterlab/coreutils';
+import { PathExt, PageConfig } from '@jupyterlab/coreutils';
 
 import { UUID } from '@lumino/coreutils';
 
@@ -78,6 +78,22 @@ export class DocumentManager implements IDocumentManager {
     return this._activateRequested;
   }
 
+  /**
+   * The document mode of the document manager, either 'single-document' or 'multiple-document'.
+   *
+   * This is usually synced with the `mode` attribute of the shell.
+   */
+  get mode(): string {
+    return this._mode;
+  }
+
+  /**
+   * Set the mode of the document manager, either 'single-document' or 'multiple-document'.
+   */
+  set mode(value: string) {
+    this._mode = value;
+  }
+
   /**
    * Whether to autosave documents.
    */
@@ -372,12 +388,16 @@ export class DocumentManager implements IDocumentManager {
     kernel?: Partial<Kernel.IModel>,
     options?: DocumentRegistry.IOpenOptions
   ): IDocumentWidget | undefined {
-    const widget = this.findWidget(path, widgetName);
-    if (widget) {
-      this._opener.open(widget, options || {});
-      return widget;
+    if (this.mode == 'single-document' && options?.maybeNewWorkspace) {
+      this._openInNewWorkspace(path);
+    } else {
+      const widget = this.findWidget(path, widgetName);
+      if (widget) {
+        this._opener.open(widget, options || {});
+        return widget;
+      }
+      return this.open(path, widgetName, kernel, options || {});
     }
-    return this.open(path, widgetName, kernel, options || {});
   }
 
   /**
@@ -519,6 +539,15 @@ export class DocumentManager implements IDocumentManager {
     return registry.getWidgetFactory(widgetName);
   }
 
+  private _openInNewWorkspace(path: string) {
+    const newUrl = PageConfig.getUrl({
+      mode: this.mode,
+      workspace: 'default',
+      treePath: path
+    });
+    window.open(newUrl);
+  }
+
   /**
    * Creates a new document, or loads one from disk, depending on the `which` argument.
    * If `which==='create'`, then it creates a new document. If `which==='open'`,
@@ -600,6 +629,7 @@ export class DocumentManager implements IDocumentManager {
   private _isDisposed = false;
   private _autosave = true;
   private _autosaveInterval = 120;
+  private _mode = '';
   private _when: Promise<void>;
   private _setBusy: (() => IDisposable) | undefined;
   private _dialogs: ISessionContext.IDialogs;

+ 8 - 0
packages/docmanager/src/tokens.ts

@@ -41,6 +41,14 @@ export interface IDocumentManager extends IDisposable {
    */
   readonly activateRequested: ISignal<this, string>;
 
+  /**
+   * The mode of the document manager, either 'single-document' or
+   * 'multiple-document' and usually matches that of the shell. When this
+   * is 'single-document' the document manager will open documents in a
+   * separate browser tab.
+   */
+  mode: string;
+
   /**
    * Whether to autosave documents.
    */

+ 6 - 0
packages/docregistry/src/registry.ts

@@ -1061,6 +1061,12 @@ export namespace DocumentRegistry {
      * This field may be used or ignored depending on shell implementation.
      */
     rank?: number;
+
+    /**
+     * If the document manager is in single document mode, open the document in
+     * a new browser tab and JupyterLab workspace.
+     */
+    maybeNewWorkspace?: boolean;
   }
 
   /**

+ 58 - 14
packages/filebrowser-extension/src/index.ts

@@ -4,6 +4,7 @@
 import {
   ILabShell,
   ILayoutRestorer,
+  ITreePathUpdater,
   IRouter,
   JupyterFrontEnd,
   JupyterFrontEndPlugin
@@ -140,7 +141,8 @@ const browser: JupyterFrontEndPlugin<void> = {
     IDocumentManager,
     ILabShell,
     ILayoutRestorer,
-    ISettingRegistry
+    ISettingRegistry,
+    ITreePathUpdater
   ],
   optional: [ICommandPalette, IMainMenu],
   autoStart: true
@@ -153,7 +155,7 @@ const factory: JupyterFrontEndPlugin<IFileBrowserFactory> = {
   activate: activateFactory,
   id: '@jupyterlab/filebrowser-extension:factory',
   provides: IFileBrowserFactory,
-  requires: [IDocumentManager],
+  requires: [ILabShell, IDocumentManager],
   optional: [IStateDB, IRouter, JupyterFrontEnd.ITreeResolver]
 };
 
@@ -231,6 +233,7 @@ export default plugins;
  */
 async function activateFactory(
   app: JupyterFrontEnd,
+  labShell: ILabShell,
   docManager: IDocumentManager,
   state: IStateDB | null,
   router: IRouter | null,
@@ -257,11 +260,22 @@ async function activateFactory(
     const launcher = new ToolbarButton({
       icon: addIcon,
       onClick: () => {
-        if (commands.hasCommand('launcher:create')) {
+        if (
+          labShell.mode === 'multiple-document' &&
+          commands.hasCommand('launcher:create')
+        ) {
           return Private.createLauncher(commands, widget);
+        } else {
+          const newUrl = PageConfig.getUrl({
+            mode: labShell.mode,
+            workspace: PageConfig.defaultWorkspace,
+            treePath: model.path
+          });
+          window.open(newUrl, '_blank');
         }
       },
-      tooltip: 'New Launcher'
+      tooltip: 'New Launcher',
+      actualOnClick: true
     });
     widget.toolbar.insertItem(0, 'launch', launcher);
 
@@ -291,6 +305,7 @@ function activateBrowser(
   labShell: ILabShell,
   restorer: ILayoutRestorer,
   settingRegistry: ISettingRegistry,
+  treePathUpdater: ITreePathUpdater,
   commandPalette: ICommandPalette | null,
   mainMenu: IMainMenu | null
 ): void {
@@ -335,9 +350,10 @@ function activateBrowser(
   });
   labShell.add(browser, 'left', { rank: 100 });
 
-  // If the layout is a fresh session without saved data, open file browser.
+  // If the layout is a fresh session without saved data and not in single document
+  // mode, open file browser.
   void labShell.restored.then(layout => {
-    if (layout.fresh) {
+    if (layout.fresh && labShell.mode !== 'single-document') {
       void commands.execute(CommandIDs.showBrowser, void 0);
     }
   });
@@ -398,6 +414,10 @@ function activateBrowser(
       }
     });
 
+    browser.model.pathChanged.connect((sender, args) => {
+      treePathUpdater(args.newValue);
+    });
+
     maybeCreate();
   });
 }
@@ -417,7 +437,16 @@ function activateShareFile(
         return;
       }
       const path = encodeURI(model.path);
-      Clipboard.copyToSystem(URLExt.join(PageConfig.getTreeShareUrl(), path));
+
+      Clipboard.copyToSystem(
+        URLExt.normalize(
+          PageConfig.getUrl({
+            mode: 'single-document',
+            workspace: PageConfig.defaultWorkspace,
+            treePath: path
+          })
+        )
+      );
     },
     isVisible: () =>
       !!tracker.currentWidget &&
@@ -516,9 +545,10 @@ function addCommands(
   commands.addCommand(CommandIDs.goToPath, {
     execute: async args => {
       const path = (args.path as string) || '';
+      const showBrowser = !(args?.dontShowBrowser ?? false);
       try {
         const item = await Private.navigateToPath(path, factory);
-        if (item.type !== 'directory') {
+        if (item.type !== 'directory' && showBrowser) {
           const browserForPath = Private.getBrowserForPath(path, factory);
           if (browserForPath) {
             browserForPath.clearSelectedItems();
@@ -532,15 +562,20 @@ function addCommands(
       } catch (reason) {
         console.warn(`${CommandIDs.goToPath} failed to go to: ${path}`, reason);
       }
-      return commands.execute(CommandIDs.showBrowser, { path });
+      if (showBrowser) {
+        return commands.execute(CommandIDs.showBrowser, { path });
+      }
     }
   });
 
   commands.addCommand(CommandIDs.openPath, {
     label: args => (args.path ? `Open ${args.path}` : 'Open from Path…'),
     caption: args => (args.path ? `Open ${args.path}` : 'Open from path'),
-    execute: async ({ path }: { path?: string }) => {
-      if (!path) {
+    execute: async args => {
+      let path: string | undefined;
+      if (args?.path) {
+        path = args.path as string;
+      } else {
         path =
           (
             await InputDialog.getText({
@@ -568,7 +603,10 @@ function addCommands(
         if (trailingSlash && item.type !== 'directory') {
           throw new Error(`Path ${path}/ is not a directory`);
         }
-        await commands.execute(CommandIDs.goToPath, { path });
+        await commands.execute(CommandIDs.goToPath, {
+          path,
+          dontShowBrowser: args.dontShowBrowser
+        });
         if (item.type === 'directory') {
           return;
         }
@@ -1166,10 +1204,16 @@ namespace Private {
         // Restore the model without populating it.
         await browser.model.restore(browser.id, false);
         if (paths.file) {
-          await commands.execute(CommandIDs.openPath, { path: paths.file });
+          await commands.execute(CommandIDs.openPath, {
+            path: paths.file,
+            dontShowBrowser: true
+          });
         }
         if (paths.browser) {
-          await commands.execute(CommandIDs.openPath, { path: paths.browser });
+          await commands.execute(CommandIDs.openPath, {
+            path: paths.browser,
+            dontShowBrowser: true
+          });
         }
       } else {
         await browser.model.restore(browser.id);

+ 3 - 1
packages/filebrowser/src/listing.ts

@@ -949,7 +949,9 @@ export class DirListing extends Widget {
         .catch(error => showErrorMessage('Open directory', error));
     } else {
       const path = item.path;
-      this._manager.openOrReveal(path);
+      this._manager.openOrReveal(path, 'default', undefined, {
+        maybeNewWorkspace: true
+      });
     }
   }