diff --git a/app/doc/tree/index.html b/app/doc/tree/index.html
new file mode 100644
index 000000000..b3c3206f6
--- /dev/null
+++ b/app/doc/tree/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/lab/package.json b/app/lab/package.json
index 6ba6e57d2..c49dcc7bf 100644
--- a/app/lab/package.json
+++ b/app/lab/package.json
@@ -86,6 +86,7 @@
"@jupyterlite/kernel": "~0.2.0-rc.0",
"@jupyterlite/licenses": "~0.2.0-rc.0",
"@jupyterlite/localforage": "~0.2.0-rc.0",
+ "@jupyterlite/notebook-application-extension": "~0.2.0-rc.0",
"@jupyterlite/server": "~0.2.0-rc.0",
"@jupyterlite/server-extension": "~0.2.0-rc.0",
"@jupyterlite/types": "~0.2.0-rc.0",
@@ -157,6 +158,7 @@
"@jupyterlite/iframe-extension": "^0.2.0-rc.0",
"@jupyterlite/licenses": "^0.2.0-rc.0",
"@jupyterlite/localforage": "^0.2.0-rc.0",
+ "@jupyterlite/notebook-application-extension": "^0.2.0-rc.0",
"@jupyterlite/server": "^0.2.0-rc.0",
"@jupyterlite/server-extension": "^0.2.0-rc.0",
"@jupyterlite/types": "^0.2.0-rc.0",
@@ -212,6 +214,7 @@
"@jupyterlab/vega5-extension",
"@jupyterlite/application-extension",
"@jupyterlite/iframe-extension",
+ "@jupyterlite/notebook-application-extension",
"@jupyterlite/server-extension"
],
"singletonPackages": [
@@ -287,7 +290,9 @@
"@jupyterlab/docmanager-extension:download",
"@jupyterlab/filebrowser-extension:download",
"@jupyterlab/filebrowser-extension:share-file",
- "@jupyterlab/help-extension:about"
+ "@jupyterlab/help-extension:about",
+ "@jupyterlite/notebook-application-extension:logo",
+ "@jupyterlite/notebook-application-extension:notify-commands"
],
"mimeExtensions": {
"@jupyterlab/javascript-extension": "",
diff --git a/app/webpack.config.js b/app/webpack.config.js
index a167660c1..d6be92131 100644
--- a/app/webpack.config.js
+++ b/app/webpack.config.js
@@ -227,8 +227,9 @@ class CompileSchemasPlugin {
compiler.hooks.done.tapAsync('CompileSchemasPlugin', (compilation, callback) => {
// ensure all schemas are statically compiled
const schemaDir = path.resolve(topLevelBuild, './schemas');
+ const allCore = 'all.json';
const files = glob.sync(`${schemaDir}/**/*.json`, {
- ignore: [`${schemaDir}/all.json`],
+ ignore: [`${schemaDir}/all*.json`],
});
const all = files.map((file) => {
const schema = fs.readJSONSync(file);
@@ -247,7 +248,7 @@ class CompileSchemasPlugin {
};
});
- fs.writeFileSync(path.resolve(schemaDir, 'all.json'), JSON.stringify(all));
+ fs.writeFileSync(path.resolve(schemaDir, allCore), JSON.stringify(all));
callback();
});
}
diff --git a/docs/howto/configure/interface_switcher.md b/docs/howto/configure/interface_switcher.md
new file mode 100644
index 000000000..a3230ab94
--- /dev/null
+++ b/docs/howto/configure/interface_switcher.md
@@ -0,0 +1,52 @@
+# Enable switching between the JupyterLab and Notebook interfaces
+
+By default JupyterLite includes both the JupyterLab and Notebook interfaces.
+
+The JupyterLab interface is available under the `/lab` path, and the Notebook file
+browser is available under the `/tree` path. However there is no convenient way to
+switch between the two interfaces by default.
+
+To add menu entries and toolbar items to help switching between interfaces, you can
+install the `notebook` package in the build environment, just like any other extension.
+
+## Installing the `notebook` package
+
+In your build environment, install the `notebook` package:
+
+```shell
+pip install notebook
+```
+
+Or add it to your `requirements.txt` or similar file for managing dependencies:
+
+```text
+notebook
+```
+
+Then build your JupyterLite site as usual:
+
+```shell
+jupyter lite build
+```
+
+The `notebook` package includes a JupyterLab extensions that adds the menu entries and
+toolbar items to switch between interfaces.
+
+## Launch the Jupyter Notebook File Browser menu item
+
+The `notebook` package adds a menu item to launch the Jupyter Notebook File Browser via
+the `Help > Launch Jupyter Notebook File Browser`:
+
+![a screenshot showing how to launch Jupyter Notebook from the menu entry](https://github.com/jupyterlite/jupyterlite/assets/591645/bc45a79a-1ede-44ca-b6ca-3deb5fe56187)
+
+## Switch between JupyterLab and Notebook interfaces
+
+The `notebook` package also adds a toolbar item to switch between the JupyterLab and
+Notebook interfaces:
+
+![a screenshot showing how to launch JupyterLab from a notebook](https://github.com/jupyterlite/jupyterlite/assets/591645/009c5e32-d8bf-4658-a711-b28c45dcdd1d)
+
+## References
+
+Check out the [guide for adding extensions](../configure/simple_extensions.md) for more
+informations about how to add extensions to your JupyterLite site.
diff --git a/docs/howto/index.md b/docs/howto/index.md
index fb4ecbb4c..798456fa1 100644
--- a/docs/howto/index.md
+++ b/docs/howto/index.md
@@ -23,6 +23,7 @@ configure/settings
configure/translation
configure/rtc
configure/config_files
+configure/interface_switcher
```
## Contents
diff --git a/examples/jupyter_lite_config.json b/examples/jupyter_lite_config.json
index afbe17d33..f823626d5 100644
--- a/examples/jupyter_lite_config.json
+++ b/examples/jupyter_lite_config.json
@@ -17,6 +17,7 @@
"https://conda.anaconda.org/conda-forge/noarch/jupyterlab-myst-2.0.2-pyhd8ed1ab_0.conda",
"https://conda.anaconda.org/conda-forge/noarch/jupyterlab_miami_nights-0.4.0-pyhd8ed1ab_0.conda",
"https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.8-pyhd8ed1ab_0.conda",
+ "https://conda.anaconda.org/conda-forge/noarch/notebook-7.0.6-pyhd8ed1ab_0.conda",
"https://conda.anaconda.org/conda-forge/label/jupyterlite_pyodide_kernel_alpha/noarch/jupyterlite-pyodide-kernel-0.2.0a1-pyh9178eb9_0.conda",
"https://conda.anaconda.org/conda-forge/noarch/plotly-5.15.0-pyhd8ed1ab_0.conda",
"https://github.com/jupyterlite/p5-kernel/releases/download/v0.1.0/jupyterlite_p5_kernel-0.1.0-py3-none-any.whl"
diff --git a/packages/application-extension/src/index.tsx b/packages/application-extension/src/index.tsx
index bf53269a7..57f4644c8 100644
--- a/packages/application-extension/src/index.tsx
+++ b/packages/application-extension/src/index.tsx
@@ -37,7 +37,7 @@ import React from 'react';
/**
* A regular expression to match path to notebooks, documents and consoles
*/
-const URL_PATTERN = new RegExp('/(lab|notebooks|edit|consoles)\\/?');
+const URL_PATTERN = new RegExp('/(lab|tree|notebooks|edit|consoles)\\/?');
/**
* The JupyterLab document manager plugin id.
@@ -333,14 +333,28 @@ const opener: JupyterFrontEndPlugin = {
const urlParams = new URLSearchParams(search);
const paths = urlParams.getAll('path');
- if (!paths) {
+ if (paths.length === 0) {
return;
}
const files = paths.map((path) => decodeURIComponent(path));
app.started.then(async () => {
const page = PageConfig.getOption('notebookPage');
const [file] = files;
- if (page === 'consoles') {
+ if (page === 'tree') {
+ let appUrl = '/edit';
+ // check if the file is a notebook
+ const defaultFactory = docRegistry.defaultWidgetFactory(file);
+ if (defaultFactory.name === 'Notebook') {
+ appUrl = '/notebooks';
+ }
+ const baseUrl = PageConfig.getBaseUrl();
+ const url = new URL(URLExt.join(baseUrl, appUrl, 'index.html'));
+ url.searchParams.append('path', file);
+
+ // redirect to the proper page
+ window.location.href = url.toString();
+ return;
+ } else if (page === 'consoles') {
commands.execute('console:create', { path: file });
return;
} else if (page === 'notebooks' || page === 'edit') {
diff --git a/packages/settings/src/settings.ts b/packages/settings/src/settings.ts
index 8ec6f40db..136673ce7 100644
--- a/packages/settings/src/settings.ts
+++ b/packages/settings/src/settings.ts
@@ -1,13 +1,12 @@
import { PageConfig, URLExt } from '@jupyterlab/coreutils';
+import { PromiseDelegate } from '@lumino/coreutils';
+
import * as json5 from 'json5';
import type localforage from 'localforage';
-import { IFederatedExtension } from '@jupyterlite/types';
-
-import { IPlugin, ISettings } from './tokens';
-import { PromiseDelegate } from '@lumino/coreutils';
+import { IPlugin, ISettings, SettingsFile } from './tokens';
/**
* The name of the local storage.
@@ -87,26 +86,27 @@ export class Settings implements ISettings {
async get(pluginId: string): Promise {
const all = await this.getAll();
const settings = all.settings as IPlugin[];
- let found = settings.find((setting: IPlugin) => {
+ const setting = settings.find((setting: IPlugin) => {
return setting.id === pluginId;
});
-
- if (!found) {
- found = await this._getFederated(pluginId);
- }
-
- return found;
+ return setting;
}
/**
* Get all the settings
*/
async getAll(): Promise<{ settings: IPlugin[] }> {
- const settingsUrl = PageConfig.getOption('settingsUrl') ?? '/';
+ const [allCore, allFederated] = await Promise.all([
+ this._getAll('all.json'),
+ this._getAll('all_federated.json'),
+ ]);
+
+ // JupyterLab 4 expects all settings to be returned in one go
+ // so append the settings from federated plugins to the core ones
+ const all = allCore.concat(allFederated);
+
+ // return existing user settings if they exist
const storage = await this.storage;
- const all = (await (
- await fetch(URLExt.join(settingsUrl, 'all.json'))
- ).json()) as IPlugin[];
const settings = await Promise.all(
all.map(async (plugin) => {
const { id } = plugin;
@@ -133,37 +133,14 @@ export class Settings implements ISettings {
}
/**
- * Get the settings for a federated extension
- *
- * @param pluginId The id of a plugin
+ * Get all the settings for core or federated plugins
*/
- private async _getFederated(pluginId: string): Promise {
- const [packageName, schemaName] = pluginId.split(':');
-
- if (!Private.isFederated(packageName)) {
- return;
- }
-
- const labExtensionsUrl = PageConfig.getOption('fullLabextensionsUrl');
- const schemaUrl = URLExt.join(
- labExtensionsUrl,
- packageName,
- 'schemas',
- packageName,
- `${schemaName}.json`,
- );
- const packageUrl = URLExt.join(labExtensionsUrl, packageName, 'package.json');
- const schema = await (await fetch(schemaUrl)).json();
- const packageJson = await (await fetch(packageUrl)).json();
- const raw = ((await (await this.storage).getItem(pluginId)) as string) ?? '{}';
- const settings = json5.parse(raw) || {};
- return Private.override({
- id: pluginId,
- raw,
- schema,
- settings,
- version: packageJson.version || '3.0.8',
- });
+ private async _getAll(file: SettingsFile): Promise {
+ const settingsUrl = PageConfig.getOption('settingsUrl') ?? '/';
+ const all = (await (
+ await fetch(URLExt.join(settingsUrl, file))
+ ).json()) as IPlugin[];
+ return all;
}
private _storageName: string = DEFAULT_STORAGE_NAME;
@@ -195,29 +172,6 @@ namespace Private {
PageConfig.getOption('settingsOverrides') || '{}',
);
- /**
- * Test whether this package is configured in `federated_extensions` in this app
- *
- * @param packageName The npm name of a package
- */
- export function isFederated(packageName: string): boolean {
- let federated: IFederatedExtension[];
-
- try {
- federated = JSON.parse(PageConfig.getOption('federated_extensions'));
- } catch {
- return false;
- }
-
- for (const { name } of federated) {
- if (name === packageName) {
- return true;
- }
- }
-
- return false;
- }
-
/**
* Override the defaults of the schema with ones from PageConfig
*
diff --git a/packages/settings/src/tokens.ts b/packages/settings/src/tokens.ts
index f20dd8989..7a533458f 100644
--- a/packages/settings/src/tokens.ts
+++ b/packages/settings/src/tokens.ts
@@ -7,6 +7,11 @@ import { JSONObject, PartialJSONObject, Token } from '@lumino/coreutils';
*/
export const ISettings = new Token('@jupyterlite/settings:ISettings');
+/**
+ * The settings file to request
+ */
+export type SettingsFile = 'all.json' | 'all_federated.json';
+
/**
* An interface for the plugin settings.
*/
diff --git a/py/jupyterlite-core/jupyterlite_core/addons/federated_extensions.py b/py/jupyterlite-core/jupyterlite_core/addons/federated_extensions.py
index 172b6abf2..122614172 100644
--- a/py/jupyterlite-core/jupyterlite_core/addons/federated_extensions.py
+++ b/py/jupyterlite-core/jupyterlite_core/addons/federated_extensions.py
@@ -8,6 +8,7 @@
from traitlets import List, Unicode
from ..constants import (
+ ALL_FEDERATED_JSON,
FEDERATED_EXTENSIONS,
JSON_FMT,
JUPYTER_CONFIG_DATA,
@@ -189,7 +190,8 @@ def copy_one_federated_extension(self, pkg_json):
if self.is_prebuilt(pkg_data):
pkg_name = pkg_data["name"]
- self.copy_one(pkg_json.parent, self.output_extensions / f"{pkg_name}")
+ output_pkg = self.output_extensions / pkg_name
+ self.copy_one(pkg_json.parent, output_pkg)
def is_prebuilt(self, pkg_json):
"""verify this is an actual pre-built extension, containing load information"""
@@ -284,6 +286,53 @@ def post_build(self, manager):
actions=[(self.copy_one, [theme_dir, dest])],
)
+ yield self.task(
+ name="settings",
+ doc=f"ensure {ALL_FEDERATED_JSON} includes the settings of federated extensions",
+ file_dep=[*lab_extensions],
+ actions=[(self.ensure_federated_settings, [manager, lab_extensions])],
+ )
+
+ def ensure_federated_settings(self, manager, lab_extensions):
+ """ensure settings from federated extensions are aggregated in a single file"""
+ all_federated_settings = [
+ setting for p in lab_extensions for setting in self.get_federated_settings(p.parent)
+ ]
+
+ app_schemas = manager.output_dir / "build" / "schemas"
+ if not app_schemas.is_dir():
+ # bail if there is no schemas dir
+ return
+ all_federated_json = app_schemas / ALL_FEDERATED_JSON
+ all_federated_json.write_text(json.dumps(all_federated_settings), **UTF8)
+
+ def get_federated_settings(self, extension):
+ """get the settings for a federated extension"""
+ pkg_json = extension / PACKAGE_JSON
+ pkg_data = json.loads(pkg_json.read_text(**UTF8))
+ settings_dir = extension / "schemas"
+ if not settings_dir.is_dir():
+ # bail if there is no settings for that extension
+ return []
+ setting_files = sorted(settings_dir.rglob("*.json"))
+
+ pkg_name = pkg_data["name"]
+ pkg_version = pkg_data["version"]
+ all_settings = []
+ for setting_file in setting_files:
+ plugin_id = f"{pkg_name}:{setting_file.stem}"
+ schema = json.loads(setting_file.read_text(**UTF8))
+ setting = {
+ "id": plugin_id,
+ "raw": "{}",
+ "schema": schema,
+ "settings": {},
+ "version": pkg_version,
+ }
+ all_settings.append(setting)
+
+ return all_settings
+
def patch_jupyterlite_json(self, jupyterlite_json):
"""add the federated_extensions to jupyter-lite.json
diff --git a/py/jupyterlite-core/jupyterlite_core/constants.py b/py/jupyterlite-core/jupyterlite_core/constants.py
index 3c9b32bcf..2b48808b0 100644
--- a/py/jupyterlite-core/jupyterlite_core/constants.py
+++ b/py/jupyterlite-core/jupyterlite_core/constants.py
@@ -80,8 +80,9 @@
#: Needs a better canonical location
DEFAULT_OUTPUT_DIR = "_output"
-#: commonly-used filename for response fixtures, e.g. settings
+#: commonly-used filenames for response fixtures, e.g. settings
ALL_JSON = "all.json"
+ALL_FEDERATED_JSON = "all_federated.json"
### Environment Variables
diff --git a/py/jupyterlite-core/jupyterlite_core/tests/test_cli.py b/py/jupyterlite-core/jupyterlite_core/tests/test_cli.py
index a12dd49f3..2080ed791 100644
--- a/py/jupyterlite-core/jupyterlite_core/tests/test_cli.py
+++ b/py/jupyterlite-core/jupyterlite_core/tests/test_cli.py
@@ -210,7 +210,8 @@ def test_build_repl_no_sourcemaps(an_empty_lite_dir, script_runner):
"""does (re-)building create a predictable pattern of file counts"""
out = an_empty_lite_dir / "_output"
- args = original_args = "jupyter", "lite", "build"
+ # disable the federated_extensions addon as it may output settings for federated extensions
+ args = original_args = "jupyter", "lite", "build", "--disable-addons", "federated_extensions"
status = script_runner.run(args, cwd=str(an_empty_lite_dir))
norm_files = sorted(out.rglob("*"))
assert status.success
diff --git a/py/jupyterlite-core/pyproject.toml b/py/jupyterlite-core/pyproject.toml
index 8b85cde45..1a5470a9c 100644
--- a/py/jupyterlite-core/pyproject.toml
+++ b/py/jupyterlite-core/pyproject.toml
@@ -65,6 +65,7 @@ libarchive = [
]
lab = [
"jupyterlab >=4.0.7,<5.0",
+ "notebook >=7.0.6,<8.0",
]
contents = [
"jupyter_server",
@@ -84,6 +85,7 @@ all = [
"jupyterlab >=4.0.7,<5.0",
"jupyterlab_server >=2.8.1,<3",
"libarchive-c >=4.0",
+ "notebook >=7.0.6,<8.0",
"pkginfo",
"tornado >=6.1",
]
diff --git a/py/jupyterlite/pyproject.toml b/py/jupyterlite/pyproject.toml
index 449714015..f8d828718 100644
--- a/py/jupyterlite/pyproject.toml
+++ b/py/jupyterlite/pyproject.toml
@@ -52,6 +52,7 @@ libarchive = [
]
lab = [
"jupyterlab >=4.0.7,<5.0",
+ "notebook >=7.0.6,<8.0",
]
contents = [
"jupyter_server",
@@ -71,6 +72,7 @@ all = [
"jupyterlab >=4.0.7,<5.0",
"jupyterlab_server >=2.8.1,<3",
"libarchive-c >=4.0",
+ "notebook >=7.0.6,<8.0",
"pkginfo",
"tornado >=6.1",
]
diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json
index 332658c6b..5d2fe6d17 100644
--- a/tsconfig.eslint.json
+++ b/tsconfig.eslint.json
@@ -3,6 +3,7 @@
"include": [
"*",
".eslintrc.js",
+ "app/**/*.config.js",
"docs/**/*",
"packages/**/src/**/*",
"packages/**/test/**/*",
diff --git a/ui-tests/requirements.txt b/ui-tests/requirements.txt
index 53e8e1a67..e3238f7b7 100644
--- a/ui-tests/requirements.txt
+++ b/ui-tests/requirements.txt
@@ -1,5 +1,6 @@
../py/jupyterlite-javascript-kernel
-jupyterlite-pyodide-kernel==0.2.0a2
-jupyterlite-p5-kernel==0.1.1
jupyterlab-language-pack-fr-FR
+jupyterlite-p5-kernel==0.1.1
+jupyterlite-pyodide-kernel==0.2.0a2
+notebook==7.0.6
theme-darcula==4.0.0
diff --git a/ui-tests/test/notebook.test.ts b/ui-tests/test/notebook.test.ts
index eca427bdc..938759717 100644
--- a/ui-tests/test/notebook.test.ts
+++ b/ui-tests/test/notebook.test.ts
@@ -143,3 +143,58 @@ test.describe('Notebook favicons', () => {
expect(finalFavicon).toEqual(favicon);
});
});
+
+test.describe('Switch between Notebook and JupyterLab', () => {
+ const NOTEBOOK = 'empty.ipynb';
+
+ test.use({
+ waitForApplication: async function ({ baseURL }, use, testInfo) {
+ const waitIsReady = async (page): Promise => {
+ const selector = page.url().includes('lab')
+ ? `text=${NOTEBOOK}`
+ : '.jp-NotebookPanel';
+ await page.waitForSelector(selector);
+ };
+ await use(waitIsReady);
+ },
+ });
+
+ test('Open the notebook file browser', async ({ page }) => {
+ await page.goto('lab/index.html');
+
+ const [treePage] = await Promise.all([
+ page.waitForEvent('popup'),
+ page.menu.clickMenuItem('Help>Launch Jupyter Notebook File Browser'),
+ ]);
+
+ await treePage.waitForSelector('#filebrowser');
+
+ expect(treePage.url()).toContain('tree');
+ });
+
+ test('Open a notebook with JupyterLab', async ({ page }) => {
+ await page.goto(`notebooks/index.html?path=${NOTEBOOK}`);
+
+ const [labPage] = await Promise.all([
+ page.waitForEvent('popup'),
+ page.locator('.jp-ToolbarButtonComponent >> text=JupyterLab').first().click(),
+ ]);
+
+ await labPage.waitForSelector('.jp-NotebookPanel');
+
+ expect(labPage.url()).toContain('lab');
+ });
+
+ test('Open a notebook with Notebook', async ({ page }) => {
+ await page.goto(`lab/index.html?path=${NOTEBOOK}`);
+
+ const [notebookPage] = await Promise.all([
+ page.waitForEvent('popup'),
+ page.locator('.jp-ToolbarButtonComponent >> text=Notebook').first().click(),
+ ]);
+
+ await notebookPage.waitForSelector('.jp-NotebookPanel');
+
+ expect(notebookPage.url()).toContain('notebooks');
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index bcf66b33a..d8575b7e6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4118,6 +4118,7 @@ __metadata:
"@jupyterlite/iframe-extension": ^0.2.0-rc.0
"@jupyterlite/licenses": ^0.2.0-rc.0
"@jupyterlite/localforage": ^0.2.0-rc.0
+ "@jupyterlite/notebook-application-extension": ^0.2.0-rc.0
"@jupyterlite/server": ^0.2.0-rc.0
"@jupyterlite/server-extension": ^0.2.0-rc.0
"@jupyterlite/types": ^0.2.0-rc.0