Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve race condition between default file browser and tree URLs. #7676

Merged
merged 9 commits into from Dec 23, 2019
2 changes: 2 additions & 0 deletions packages/application-extension/package.json
Expand Up @@ -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"
},
Expand Down
108 changes: 68 additions & 40 deletions packages/application-extension/src/index.tsx
Expand Up @@ -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';
Expand Down Expand Up @@ -247,63 +251,87 @@ const router: JupyterFrontEndPlugin<IRouter> = {
};

/**
* The tree route handler provider.
* The default tree route resolver plugin.
*/
const tree: JupyterFrontEndPlugin<void> = {
id: '@jupyterlab/application-extension:tree',
const tree: JupyterFrontEndPlugin<JupyterFrontEnd.ITreeResolver> = {
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<JupyterFrontEnd.ITreeResolver.Paths>();

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 };
}
};

Expand Down
33 changes: 33 additions & 0 deletions packages/application/src/frontend.ts
Expand Up @@ -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<ITreeResolver>(
'@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<ITreeResolver.Paths>;
}

/**
* 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;
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/coreutils/src/text.ts
Expand Up @@ -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))
Expand Down
63 changes: 50 additions & 13 deletions packages/filebrowser-extension/src/index.ts
Expand Up @@ -4,6 +4,7 @@
import {
ILabShell,
ILayoutRestorer,
IRouter,
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
Expand Down Expand Up @@ -134,7 +135,8 @@ const factory: JupyterFrontEndPlugin<IFileBrowserFactory> = {
activate: activateFactory,
id: '@jupyterlab/filebrowser-extension:factory',
provides: IFileBrowserFactory,
requires: [IIconRegistry, IDocumentManager, IStateDB]
requires: [IIconRegistry, IDocumentManager],
optional: [IStateDB, IRouter, JupyterFrontEnd.ITreeResolver]
};

/**
Expand Down Expand Up @@ -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<IFileBrowserFactory> {
const { commands } = app;
const tracker = new WidgetTracker<FileBrowser>({ namespace });
const createFileBrowser = (
Expand All @@ -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({
Expand All @@ -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;
}

/**
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/filebrowser-extension/style/index.css
Expand Up @@ -4,3 +4,7 @@
|----------------------------------------------------------------------------*/

@import url('~@jupyterlab/filebrowser/style/index.css');

#filebrowser.jp-mod-restoring .jp-DirListing-content {
display: none;
}
26 changes: 17 additions & 9 deletions packages/filebrowser/src/browser.ts
Expand Up @@ -64,19 +64,17 @@ export class FileBrowser extends Widget {
this._manager = model.manager;
this._crumbs = new BreadCrumbs({ model });
this.toolbar = new Toolbar<Widget>();

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();
Expand All @@ -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);
}
}

/**
Expand Down Expand Up @@ -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;
}
}