Skip to content

Commit

Permalink
Merge pull request #7676 from afshin/issue-4009
Browse files Browse the repository at this point in the history
Resolve race condition between default file browser and tree URLs.
  • Loading branch information
Steven Silvester committed Dec 23, 2019
2 parents 3c6521e + 6e0b32e commit 8ebef8b
Show file tree
Hide file tree
Showing 9 changed files with 223 additions and 91 deletions.
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;
}
}

0 comments on commit 8ebef8b

Please sign in to comment.