diff --git a/app/test/tree.spec.ts b/app/test/tree.spec.ts index 58ac53970..65e80e52c 100644 --- a/app/test/tree.spec.ts +++ b/app/test/tree.spec.ts @@ -10,3 +10,21 @@ test('Tree', async ({ page }) => { const button = await page.$('text="New Notebook"'); expect(button).toBeDefined(); }); + +test('should go to subfolder', async ({ page }) => { + await page.goto(`${BASE_URL}retro/tree/binder`); + + expect( + await page.waitForSelector('.jp-FileBrowser-crumbs >> text=/binder/') + ).toBeTruthy(); +}); + +test('should update url when navigating in filebrowser', async ({ page }) => { + await page.goto(`${BASE_URL}retro/tree`); + + await page.dblclick('.jp-FileBrowser-listing >> text=binder'); + + await page.waitForSelector('.jp-FileBrowser-crumbs >> text=/binder/'); + + expect(page.url()).toEqual(`${BASE_URL}retro/tree/binder`); +}); diff --git a/packages/application-extension/package.json b/packages/application-extension/package.json index 1788628c7..b00dcc437 100644 --- a/packages/application-extension/package.json +++ b/packages/application-extension/package.json @@ -50,6 +50,8 @@ "@jupyterlab/mainmenu": "^3.1.8", "@jupyterlab/settingregistry": "^3.1.8", "@jupyterlab/translation": "^3.1.8", + "@lumino/coreutils": "^1.8.0", + "@lumino/disposable": "^1.7.0", "@lumino/widgets": "^1.23.0", "@retrolab/application": "^0.3.2", "@retrolab/ui-components": "^0.3.2" diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 49b3fd1bb..3e2aa5d43 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -4,6 +4,7 @@ import { ILabStatus, IRouter, + ITreePathUpdater, JupyterFrontEnd, JupyterFrontEndPlugin, Router @@ -16,7 +17,7 @@ import { ICommandPalette } from '@jupyterlab/apputils'; -import { PageConfig, PathExt } from '@jupyterlab/coreutils'; +import { PageConfig, PathExt, URLExt } from '@jupyterlab/coreutils'; import { IDocumentManager, renameDialog } from '@jupyterlab/docmanager'; @@ -30,6 +31,10 @@ import { RetroApp, RetroShell, IRetroShell } from '@retrolab/application'; import { jupyterIcon, retroInlineIcon } from '@retrolab/ui-components'; +import { PromiseDelegate } from '@lumino/coreutils'; + +import { DisposableDelegate, DisposableSet } from '@lumino/disposable'; + import { Widget } from '@lumino/widgets'; /** @@ -70,6 +75,11 @@ namespace CommandIDs { * Open the tree page. */ export const openTree = 'application:open-tree'; + + /** + * Resolve tree path + */ + export const resolveTree = 'application:resolve-tree'; } /** @@ -475,6 +485,91 @@ const translator: JupyterFrontEndPlugin = { provides: ITranslator }; +/** + * The default tree route resolver plugin. + */ +const tree: JupyterFrontEndPlugin = { + id: '@retrolab/application-extension:tree-resolver', + autoStart: true, + requires: [IRouter], + provides: JupyterFrontEnd.ITreeResolver, + activate: ( + app: JupyterFrontEnd, + router: IRouter + ): JupyterFrontEnd.ITreeResolver => { + const { commands } = app; + const set = new DisposableSet(); + const delegate = new PromiseDelegate(); + + const treePattern = new RegExp('/retro(/tree/.*)?'); + + set.add( + commands.addCommand(CommandIDs.resolveTree, { + execute: (async (args: IRouter.ILocation) => { + if (set.isDisposed) { + return; + } + + 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']; + + // Clean up artifacts immediately upon routing. + set.dispose(); + + delegate.resolve({ browser, file: PageConfig.getOption('treePath') }); + }) as (args: any) => Promise + }) + ); + set.add( + router.register({ command: CommandIDs.resolveTree, pattern: treePattern }) + ); + + // 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); + }) + ); + + return { paths: delegate.promise }; + } +}; + +const treePathUpdater: JupyterFrontEndPlugin = { + id: '@retrolab/application-extension:tree-updater', + requires: [IRouter], + provides: ITreePathUpdater, + activate: (app: JupyterFrontEnd, router: IRouter) => { + function updateTreePath(treePath: string) { + if (treePath !== PageConfig.getOption('treePath')) { + const path = URLExt.join( + PageConfig.getOption('baseUrl') || '/', + 'retro', + 'tree', + URLExt.encodeParts(treePath) + ); + router.navigate(path, { skipRouting: true }); + // Persist the new tree path to PageConfig as it is used elsewhere at runtime. + PageConfig.setOption('treePath', treePath); + } + } + return updateTreePath; + }, + autoStart: true +}; + /** * Zen mode plugin */ @@ -552,6 +647,8 @@ const plugins: JupyterFrontEndPlugin[] = [ title, topVisibility, translator, + tree, + treePathUpdater, zen ]; diff --git a/retrolab/app.py b/retrolab/app.py index dce096318..10e03b06a 100644 --- a/retrolab/app.py +++ b/retrolab/app.py @@ -115,7 +115,12 @@ class RetroTreeHandler(RetroHandler): if await maybe_future(cm.is_hidden(path)) and not cm.allow_hidden: self.log.info("Refusing to serve hidden directory, via 404 Error") raise web.HTTPError(404) - tpl = self.render_template("tree.html", page_config=self.get_page_config()) + + # Set treePath for routing to the directory + page_config = self.get_page_config() + page_config['treePath'] = path + + tpl = self.render_template("tree.html", page_config=page_config) return self.write(tpl) elif await maybe_future(cm.file_exists(path)): # it's not a directory, we have redirecting to do