diff --git a/packages/application-extension/package.json b/packages/application-extension/package.json index c2d8a8c86d1b..036bb15cee89 100644 --- a/packages/application-extension/package.json +++ b/packages/application-extension/package.json @@ -40,6 +40,8 @@ "@jupyterlab/apputils": "^2.0.0-alpha.4", "@jupyterlab/coreutils": "^4.0.0-alpha.4", "@lumino/algorithm": "^1.2.1", + "@lumino/coreutils": "^1.4.0", + "@lumino/disposable": "^1.3.2", "@lumino/widgets": "^1.9.4", "react": "~16.9.0" }, diff --git a/packages/application-extension/src/index.tsx b/packages/application-extension/src/index.tsx index 8e2eec0f749e..14e5fabd7cdf 100644 --- a/packages/application-extension/src/index.tsx +++ b/packages/application-extension/src/index.tsx @@ -33,6 +33,10 @@ import { import { each, iter, toArray } from '@lumino/algorithm'; +import { PromiseDelegate } from '@lumino/coreutils'; + +import { DisposableDelegate, DisposableSet } from '@lumino/disposable'; + import { Widget, DockLayout } from '@lumino/widgets'; import * as React from 'react'; @@ -247,63 +251,87 @@ const router: JupyterFrontEndPlugin = { }; /** - * The tree route handler provider. + * The default tree route resolver plugin. */ -const tree: JupyterFrontEndPlugin = { - id: '@jupyterlab/application-extension:tree', +const tree: JupyterFrontEndPlugin = { + id: '@jupyterlab/application-extension:tree-resolver', autoStart: true, requires: [JupyterFrontEnd.IPaths, IRouter, IWindowResolver], + provides: JupyterFrontEnd.ITreeResolver, activate: ( app: JupyterFrontEnd, paths: JupyterFrontEnd.IPaths, router: IRouter, resolver: IWindowResolver - ) => { + ): JupyterFrontEnd.ITreeResolver => { const { commands } = app; const treePattern = new RegExp(`^${paths.urls.tree}([^?]+)`); const workspacePattern = new RegExp( `^${paths.urls.workspaces}/[^?/]+/tree/([^?]+)` ); - - commands.addCommand(CommandIDs.tree, { - execute: async (args: IRouter.ILocation) => { - const treeMatch = args.path.match(treePattern); - const workspaceMatch = args.path.match(workspacePattern); - const match = treeMatch || workspaceMatch; - const path = match ? decodeURI(match[1]) : undefined; - const workspace = PathExt.basename(resolver.name); - const query = URLExt.queryStringToObject(args.search ?? ''); - const fileBrowserPath = 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; - - router.navigate(url); - - try { - await commands.execute('filebrowser:open-path', { path }); - - if (fileBrowserPath) { - await commands.execute('filebrowser:open-path', { - path: fileBrowserPath - }); + const set = new DisposableSet(); + const delegate = new PromiseDelegate(); + + set.add( + commands.addCommand(CommandIDs.tree, { + execute: async (args: IRouter.ILocation) => { + if (set.isDisposed) { + return; } - } catch (error) { - console.warn('Tree routing failed.', error); + + 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 }); } + }) + ); + 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. + const listener = () => { + if (set.isDisposed) { + return; } - }); + set.dispose(); + delegate.resolve(null); + }; + router.routed.connect(listener); + set.add( + new DisposableDelegate(() => { + router.routed.disconnect(listener); + }) + ); - router.register({ command: CommandIDs.tree, pattern: treePattern }); - router.register({ command: CommandIDs.tree, pattern: workspacePattern }); + return { paths: delegate.promise }; } }; diff --git a/packages/application/src/frontend.ts b/packages/application/src/frontend.ts index d876aa87edad..b615b0459a6c 100644 --- a/packages/application/src/frontend.ts +++ b/packages/application/src/frontend.ts @@ -305,6 +305,39 @@ export namespace JupyterFrontEnd { readonly workspaces: string; }; } + + /** + * The application tree resolver token. + * + * #### Notes + * Not all Jupyter front-end applications will have a tree resolver + * implemented on the client-side. This token should not be required as a + * dependency if it is possible to make it an optional dependency. + */ + export const ITreeResolver = new Token( + '@jupyterlab/application:ITreeResolver' + ); + + /** + * An interface for a front-end tree route resolver. + */ + export interface ITreeResolver { + /** + * A promise that resolves to the routed tree paths or null. + */ + readonly paths: Promise; + } + + /** + * A namespace for tree resolver types. + */ + export namespace ITreeResolver { + /** + * The browser and file paths if the tree resolver encountered and handled + * a tree URL or `null` if not. Empty string paths should be ignored. + */ + export type Paths = { browser: string; file: string } | null; + } } /** diff --git a/packages/coreutils/src/text.ts b/packages/coreutils/src/text.ts index 7892a1b11a7e..8778c47801df 100644 --- a/packages/coreutils/src/text.ts +++ b/packages/coreutils/src/text.ts @@ -102,7 +102,7 @@ export namespace Text { * @returns the same string, but with each word capitalized. */ export function titleCase(str: string) { - return str + return (str || '') .toLowerCase() .split(' ') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) diff --git a/packages/filebrowser-extension/src/index.ts b/packages/filebrowser-extension/src/index.ts index 7fbd542e31f2..514c4317c12a 100644 --- a/packages/filebrowser-extension/src/index.ts +++ b/packages/filebrowser-extension/src/index.ts @@ -4,6 +4,7 @@ import { ILabShell, ILayoutRestorer, + IRouter, JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; @@ -134,7 +135,8 @@ const factory: JupyterFrontEndPlugin = { activate: activateFactory, id: '@jupyterlab/filebrowser-extension:factory', provides: IFileBrowserFactory, - requires: [IIconRegistry, IDocumentManager, IStateDB] + requires: [IIconRegistry, IDocumentManager], + optional: [IStateDB, IRouter, JupyterFrontEnd.ITreeResolver] }; /** @@ -209,12 +211,14 @@ export default plugins; /** * Activate the file browser factory provider. */ -function activateFactory( +async function activateFactory( app: JupyterFrontEnd, icoReg: IIconRegistry, docManager: IDocumentManager, - state: IStateDB -): IFileBrowserFactory { + state: IStateDB | null, + router: IRouter | null, + tree: JupyterFrontEnd.ITreeResolver | null +): Promise { const { commands } = app; const tracker = new WidgetTracker({ namespace }); const createFileBrowser = ( @@ -226,12 +230,11 @@ function activateFactory( manager: docManager, driveName: options.driveName || '', refreshInterval: options.refreshInterval, - state: options.state === null ? undefined : options.state || state - }); - const widget = new FileBrowser({ - id, - model + state: + options.state === null ? undefined : options.state || state || undefined }); + const restore = options.restore; + const widget = new FileBrowser({ id, model, restore }); // Add a launcher toolbar item. let launcher = new ToolbarButton({ @@ -248,9 +251,43 @@ function activateFactory( return widget; }; - const defaultBrowser = createFileBrowser('filebrowser'); - return { createFileBrowser, defaultBrowser, tracker }; + // Manually restore the default file browser. + const id = 'filebrowser'; + const defaultBrowser = createFileBrowser(id, { restore: false }); + const plugin = { createFileBrowser, defaultBrowser, tracker }; + const restoring = 'jp-mod-restoring'; + + defaultBrowser.addClass(restoring); + + if (!router) { + void defaultBrowser.model.restore(id).then(() => { + defaultBrowser.removeClass(restoring); + }); + return plugin; + } + + const listener = async () => { + router.routed.disconnect(listener); + + const paths = await tree?.paths; + if (paths) { + // Restore the model without populating it. + await defaultBrowser.model.restore(id, false); + if (paths.file) { + await commands.execute(CommandIDs.openPath, { path: paths.file }); + } + if (paths.browser) { + await commands.execute(CommandIDs.openPath, { path: paths.browser }); + } + } else { + await defaultBrowser.model.restore(id); + } + defaultBrowser.removeClass(restoring); + }; + router.routed.connect(listener); + + return plugin; } /** @@ -263,8 +300,8 @@ function activateBrowser( labShell: ILabShell, restorer: ILayoutRestorer, settingRegistry: ISettingRegistry, - commandPalette: ICommandPalette, - mainMenu: IMainMenu + commandPalette: ICommandPalette | null, + mainMenu: IMainMenu | null ): void { const browser = factory.defaultBrowser; const { commands } = app; diff --git a/packages/filebrowser-extension/style/index.css b/packages/filebrowser-extension/style/index.css index a8fa0cfabee4..f9a4609659de 100644 --- a/packages/filebrowser-extension/style/index.css +++ b/packages/filebrowser-extension/style/index.css @@ -4,3 +4,7 @@ |----------------------------------------------------------------------------*/ @import url('~@jupyterlab/filebrowser/style/index.css'); + +#filebrowser.jp-mod-restoring .jp-DirListing-content { + display: none; +} diff --git a/packages/filebrowser/src/browser.ts b/packages/filebrowser/src/browser.ts index c40c4facd8dd..d6d2ffa48631 100644 --- a/packages/filebrowser/src/browser.ts +++ b/packages/filebrowser/src/browser.ts @@ -64,19 +64,17 @@ export class FileBrowser extends Widget { this._manager = model.manager; this._crumbs = new BreadCrumbs({ model }); this.toolbar = new Toolbar(); - this._directoryPending = false; - let newFolder = new ToolbarButton({ + + const newFolder = new ToolbarButton({ iconClassName: 'jp-NewFolderIcon', onClick: () => { this.createNewDirectory(); }, tooltip: 'New Folder' }); - - let uploader = new Uploader({ model }); - - let refresher = new ToolbarButton({ + const uploader = new Uploader({ model }); + const refresher = new ToolbarButton({ iconClassName: 'jp-RefreshIcon', onClick: () => { void model.refresh(); @@ -94,13 +92,16 @@ export class FileBrowser extends Widget { this.toolbar.addClass(TOOLBAR_CLASS); this._listing.addClass(LISTING_CLASS); - let layout = new PanelLayout(); + const layout = new PanelLayout(); + layout.addWidget(this.toolbar); layout.addWidget(this._crumbs); layout.addWidget(this._listing); - this.layout = layout; - void model.restore(this.id); + + if (options.restore !== false) { + void model.restore(this.id); + } } /** @@ -309,5 +310,12 @@ export namespace FileBrowser { * The default is a shared instance of `DirListing.Renderer`. */ renderer?: DirListing.IRenderer; + + /** + * Whether a file browser automatically restores state when instantiated. + * + * The default is `true`. + */ + restore?: boolean; } } diff --git a/packages/filebrowser/src/model.ts b/packages/filebrowser/src/model.ts index 45752679dc72..057f69148afb 100644 --- a/packages/filebrowser/src/model.ts +++ b/packages/filebrowser/src/model.ts @@ -338,41 +338,53 @@ export class FileBrowserModel implements IDisposable { * * @param id - The unique ID that is used to construct a state database key. * + * @param populate - If `false`, the restoration ID will be set but the file + * browser state will not be fetched from the state database. + * * @returns A promise when restoration is complete. * * #### Notes * This function will only restore the model *once*. If it is called multiple * times, all subsequent invocations are no-ops. */ - restore(id: string): Promise { + async restore(id: string, populate = true): Promise { + const { manager } = this; + const key = `file-browser-${id}:cwd`; const state = this._state; const restored = !!this._key; - if (!state || restored) { - return Promise.resolve(void 0); + + if (restored) { + return; } - const manager = this.manager; - const key = `file-browser-${id}:cwd`; - const ready = manager.services.ready; - return Promise.all([state.fetch(key), ready]) - .then(([value]) => { - if (!value) { - this._restored.resolve(undefined); - return; - } + // Set the file browser key for state database fetch/save. + this._key = key; - const path = (value as ReadonlyJSONObject)['path'] as string; - const localPath = manager.services.contents.localPath(path); - return manager.services.contents - .get(path) - .then(() => this.cd(localPath)) - .catch(() => state.remove(key)); - }) - .catch(() => state.remove(key)) - .then(() => { - this._key = key; + if (!populate || !state) { + this._restored.resolve(undefined); + return; + } + + await manager.services.ready; + + try { + const value = await state.fetch(key); + + if (!value) { this._restored.resolve(undefined); - }); // Set key after restoration is done. + return; + } + + const path = (value as ReadonlyJSONObject)['path'] as string; + const localPath = manager.services.contents.localPath(path); + + await manager.services.contents.get(path); + await this.cd(localPath); + } catch (error) { + await state.remove(key); + } + + this._restored.resolve(undefined); } /** diff --git a/packages/filebrowser/src/tokens.ts b/packages/filebrowser/src/tokens.ts index 94daab96c4a0..fa2f4d712d0d 100644 --- a/packages/filebrowser/src/tokens.ts +++ b/packages/filebrowser/src/tokens.ts @@ -78,6 +78,19 @@ export namespace IFileBrowserFactory { */ driveName?: string; + /** + * The time interval for browser refreshing, in ms. + */ + refreshInterval?: number; + + /** + * Whether to restore the file browser state after instantiation. + * + * #### Notes + * The default value is `true`. + */ + restore?: boolean; + /** * The state database to use for saving file browser state and restoring it. * @@ -86,10 +99,5 @@ export namespace IFileBrowserFactory { * database will be automatically passed in and used for state restoration. */ state?: IStateDB | null; - - /** - * The time interval for browser refreshing, in ms. - */ - refreshInterval?: number; } }