Skip to content

Commit 3fa176e

Browse files
Add an SPA-like route to the Files page via ILiteRouter (#217)
Co-authored-by: Michał Krassowski <[email protected]>
1 parent 970bc3d commit 3fa176e

File tree

14 files changed

+501
-33
lines changed

14 files changed

+501
-33
lines changed

package.json

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,16 @@
3434
"build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension",
3535
"build:labextension": "jupyter labextension build .",
3636
"copy:landing:template": "cp templates/index.html dist/index.html",
37-
"patch:lab:index": "patch -p1 --forward < templates/customise-title-and-spinner.patch || patch -p1 -R --dry-run < templates/customise-title-and-spinner.patch",
37+
"build:base:css": "esbuild style/base.css --bundle --minify --loader:.svg=dataurl --outfile=dist/base.css",
38+
"build:landing:css": "esbuild style/landing.css --bundle --minify --loader:.svg=dataurl --outfile=dist/landing.css",
39+
"build:landing:page": "jlpm copy:landing:template && jlpm build:base:css && jlpm build:landing:css",
40+
"build:landing:all": "esbuild src/landing.tsx --bundle --outfile=dist/landing.js --minify --loader:.svg=dataurl --loader:.png=dataurl --loader:.jpeg=dataurl && jlpm build:landing:page",
41+
"patch:lab:index": "patch -p1 --verbose --forward < templates/customise-title-and-spinner.patch || patch -p1 -R --dry-run --verbose < templates/customise-title-and-spinner.patch",
3842
"copy:je-spinner-logo": "cp style/icons/logo.svg dist/lab/logo.svg",
3943
"copy:favicon": "cp static/favicon.ico dist/favicon.ico && cp static/favicon.ico dist/lab/favicon.ico",
40-
"build:landing:css": "esbuild style/landing.css --bundle --minify --loader:.svg=dataurl --outfile=dist/landing.css",
41-
"build:base:css": "esbuild style/base.css --bundle --minify --loader:.svg=dataurl --outfile=dist/base.css",
42-
"build:css": "jlpm build:landing:css && jlpm build:base:css",
43-
"build:landing": "esbuild src/landing.tsx --bundle --outfile=dist/landing.js --minify --loader:.svg=dataurl --loader:.png=dataurl --loader:.jpeg=dataurl && jlpm copy:landing:template && jlpm patch:lab:index && jlpm copy:je-spinner-logo && jlpm build:css",
44-
"build:jupyterlite": "cd lite && jupyter lite build --XeusAddon.environment_file=xeus-environment.yml --output-dir=../dist && cd .. && jlpm build:landing && jlpm copy:favicon",
44+
"build:lab:all": "jlpm patch:lab:index && jlpm copy:je-spinner-logo && jlpm copy:favicon",
45+
"build:files:all": "mkdir -p dist/lab/files && cp templates/files-redirect.html dist/lab/files/index.html",
46+
"build:jupyterlite": "cd lite && jupyter lite build --XeusAddon.environment_file=xeus-environment.yml --output-dir=../dist && cd .. && jlpm build:landing:all && jlpm build:lab:all && jlpm build:files:all",
4547
"build:all": "jlpm build && jlpm build:jupyterlite",
4648
"build:labextension:dev": "jupyter labextension build --development True .",
4749
"build:lib": "tsc --sourceMap",
@@ -80,6 +82,7 @@
8082
"@jupyterlab/settingregistry": "^4.5.0-alpha.3",
8183
"@jupyterlab/translation": "^4.5.0-alpha.3",
8284
"@jupyterlab/ui-components": "^4.5.0-alpha.3",
85+
"@jupyterlite/application": "^0.7.0-alpha.4",
8386
"@lumino/commands": "^2.3.2",
8487
"@lumino/coreutils": "^2.2.1",
8588
"@lumino/messaging": "^2.0.3",
@@ -90,6 +93,7 @@
9093
},
9194
"resolutions": {
9295
"@jupyterlab/application": "^4.5.0-alpha.3",
96+
"@jupyterlite/application": "^0.7.0-alpha.4",
9397
"@jupyterlab/apputils": "^4.5.0-alpha.3",
9498
"@jupyterlab/cells": "^4.5.0-alpha.3",
9599
"@jupyterlab/codeeditor": "^4.5.0-alpha.3",

src/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export namespace Commands {
22
export const openCompetitions = 'jupytereverywhere:open-competitions';
33
export const openFiles = 'jupytereverywhere:open-files';
4+
export const routeFiles = 'jupytereverywhere:files-route';
45
export const openHelp = 'jupytereverywhere:open-help';
56
export const downloadNotebookCommand = 'jupytereverywhere:download-notebook';
67
export const downloadPDFCommand = 'jupytereverywhere:download-pdf';

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import fastForwardSvg from '../style/icons/fast-forward.svg';
1616

1717
import { exportNotebookAsPDF } from './pdf';
1818
import { files } from './pages/files';
19+
import routesPlugin from './routes';
1920
import { Commands } from './commands';
2021
// import { competitions } from './pages/competitions';
2122
import { notebookPlugin } from './pages/notebook';
@@ -597,6 +598,7 @@ export default [
597598
plugin,
598599
notebookPlugin,
599600
files,
601+
routesPlugin,
600602
// competitions,
601603
customSidebar,
602604
// helpPlugin,

src/pages/competitions.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ export const competitions: JupyterFrontEndPlugin<void> = {
4040
label: 'Competition',
4141
icon: EverywhereIcons.competition,
4242
execute: () => {
43-
app.commands.execute(Commands.openCompetitions);
43+
void app.commands.execute(Commands.openCompetitions);
44+
return;
4445
}
4546
}),
4647
'left',

src/pages/files.tsx

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { JupyterFrontEndPlugin, JupyterFrontEnd } from '@jupyterlab/application';
2+
import { ILiteRouter } from '@jupyterlite/application';
23
import { MainAreaWidget, ReactWidget, showErrorMessage } from '@jupyterlab/apputils';
34
import { Contents } from '@jupyterlab/services';
45
import { IContentsManager } from '@jupyterlab/services';
@@ -365,7 +366,12 @@ export const files: JupyterFrontEndPlugin<void> = {
365366
id: 'jupytereverywhere:files',
366367
autoStart: true,
367368
requires: [IContentsManager],
368-
activate: (app: JupyterFrontEnd, contentsManager: Contents.IManager) => {
369+
optional: [ILiteRouter],
370+
activate: (
371+
app: JupyterFrontEnd,
372+
contentsManager: Contents.IManager,
373+
router: ILiteRouter | null
374+
) => {
369375
const createWidget = () => {
370376
const content = new Files(contentsManager);
371377
const widget = new MainAreaWidget({ content });
@@ -385,18 +391,40 @@ export const files: JupyterFrontEndPlugin<void> = {
385391

386392
let widget = createWidget();
387393

388-
app.shell.add(
389-
new SidebarIcon({
390-
label: 'Files',
391-
icon: EverywhereIcons.folderSidebar,
392-
execute: () => {
393-
void app.commands.execute(Commands.openFiles);
394-
return undefined;
394+
const base = (router?.base || '').replace(/\/$/, '');
395+
const filesPath = `${base}/lab/files/`;
396+
397+
// Show the Files widget; return false-y so SidebarIcon does the URL swap.
398+
const filesSidebar = new SidebarIcon({
399+
label: 'Files',
400+
icon: EverywhereIcons.folderSidebar,
401+
pathName: filesPath,
402+
execute: () => {
403+
void app.commands.execute(Commands.openFiles);
404+
return SidebarIcon.delegateNavigation;
405+
}
406+
});
407+
app.shell.add(filesSidebar, 'left', { rank: 200 });
408+
409+
// If we landed with a "files" intent, highlight Files in the sidebar.
410+
void app.restored.then(() => {
411+
const url = new URL(window.location.href);
412+
const pathIsFiles = /\/lab\/files(?:\/|$)/.test(url.pathname);
413+
const tabIsFiles = url.searchParams.get('tab') === 'files';
414+
if (pathIsFiles || tabIsFiles) {
415+
const desired = new URL(filesPath, window.location.origin);
416+
desired.hash = url.hash;
417+
window.history.replaceState(null, 'Files', desired.toString());
418+
419+
if (widget.isDisposed) {
420+
widget = createWidget();
421+
}
422+
if (!widget.isAttached) {
423+
app.shell.add(widget, 'main');
395424
}
396-
}),
397-
'left',
398-
{ rank: 200 }
399-
);
425+
app.shell.activateById(filesSidebar.id);
426+
}
427+
});
400428

401429
app.commands.addCommand(Commands.openFiles, {
402430
label: 'Open Files',

src/pages/help.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ export const helpPlugin: JupyterFrontEndPlugin<void> = {
128128
label: 'Help Centre',
129129
icon: EverywhereIcons.help,
130130
execute: () => {
131-
app.commands.execute(Commands.openHelp);
131+
void app.commands.execute(Commands.openHelp);
132+
return;
132133
}
133134
}),
134135
'left',

src/pages/notebook.tsx

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application';
2+
import { ILiteRouter } from '@jupyterlite/application';
23
import { INotebookTracker, INotebookWidgetFactory } from '@jupyterlab/notebook';
34
import { INotebookContent } from '@jupyterlab/nbformat';
45
import { SidebarIcon } from '../ui-components/SidebarIcon';
@@ -41,16 +42,26 @@ export const notebookPlugin: JupyterFrontEndPlugin<void> = {
4142
IToolbarWidgetRegistry,
4243
INotebookWidgetFactory
4344
],
45+
optional: [ILiteRouter],
4446
activate: (
4547
app: JupyterFrontEnd,
4648
tracker: INotebookTracker,
4749
readonlyTracker: IViewOnlyNotebookTracker,
48-
toolbarRegistry: IToolbarWidgetRegistry
50+
toolbarRegistry: IToolbarWidgetRegistry,
51+
router?: ILiteRouter | null
4952
) => {
5053
const { commands, shell, serviceManager } = app;
5154
const { contents } = serviceManager;
5255

5356
const params = new URLSearchParams(window.location.search);
57+
58+
// Are we landing on the Files tab directly? In this case, we won't
59+
// auto-create a new notebook or activate the notebook sidebar.
60+
const nowUrl = new URL(window.location.href);
61+
const onFilesPath = /\/lab\/files(?:\/|$)/.test(nowUrl.pathname);
62+
const onFilesTab = nowUrl.searchParams.get('tab') === 'files';
63+
const onFilesIntent = onFilesPath || onFilesTab;
64+
5465
let notebookId = params.get('notebook');
5566
const uploadedNotebookId = params.get('uploaded-notebook');
5667

@@ -210,7 +221,7 @@ export const notebookPlugin: JupyterFrontEndPlugin<void> = {
210221
void loadSharedNotebook(notebookId);
211222
} else if (uploadedNotebookId) {
212223
void openUploadedNotebook(uploadedNotebookId);
213-
} else {
224+
} else if (!onFilesIntent) {
214225
void createNewNotebook();
215226
}
216227

@@ -230,19 +241,35 @@ export const notebookPlugin: JupyterFrontEndPlugin<void> = {
230241
const sidebarItem = new SidebarIcon({
231242
label: 'Notebook',
232243
icon: EverywhereIcons.notebook,
244+
pathName: `${(router?.base || '').replace(/\/$/, '')}/lab/index.html`,
233245
execute: () => {
234246
if (readonlyTracker.currentWidget) {
235-
return shell.activateById(readonlyTracker.currentWidget.id);
247+
const id = readonlyTracker.currentWidget.id;
248+
shell.activateById(id);
249+
return SidebarIcon.delegateNavigation;
236250
}
237251
if (tracker.currentWidget) {
238-
return shell.activateById(tracker.currentWidget.id);
252+
const id = tracker.currentWidget.id;
253+
shell.activateById(id);
254+
return SidebarIcon.delegateNavigation;
239255
}
256+
257+
// If we don't have a notebook yet (likely we started on /lab/files/) -> create one now.
258+
void (async () => {
259+
await app.commands.execute('notebook:create-new', { kernelName: 'python' });
260+
if (tracker.currentWidget) {
261+
shell.activateById(tracker.currentWidget.id);
262+
}
263+
})();
264+
return SidebarIcon.delegateNavigation;
240265
}
241266
});
242267
shell.add(sidebarItem, 'left', { rank: 100 });
243268

244-
app.shell.activateById(sidebarItem.id);
245-
app.restored.then(() => app.shell.activateById(sidebarItem.id));
269+
if (!onFilesIntent) {
270+
app.shell.activateById(sidebarItem.id);
271+
app.restored.then(() => app.shell.activateById(sidebarItem.id));
272+
}
246273

247274
for (const toolbarName of ['Notebook', 'ViewOnlyNotebook']) {
248275
toolbarRegistry.addFactory(
@@ -293,5 +320,33 @@ export const notebookPlugin: JupyterFrontEndPlugin<void> = {
293320
() => new KernelSwitcherDropdownButton(commands, tracker)
294321
);
295322
}
323+
324+
// Canonicalise the URL if we are directly at /lab/.
325+
void app.restored.then(() => {
326+
const url = new URL(window.location.href);
327+
if (/\/lab\/$/.test(url.pathname)) {
328+
url.pathname = url.pathname.replace(/\/lab\/$/, '/lab/index.html');
329+
window.history.replaceState({}, '', url.toString());
330+
}
331+
332+
const after = new URL(window.location.href);
333+
if (after.searchParams.get('tab') === 'notebook') {
334+
const id = document.querySelector('.jp-NotebookPanel')?.id;
335+
if (id) {
336+
app.shell.activateById(id);
337+
after.searchParams.delete('tab');
338+
const base = (router?.base || '').replace(/\/$/, '');
339+
const canonical = new URL(`${base}/lab/index.html`, window.location.origin);
340+
canonical.hash = after.hash;
341+
// Keep any other non-tab params off; Notebook page doesn't need them
342+
if (
343+
after.pathname + after.search + after.hash !==
344+
canonical.pathname + canonical.search + canonical.hash
345+
) {
346+
window.history.replaceState(null, 'Notebook', canonical.toString());
347+
}
348+
}
349+
}
350+
});
296351
}
297352
};

src/routes.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application';
2+
import { ILiteRouter } from '@jupyterlite/application';
3+
import { Commands } from './commands';
4+
import { ILabShell } from '@jupyterlab/application';
5+
6+
const ROUTE_FILES_CMD = Commands.routeFiles;
7+
8+
const routesPlugin: JupyterFrontEndPlugin<void> = {
9+
id: 'jupytereverywhere:routes',
10+
autoStart: true,
11+
optional: [ILiteRouter, ILabShell],
12+
activate: (app: JupyterFrontEnd, router: ILiteRouter | null, _labShell?: ILabShell | null) => {
13+
if (!router) {
14+
return;
15+
}
16+
17+
app.commands.addCommand(ROUTE_FILES_CMD, {
18+
label: 'Open Files (route)',
19+
execute: async () => {
20+
await app.restored;
21+
await app.commands.execute(Commands.openFiles);
22+
}
23+
});
24+
25+
const base = router.base.replace(/\/+$/, '');
26+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27+
28+
// 1. Support direct files/ paths, as the redirect page lands there first.
29+
const filesPathPatterns = [
30+
/^\/files(?:\/.*)?$/,
31+
new RegExp(`^${esc(base)}\\/files(?:\\/.*)?$`)
32+
];
33+
filesPathPatterns.forEach(pattern => router.register({ command: ROUTE_FILES_CMD, pattern }));
34+
35+
// 2. Support /lab/index.html?tab=files (or ?tab=notebook). We register a
36+
// router handler that just inspects the query string.
37+
router.register({
38+
command: ROUTE_FILES_CMD,
39+
pattern: new RegExp(`^${esc(base)}\\/?(?:index\\.html)?\\?[^#]*\\btab=files\\b(?:[&#].*)?$`)
40+
});
41+
router.register({
42+
command: ROUTE_FILES_CMD,
43+
pattern: /^\/?(?:index\.html)?\?[^#]*\btab=files\b(?:[&#].*)?$/
44+
});
45+
46+
void app.restored.then(() => {
47+
const search = window.location.search || '';
48+
const params = new URLSearchParams(search);
49+
const tab = params.get('tab');
50+
51+
if (tab === 'files') {
52+
void app.commands.execute(ROUTE_FILES_CMD).then(() => {
53+
const filesURL = new URL(`${base.replace(/\/$/, '')}/lab/files/`, window.location.origin);
54+
filesURL.hash = window.location.hash;
55+
window.history.replaceState(null, 'Files', filesURL.toString());
56+
});
57+
return;
58+
}
59+
60+
if (tab === 'notebook') {
61+
const tryActivate = async () => {
62+
const id = document.querySelector('.jp-NotebookPanel')?.id;
63+
if (id) {
64+
app.shell.activateById(id);
65+
}
66+
const nbURL = new URL(
67+
`${base.replace(/\/$/, '')}/lab/index.html`,
68+
window.location.origin
69+
);
70+
nbURL.hash = window.location.hash;
71+
window.history.replaceState(null, 'Notebook', nbURL.toString());
72+
};
73+
tryActivate();
74+
}
75+
});
76+
77+
const here = window.location.href;
78+
79+
if (filesPathPatterns.some(p => p.test(here))) {
80+
void app.restored.then(() => {
81+
void app.commands.execute(Commands.openFiles);
82+
});
83+
}
84+
}
85+
};
86+
87+
export default routesPlugin;

src/sidebar.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const customSidebar: JupyterFrontEndPlugin<void> = {
4444
: null;
4545
if (newWidget && newWidget instanceof SidebarIcon) {
4646
const cancel = newWidget.execute();
47-
if (cancel) {
47+
if (cancel === true) {
4848
console.log('Attempting to revert to:', oldWidget?.label);
4949
if (args.previousTitle) {
5050
const previousIndex = sidebar.titles.indexOf(oldWidget);

0 commit comments

Comments
 (0)