diff --git a/builder/index.js b/builder/index.js
index e5b1a8607..b6c2a20b9 100644
--- a/builder/index.js
+++ b/builder/index.js
@@ -67,7 +67,7 @@ async function main() {
const app = new App();
// TODO: formalize the way the set of initial extensions and plugins are specified
- const mods = [
+ let mods = [
require('@jupyterlab-classic/application-extension'),
require('@jupyterlab/apputils-extension').default.filter(({ id }) =>
[
@@ -80,36 +80,52 @@ async function main() {
require('@jupyterlab/codemirror-extension').default.filter(({ id }) =>
['@jupyterlab/codemirror-extension:services'].includes(id)
),
- require('@jupyterlab/completer-extension').default.filter(({ id }) =>
- [
- '@jupyterlab/completer-extension:manager',
- '@jupyterlab/completer-extension:notebooks'
- ].includes(id)
- ),
require('@jupyterlab/docmanager-extension').default.filter(({ id }) =>
['@jupyterlab/docmanager-extension:plugin'].includes(id)
),
require('@jupyterlab/mainmenu-extension'),
require('@jupyterlab/mathjax2-extension'),
- require('@jupyterlab/rendermime-extension'),
require('@jupyterlab/notebook-extension').default.filter(({ id }) =>
[
'@jupyterlab/notebook-extension:factory',
- '@jupyterlab/notebook-extension:widget-factory',
- '@jupyterlab/notebook-extension:tracker'
+ '@jupyterlab/notebook-extension:tracker',
+ '@jupyterlab/notebook-extension:widget-factory'
].includes(id)
),
+ require('@jupyterlab/rendermime-extension'),
require('@jupyterlab/shortcuts-extension'),
- require('@jupyterlab/tooltip-extension').default.filter(({ id }) =>
- [
- '@jupyterlab/tooltip-extension:manager',
- '@jupyterlab/tooltip-extension:notebooks'
- ].includes(id)
- ),
require('@jupyterlab/theme-light-extension'),
require('@jupyterlab/theme-dark-extension')
];
+ const page = PageConfig.getOption('classicPage');
+ if (page === 'tree') {
+ mods = mods.concat([
+ require('@jupyterlab-classic/filebrowser-extension').default.filter(
+ ({ id }) =>
+ [
+ '@jupyterlab-classic/filebrowser-extension:browser',
+ '@jupyterlab-classic/filebrowser-extension:factory'
+ ].includes(id)
+ )
+ ]);
+ } else if (page === 'notebooks') {
+ mods = mods.concat([
+ require('@jupyterlab/completer-extension').default.filter(({ id }) =>
+ [
+ '@jupyterlab/completer-extension:manager',
+ '@jupyterlab/completer-extension:notebooks'
+ ].includes(id)
+ ),
+ require('@jupyterlab/tooltip-extension').default.filter(({ id }) =>
+ [
+ '@jupyterlab/tooltip-extension:manager',
+ '@jupyterlab/tooltip-extension:notebooks'
+ ].includes(id)
+ )
+ ]);
+ }
+
const extension_data = JSON.parse(
PageConfig.getOption('federated_extensions')
);
diff --git a/builder/package.json b/builder/package.json
index be0888ab5..f863269b2 100644
--- a/builder/package.json
+++ b/builder/package.json
@@ -12,6 +12,7 @@
"dependencies": {
"@jupyterlab-classic/application": "^0.1.0",
"@jupyterlab-classic/application-extension": "^0.1.0",
+ "@jupyterlab-classic/filebrowser-extension": "^0.1.0",
"@jupyterlab-classic/ui-components": "^0.1.0",
"@jupyterlab/apputils-extension": "^3.0.0-rc.12",
"@jupyterlab/codemirror-extension": "^3.0.0-rc.12",
diff --git a/builder/style.css b/builder/style.css
index 9d04b781c..3a3c0da66 100644
--- a/builder/style.css
+++ b/builder/style.css
@@ -1,4 +1,5 @@
@import url('~@jupyterlab-classic/application-extension/style/index.css');
+@import url('~@jupyterlab-classic/filebrowser-extension/style/index.css');
@import url('~@jupyterlab-classic/ui-components/style/index.css');
/* TODO: check is the the extension package can be used directly */
diff --git a/jupyterlab_classic/app.py b/jupyterlab_classic/app.py
index 91a84eb61..da4d5c7f3 100644
--- a/jupyterlab_classic/app.py
+++ b/jupyterlab_classic/app.py
@@ -24,9 +24,8 @@ app_dir = get_app_dir()
version = __version__
-class ClassicHandler(ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler):
- @web.authenticated
- def get(self, path=None):
+class ClassicPageConfigMixin:
+ def get_page_config(self):
config = LabConfig()
app = self.extensionapp
base_url = self.settings.get("base_url")
@@ -71,7 +70,27 @@ class ClassicHandler(ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterH
logger=self.log,
),
)
+ return page_config
+
+
+class ClassicTreeHandler(ClassicPageConfigMixin, ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler):
+ @web.authenticated
+ def get(self, path=None):
+ page_config = self.get_page_config()
+ return self.write(
+ self.render_template(
+ "tree.html",
+ base_url=self.base_url,
+ token=self.settings["token"],
+ page_config=page_config,
+ )
+ )
+
+class ClassicNotebookHandler(ClassicPageConfigMixin, ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler):
+ @web.authenticated
+ def get(self, path=None):
+ page_config = self.get_page_config()
return self.write(
self.render_template(
"notebooks.html",
@@ -97,7 +116,8 @@ class ClassicApp(NBClassicConfigShimMixin, LabServerApp):
def initialize_handlers(self):
super().initialize_handlers()
- self.handlers.append(("/classic/notebooks(.*)", ClassicHandler))
+ self.handlers.append(("/classic/tree(.*)", ClassicTreeHandler))
+ self.handlers.append(("/classic/notebooks(.*)", ClassicNotebookHandler))
def initialize_templates(self):
super().initialize_templates()
diff --git a/jupyterlab_classic/templates/notebooks.html b/jupyterlab_classic/templates/notebooks.html
index e59e3e604..6f794eba6 100644
--- a/jupyterlab_classic/templates/notebooks.html
+++ b/jupyterlab_classic/templates/notebooks.html
@@ -13,6 +13,9 @@
{# Set a dummy variable - we just want the side effect of the update. #}
{% set _ = page_config_full.update(baseUrl=base_url, wsUrl=ws_url) %}
+ {# Sentinel value to say that we are on the tree page #}
+ {% set _ = page_config_full.update(classicPage='notebooks') %}
+
diff --git a/jupyterlab_classic/templates/tree.html b/jupyterlab_classic/templates/tree.html
new file mode 100644
index 000000000..f8abf0f27
--- /dev/null
+++ b/jupyterlab_classic/templates/tree.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ {{page_config['appName'] | e}}
+
+
+
+ {# Copy so we do not modify the page_config with updates. #}
+ {% set page_config_full = page_config.copy() %}
+
+ {# Set a dummy variable - we just want the side effect of the update. #}
+ {% set _ = page_config_full.update(baseUrl=base_url, wsUrl=ws_url) %}
+
+ {# Sentinel value to say that we are on the tree page #}
+ {% set _ = page_config_full.update(classicPage='tree') %}
+
+
+
+
+
+
+
+
diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts
index 6caa25dd8..c1790e76e 100644
--- a/packages/application/src/shell.ts
+++ b/packages/application/src/shell.ts
@@ -102,10 +102,10 @@ export class ClassicShell extends Widget implements JupyterFrontEnd.IShell {
if (area === 'menu') {
return this._menuHandler.addWidget(widget, rank);
}
- if (this._main.widgets.length > 0) {
- // do not add the widget if there is already one
- return;
- }
+ // TODO: better handle this
+ this._main.widgets.forEach(w => {
+ w.close();
+ });
this._main.addWidget(widget);
this._main.update();
this._currentChanged.emit(void 0);
diff --git a/packages/filebrowser-extension/package.json b/packages/filebrowser-extension/package.json
new file mode 100644
index 000000000..0ba9c4ccc
--- /dev/null
+++ b/packages/filebrowser-extension/package.json
@@ -0,0 +1,67 @@
+{
+ "name": "@jupyterlab-classic/filebrowser-extension",
+ "version": "0.1.0",
+ "description": "JupyterLab Classic - File browser Extension",
+ "homepage": "https://github.com/jtpio/jupyterlab-classic",
+ "bugs": {
+ "url": "https://github.com/jtpio/jupyterlab-classic/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/jtpio/jupyterlab-classic.git"
+ },
+ "license": "BSD-3-Clause",
+ "author": "Project Jupyter",
+ "sideEffects": [
+ "style/**/*.css"
+ ],
+ "main": "lib/index.js",
+ "types": "lib/index.d.ts",
+ "style": "style/index.css",
+ "directories": {
+ "lib": "lib/"
+ },
+ "files": [
+ "lib/*.d.ts",
+ "lib/*.js.map",
+ "lib/*.js",
+ "schema/*.json",
+ "style/**/*.css"
+ ],
+ "scripts": {
+ "build": "tsc -b",
+ "clean": "rimraf lib && rimraf tsconfig.tsbuildinfo",
+ "docs": "typedoc src",
+ "prepublishOnly": "npm run build",
+ "watch": "tsc -b --watch"
+ },
+ "dependencies": {
+ "@jupyterlab-classic/application": "0.1.0",
+ "@jupyterlab/application": "^3.0.0-rc.13",
+ "@jupyterlab/apputils": "^3.0.0-rc.13",
+ "@jupyterlab/coreutils": "^5.0.0-rc.13",
+ "@jupyterlab/docmanager": "^3.0.0-rc.13",
+ "@jupyterlab/filebrowser": "^3.0.0-rc.13",
+ "@jupyterlab/launcher": "^3.0.0-rc.13",
+ "@jupyterlab/mainmenu": "^3.0.0-rc.13",
+ "@jupyterlab/services": "^6.0.0-rc.13",
+ "@jupyterlab/settingregistry": "^3.0.0-rc.13",
+ "@jupyterlab/statedb": "^3.0.0-rc.13",
+ "@jupyterlab/translation": "^3.0.0-rc.13",
+ "@jupyterlab/ui-components": "^3.0.0-rc.13",
+ "@lumino/algorithm": "^1.3.3",
+ "@lumino/commands": "^1.12.0",
+ "@lumino/messaging": "^1.4.3",
+ "@lumino/widgets": "^1.16.1"
+ },
+ "devDependencies": {
+ "rimraf": "~3.0.0",
+ "typescript": "~4.0.2"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "jupyterlab": {
+ "extension": true
+ }
+}
diff --git a/packages/filebrowser-extension/src/index.ts b/packages/filebrowser-extension/src/index.ts
new file mode 100644
index 000000000..ac10b6c85
--- /dev/null
+++ b/packages/filebrowser-extension/src/index.ts
@@ -0,0 +1,1170 @@
+// Vendored from https://github.com/jupyterlab/jupyterlab/blob/b9511a52557d6ae56e597d0f48392993bdc432ae/packages/filebrowser-extension/src/index.ts
+// Because of ILabShell being a required dependency in some of the plugins
+// TODO: remove this package when ILabShell is optional upstream
+
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+ ILabShell,
+ ILayoutRestorer,
+ ITreePathUpdater,
+ IRouter,
+ JupyterFrontEnd,
+ JupyterFrontEndPlugin
+} from '@jupyterlab/application';
+
+import {
+ Clipboard,
+ MainAreaWidget,
+ ToolbarButton,
+ WidgetTracker,
+ ICommandPalette,
+ InputDialog,
+ showErrorMessage,
+ DOMUtils
+} from '@jupyterlab/apputils';
+
+import { PageConfig, PathExt } from '@jupyterlab/coreutils';
+
+import { IDocumentManager } from '@jupyterlab/docmanager';
+
+import {
+ FilterFileBrowserModel,
+ FileBrowser,
+ IFileBrowserFactory
+} from '@jupyterlab/filebrowser';
+
+import { Launcher } from '@jupyterlab/launcher';
+
+import { IMainMenu } from '@jupyterlab/mainmenu';
+
+import { Contents } from '@jupyterlab/services';
+
+import { ISettingRegistry } from '@jupyterlab/settingregistry';
+
+import { IStateDB } from '@jupyterlab/statedb';
+
+import { ITranslator } from '@jupyterlab/translation';
+
+import {
+ addIcon,
+ closeIcon,
+ copyIcon,
+ cutIcon,
+ downloadIcon,
+ editIcon,
+ fileIcon,
+ folderIcon,
+ markdownIcon,
+ newFolderIcon,
+ pasteIcon,
+ stopIcon,
+ textEditorIcon
+} from '@jupyterlab/ui-components';
+
+import { IIterator, map, reduce, toArray, find } from '@lumino/algorithm';
+
+import { CommandRegistry } from '@lumino/commands';
+
+import { Message } from '@lumino/messaging';
+
+import { Menu } from '@lumino/widgets';
+
+/**
+ * The command IDs used by the file browser plugin.
+ */
+namespace CommandIDs {
+ export const copy = 'filebrowser:copy';
+
+ export const copyDownloadLink = 'filebrowser:copy-download-link';
+
+ // For main browser only.
+ export const createLauncher = 'filebrowser:create-main-launcher';
+
+ export const cut = 'filebrowser:cut';
+
+ export const del = 'filebrowser:delete';
+
+ export const download = 'filebrowser:download';
+
+ export const duplicate = 'filebrowser:duplicate';
+
+ // For main browser only.
+ export const hideBrowser = 'filebrowser:hide-main';
+
+ export const goToPath = 'filebrowser:go-to-path';
+
+ export const openPath = 'filebrowser:open-path';
+
+ export const open = 'filebrowser:open';
+
+ export const openBrowserTab = 'filebrowser:open-browser-tab';
+
+ export const paste = 'filebrowser:paste';
+
+ export const createNewDirectory = 'filebrowser:create-new-directory';
+
+ export const createNewFile = 'filebrowser:create-new-file';
+
+ export const createNewMarkdownFile = 'filebrowser:create-new-markdown-file';
+
+ export const rename = 'filebrowser:rename';
+
+ // For main browser only.
+ export const share = 'filebrowser:share-main';
+
+ // For main browser only.
+ export const copyPath = 'filebrowser:copy-path';
+
+ export const showBrowser = 'filebrowser:activate';
+
+ export const shutdown = 'filebrowser:shutdown';
+
+ // For main browser only.
+ export const toggleBrowser = 'filebrowser:toggle-main';
+
+ export const toggleNavigateToCurrentDirectory =
+ 'filebrowser:toggle-navigate-to-current-directory';
+
+ export const toggleLastModified = 'filebrowser:toggle-last-modified';
+
+ export const search = 'filebrowser:search';
+}
+
+/**
+ * The default file browser extension.
+ */
+const browser: JupyterFrontEndPlugin = {
+ activate: activateBrowser,
+ id: '@jupyterlab-classic/filebrowser-extension:browser',
+ requires: [
+ IFileBrowserFactory,
+ IDocumentManager,
+ ISettingRegistry,
+ ITranslator
+ ],
+ optional: [
+ ILabShell,
+ ICommandPalette,
+ IMainMenu,
+ ILayoutRestorer,
+ ITreePathUpdater
+ ],
+ autoStart: true
+};
+
+/**
+ * The default file browser factory provider.
+ */
+const factory: JupyterFrontEndPlugin = {
+ activate: activateFactory,
+ id: '@jupyterlab-classic/filebrowser-extension:factory',
+ provides: IFileBrowserFactory,
+ requires: [IDocumentManager, ITranslator],
+ optional: [ILabShell, IStateDB, IRouter, JupyterFrontEnd.ITreeResolver]
+};
+
+/**
+ * The file browser namespace token.
+ */
+const namespace = 'filebrowser';
+
+/**
+ * Export the plugins as default.
+ */
+const plugins: JupyterFrontEndPlugin[] = [factory, browser];
+export default plugins;
+
+/**
+ * Activate the file browser factory provider.
+ */
+async function activateFactory(
+ app: JupyterFrontEnd,
+ docManager: IDocumentManager,
+ translator: ITranslator,
+ labShell: ILabShell | null,
+ state: IStateDB | null,
+ router: IRouter | null,
+ tree: JupyterFrontEnd.ITreeResolver | null
+): Promise {
+ const trans = translator.load('jupyterlab');
+ const { commands } = app;
+ const tracker = new WidgetTracker({ namespace });
+ const createFileBrowser = (
+ id: string,
+ options: IFileBrowserFactory.IOptions = {}
+ ) => {
+ const model = new FilterFileBrowserModel({
+ translator: translator,
+ auto: options.auto ?? true,
+ manager: docManager,
+ driveName: options.driveName || '',
+ refreshInterval: options.refreshInterval,
+ state:
+ options.state === null ? undefined : options.state || state || undefined
+ });
+ const restore = options.restore;
+ const widget = new FileBrowser({ id, model, restore, translator });
+
+ // Add a launcher toolbar item.
+ if (labShell) {
+ const launcher = new ToolbarButton({
+ icon: addIcon,
+ onClick: () => {
+ if (
+ labShell.mode === 'multiple-document' &&
+ commands.hasCommand('launcher:create')
+ ) {
+ return Private.createLauncher(commands, widget);
+ } else {
+ const newUrl = PageConfig.getUrl({
+ mode: labShell.mode,
+ workspace: PageConfig.defaultWorkspace,
+ treePath: model.path
+ });
+ window.open(newUrl, '_blank');
+ }
+ },
+ tooltip: trans.__('New Launcher'),
+ actualOnClick: true
+ });
+ widget.toolbar.insertItem(0, 'launch', launcher);
+ }
+
+ // Track the newly created file browser.
+ void tracker.add(widget);
+
+ return widget;
+ };
+
+ // Manually restore and load the default file browser.
+ const defaultBrowser = createFileBrowser('filebrowser', {
+ auto: false,
+ restore: false
+ });
+ void Private.restoreBrowser(defaultBrowser, commands, router, tree);
+
+ return { createFileBrowser, defaultBrowser, tracker };
+}
+
+/**
+ * Activate the default file browser in the sidebar.
+ */
+function activateBrowser(
+ app: JupyterFrontEnd,
+ factory: IFileBrowserFactory,
+ docManager: IDocumentManager,
+ settingRegistry: ISettingRegistry,
+ translator: ITranslator,
+ labShell: ILabShell | null,
+ commandPalette: ICommandPalette | null,
+ mainMenu: IMainMenu | null,
+ restorer: ILayoutRestorer | null,
+ treePathUpdater: ITreePathUpdater | null
+): void {
+ const trans = translator.load('jupyterlab');
+ const browser = factory.defaultBrowser;
+ const { commands } = app;
+
+ // Let the application restorer track the primary file browser (that is
+ // automatically created) for restoration of application state (e.g. setting
+ // the file browser as the current side bar widget).
+ //
+ // All other file browsers created by using the factory function are
+ // responsible for their own restoration behavior, if any.
+ if (restorer) {
+ restorer.add(browser, namespace);
+ }
+
+ addCommands(
+ app,
+ factory,
+ settingRegistry,
+ translator,
+ labShell,
+ commandPalette,
+ mainMenu
+ );
+
+ browser.title.icon = folderIcon;
+ // Show the current file browser shortcut in its title.
+ const updateBrowserTitle = () => {
+ const binding = find(
+ app.commands.keyBindings,
+ b => b.command === CommandIDs.toggleBrowser
+ );
+ if (binding) {
+ const ks = CommandRegistry.formatKeystroke(binding.keys.join(' '));
+ browser.title.caption = trans.__('File Browser (%1)', ks);
+ } else {
+ browser.title.caption = trans.__('File Browser');
+ }
+ };
+ updateBrowserTitle();
+ app.commands.keyBindingChanged.connect(() => {
+ updateBrowserTitle();
+ });
+
+ app.shell.add(browser, 'main', { rank: 100 });
+
+ // If the layout is a fresh session without saved data and not in single document
+ // mode, open file browser.
+ if (labShell) {
+ void labShell.restored.then(layout => {
+ if (layout.fresh && labShell.mode !== 'single-document') {
+ void commands.execute(CommandIDs.showBrowser, void 0);
+ }
+ });
+ }
+
+ void Promise.all([app.restored, browser.model.restored]).then(() => {
+ function maybeCreate() {
+ // Create a launcher if there are no open items.
+ if (
+ toArray(app.shell.widgets('main')).length === 0 &&
+ commands.hasCommand('launcher:create')
+ ) {
+ void Private.createLauncher(commands, browser);
+ }
+ }
+
+ // When layout is modified, create a launcher if there are no open items.
+ if (labShell) {
+ labShell.layoutModified.connect(() => {
+ maybeCreate();
+ });
+ }
+
+ let navigateToCurrentDirectory = false;
+ let useFuzzyFilter = true;
+
+ void settingRegistry
+ .load('@jupyterlab/filebrowser-extension:browser')
+ .then(settings => {
+ settings.changed.connect(settings => {
+ navigateToCurrentDirectory = settings.get(
+ 'navigateToCurrentDirectory'
+ ).composite as boolean;
+ browser.navigateToCurrentDirectory = navigateToCurrentDirectory;
+ });
+ navigateToCurrentDirectory = settings.get('navigateToCurrentDirectory')
+ .composite as boolean;
+ browser.navigateToCurrentDirectory = navigateToCurrentDirectory;
+ settings.changed.connect(settings => {
+ useFuzzyFilter = settings.get('useFuzzyFilter').composite as boolean;
+ browser.useFuzzyFilter = useFuzzyFilter;
+ });
+ useFuzzyFilter = settings.get('useFuzzyFilter').composite as boolean;
+ browser.useFuzzyFilter = useFuzzyFilter;
+ });
+
+ // Whether to automatically navigate to a document's current directory
+ if (labShell) {
+ labShell.currentChanged.connect(async (_, change) => {
+ if (navigateToCurrentDirectory && change.newValue) {
+ const { newValue } = change;
+ const context = docManager.contextForWidget(newValue);
+ if (context) {
+ const { path } = context;
+ try {
+ await Private.navigateToPath(path, factory, translator);
+ labShell.currentWidget?.activate();
+ } catch (reason) {
+ console.warn(
+ `${CommandIDs.goToPath} failed to open: ${path}`,
+ reason
+ );
+ }
+ }
+ }
+ });
+ }
+
+ if (treePathUpdater) {
+ browser.model.pathChanged.connect((sender, args) => {
+ treePathUpdater(args.newValue);
+ });
+ }
+
+ maybeCreate();
+ });
+}
+
+/**
+ * Add the main file browser commands to the application's command registry.
+ */
+function addCommands(
+ app: JupyterFrontEnd,
+ factory: IFileBrowserFactory,
+ settingRegistry: ISettingRegistry,
+ translator: ITranslator,
+ labShell: ILabShell | null,
+ commandPalette: ICommandPalette | null,
+ mainMenu: IMainMenu | null
+): void {
+ const trans = translator.load('jupyterlab');
+ const { docRegistry: registry, commands } = app;
+ const { defaultBrowser: browser, tracker } = factory;
+
+ commands.addCommand(CommandIDs.del, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+
+ if (widget) {
+ return widget.delete();
+ }
+ },
+ icon: closeIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Delete'),
+ mnemonic: 0
+ });
+
+ commands.addCommand(CommandIDs.copy, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+
+ if (widget) {
+ return widget.copy();
+ }
+ },
+ icon: copyIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Copy'),
+ mnemonic: 0
+ });
+
+ commands.addCommand(CommandIDs.cut, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+
+ if (widget) {
+ return widget.cut();
+ }
+ },
+ icon: cutIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Cut')
+ });
+
+ commands.addCommand(CommandIDs.download, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+
+ if (widget) {
+ return widget.download();
+ }
+ },
+ icon: downloadIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Download')
+ });
+
+ commands.addCommand(CommandIDs.duplicate, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+
+ if (widget) {
+ return widget.duplicate();
+ }
+ },
+ icon: copyIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Duplicate')
+ });
+
+ if (labShell) {
+ commands.addCommand(CommandIDs.hideBrowser, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+ if (widget && !widget.isHidden) {
+ labShell.collapseLeft();
+ }
+ }
+ });
+ }
+
+ commands.addCommand(CommandIDs.goToPath, {
+ execute: async args => {
+ const path = (args.path as string) || '';
+ const showBrowser = !(args?.dontShowBrowser ?? false);
+ try {
+ const item = await Private.navigateToPath(path, factory, translator);
+ if (item.type !== 'directory' && showBrowser) {
+ const browserForPath = Private.getBrowserForPath(path, factory);
+ if (browserForPath) {
+ browserForPath.clearSelectedItems();
+ const parts = path.split('/');
+ const name = parts[parts.length - 1];
+ if (name) {
+ await browserForPath.selectItemByName(name);
+ }
+ }
+ }
+ } catch (reason) {
+ console.warn(`${CommandIDs.goToPath} failed to go to: ${path}`, reason);
+ }
+ if (showBrowser) {
+ return commands.execute(CommandIDs.showBrowser, { path });
+ }
+ }
+ });
+
+ commands.addCommand(CommandIDs.openPath, {
+ label: args =>
+ args.path ? trans.__('Open %1', args.path) : trans.__('Open from Path…'),
+ caption: args =>
+ args.path ? trans.__('Open %1', args.path) : trans.__('Open from path'),
+ execute: async args => {
+ let path: string | undefined;
+ if (args?.path) {
+ path = args.path as string;
+ } else {
+ path =
+ (
+ await InputDialog.getText({
+ label: trans.__('Path'),
+ placeholder: '/path/relative/to/jlab/root',
+ title: trans.__('Open Path'),
+ okLabel: trans.__('Open')
+ })
+ ).value ?? undefined;
+ }
+ if (!path) {
+ return;
+ }
+ try {
+ const trailingSlash = path !== '/' && path.endsWith('/');
+ if (trailingSlash) {
+ // The normal contents service errors on paths ending in slash
+ path = path.slice(0, path.length - 1);
+ }
+ const browserForPath = Private.getBrowserForPath(path, factory)!;
+ const { services } = browserForPath.model.manager;
+ const item = await services.contents.get(path, {
+ content: false
+ });
+ if (trailingSlash && item.type !== 'directory') {
+ throw new Error(`Path ${path}/ is not a directory`);
+ }
+ await commands.execute(CommandIDs.goToPath, {
+ path,
+ dontShowBrowser: args.dontShowBrowser
+ });
+ if (item.type === 'directory') {
+ return;
+ }
+ return commands.execute('docmanager:open', { path });
+ } catch (reason) {
+ if (reason.response && reason.response.status === 404) {
+ reason.message = trans.__('Could not find path: %1', path);
+ }
+ return showErrorMessage(trans.__('Cannot open'), reason);
+ }
+ }
+ });
+ // Add the openPath command to the command palette
+ if (commandPalette) {
+ commandPalette.addItem({
+ command: CommandIDs.openPath,
+ category: trans.__('File Operations')
+ });
+ }
+
+ commands.addCommand(CommandIDs.open, {
+ execute: args => {
+ const factory = (args['factory'] as string) || void 0;
+ const widget = tracker.currentWidget;
+
+ if (!widget) {
+ return;
+ }
+
+ const { contents } = widget.model.manager.services;
+ return Promise.all(
+ toArray(
+ map(widget.selectedItems(), item => {
+ if (item.type === 'directory') {
+ const localPath = contents.localPath(item.path);
+ return widget.model.cd(`/${localPath}`);
+ }
+
+ return commands.execute('docmanager:open', {
+ factory: factory,
+ path: item.path
+ });
+ })
+ )
+ );
+ },
+ icon: args => {
+ const factory = (args['factory'] as string) || void 0;
+ if (factory) {
+ // if an explicit factory is passed...
+ const ft = registry.getFileType(factory);
+ // ...set an icon if the factory name corresponds to a file type name...
+ // ...or leave the icon blank
+ return ft?.icon?.bindprops({ stylesheet: 'menuItem' });
+ } else {
+ return folderIcon.bindprops({ stylesheet: 'menuItem' });
+ }
+ },
+ // FIXME-TRANS: Is this localizable?
+ label: args =>
+ (args['label'] || args['factory'] || trans.__('Open')) as string,
+ mnemonic: 0
+ });
+
+ commands.addCommand(CommandIDs.openBrowserTab, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+
+ if (!widget) {
+ return;
+ }
+
+ return Promise.all(
+ toArray(
+ map(widget.selectedItems(), item => {
+ return commands.execute('docmanager:open-browser-tab', {
+ path: item.path
+ });
+ })
+ )
+ );
+ },
+ icon: addIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Open in New Browser Tab'),
+ mnemonic: 0
+ });
+
+ commands.addCommand(CommandIDs.copyDownloadLink, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+ if (!widget) {
+ return;
+ }
+
+ return widget.model.manager.services.contents
+ .getDownloadUrl(widget.selectedItems().next()!.path)
+ .then(url => {
+ Clipboard.copyToSystem(url);
+ });
+ },
+ icon: copyIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Copy Download Link'),
+ mnemonic: 0
+ });
+
+ commands.addCommand(CommandIDs.paste, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+
+ if (widget) {
+ return widget.paste();
+ }
+ },
+ icon: pasteIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Paste'),
+ mnemonic: 0
+ });
+
+ commands.addCommand(CommandIDs.createNewDirectory, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+
+ if (widget) {
+ return widget.createNewDirectory();
+ }
+ },
+ icon: newFolderIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('New Folder')
+ });
+
+ commands.addCommand(CommandIDs.createNewFile, {
+ execute: () => {
+ const {
+ model: { path }
+ } = browser;
+ void commands.execute('docmanager:new-untitled', {
+ path,
+ type: 'file',
+ ext: 'txt'
+ });
+ },
+ icon: textEditorIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('New File')
+ });
+
+ commands.addCommand(CommandIDs.createNewMarkdownFile, {
+ execute: () => {
+ const {
+ model: { path }
+ } = browser;
+ void commands.execute('docmanager:new-untitled', {
+ path,
+ type: 'file',
+ ext: 'md'
+ });
+ },
+ icon: markdownIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('New Markdown File')
+ });
+
+ commands.addCommand(CommandIDs.rename, {
+ execute: args => {
+ const widget = tracker.currentWidget;
+
+ if (widget) {
+ return widget.rename();
+ }
+ },
+ icon: editIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Rename'),
+ mnemonic: 0
+ });
+
+ commands.addCommand(CommandIDs.copyPath, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+ if (!widget) {
+ return;
+ }
+ const item = widget.selectedItems().next();
+ if (!item) {
+ return;
+ }
+
+ Clipboard.copyToSystem(item.path);
+ },
+ isVisible: () =>
+ !!tracker.currentWidget &&
+ tracker.currentWidget.selectedItems().next !== undefined,
+ icon: fileIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Copy Path')
+ });
+
+ commands.addCommand(CommandIDs.showBrowser, {
+ execute: args => {
+ const path = (args.path as string) || '';
+ const browserForPath = Private.getBrowserForPath(path, factory);
+
+ // Check for browser not found
+ if (!browserForPath) {
+ return;
+ }
+ // Shortcut if we are using the main file browser
+ if (browser === browserForPath) {
+ app.shell.activateById(browser.id);
+ return;
+ } else {
+ const areas: ILabShell.Area[] = ['left', 'right'];
+ for (const area of areas) {
+ const it = app.shell.widgets(area);
+ let widget = it.next();
+ while (widget) {
+ if (widget.contains(browserForPath)) {
+ app.shell.activateById(widget.id);
+ return;
+ }
+ widget = it.next();
+ }
+ }
+ }
+ }
+ });
+
+ commands.addCommand(CommandIDs.shutdown, {
+ execute: () => {
+ const widget = tracker.currentWidget;
+
+ if (widget) {
+ return widget.shutdownKernels();
+ }
+ },
+ icon: stopIcon.bindprops({ stylesheet: 'menuItem' }),
+ label: trans.__('Shut Down Kernel')
+ });
+
+ commands.addCommand(CommandIDs.toggleBrowser, {
+ execute: () => {
+ if (browser.isHidden) {
+ return commands.execute(CommandIDs.showBrowser, void 0);
+ }
+
+ return commands.execute(CommandIDs.hideBrowser, void 0);
+ }
+ });
+
+ commands.addCommand(CommandIDs.createLauncher, {
+ label: trans.__('New Launcher'),
+ execute: () => Private.createLauncher(commands, browser)
+ });
+
+ commands.addCommand(CommandIDs.toggleNavigateToCurrentDirectory, {
+ label: trans.__('Show Active File in File Browser'),
+ isToggled: () => browser.navigateToCurrentDirectory,
+ execute: () => {
+ const value = !browser.navigateToCurrentDirectory;
+ const key = 'navigateToCurrentDirectory';
+ return settingRegistry
+ .set('@jupyterlab/filebrowser-extension:browser', key, value)
+ .catch((reason: Error) => {
+ console.error('Failed to set navigateToCurrentDirectory setting');
+ });
+ }
+ });
+
+ commands.addCommand(CommandIDs.toggleLastModified, {
+ label: trans.__('Toggle Last Modified Column'),
+ execute: () => {
+ const header = DOMUtils.findElement(document.body, 'jp-id-modified');
+ const column = DOMUtils.findElements(
+ document.body,
+ 'jp-DirListing-itemModified'
+ );
+ if (header.classList.contains('jp-LastModified-hidden')) {
+ header.classList.remove('jp-LastModified-hidden');
+ for (let i = 0; i < column.length; i++) {
+ column[i].classList.remove('jp-LastModified-hidden');
+ }
+ } else {
+ header.classList.add('jp-LastModified-hidden');
+ for (let i = 0; i < column.length; i++) {
+ column[i].classList.add('jp-LastModified-hidden');
+ }
+ }
+ }
+ });
+
+ commands.addCommand(CommandIDs.search, {
+ label: trans.__('Search on File Names'),
+ execute: () => alert('search')
+ });
+
+ if (mainMenu) {
+ mainMenu.settingsMenu.addGroup(
+ [{ command: CommandIDs.toggleNavigateToCurrentDirectory }],
+ 5
+ );
+ }
+
+ if (commandPalette) {
+ commandPalette.addItem({
+ command: CommandIDs.toggleNavigateToCurrentDirectory,
+ category: trans.__('File Operations')
+ });
+ }
+
+ /**
+ * A menu widget that dynamically populates with different widget factories
+ * based on current filebrowser selection.
+ */
+ class OpenWithMenu extends Menu {
+ protected onBeforeAttach(msg: Message): void {
+ // clear the current menu items
+ this.clearItems();
+
+ // get the widget factories that could be used to open all of the items
+ // in the current filebrowser selection
+ const factories = tracker.currentWidget
+ ? OpenWithMenu._intersection(
+ map(tracker.currentWidget.selectedItems(), i => {
+ return OpenWithMenu._getFactories(i);
+ })
+ )
+ : undefined;
+
+ if (factories) {
+ // make new menu items from the widget factories
+ factories.forEach(factory => {
+ this.addItem({
+ args: { factory: factory },
+ command: CommandIDs.open
+ });
+ });
+ }
+
+ super.onBeforeAttach(msg);
+ }
+
+ static _getFactories(item: Contents.IModel): Array {
+ const factories = registry
+ .preferredWidgetFactories(item.path)
+ .map(f => f.name);
+ const notebookFactory = registry.getWidgetFactory('notebook')?.name;
+ if (
+ notebookFactory &&
+ item.type === 'notebook' &&
+ factories.indexOf(notebookFactory) === -1
+ ) {
+ factories.unshift(notebookFactory);
+ }
+
+ return factories;
+ }
+
+ static _intersection(iter: IIterator>): Set | void {
+ // pop the first element of iter
+ const first = iter.next();
+ // first will be undefined if iter is empty
+ if (!first) {
+ return;
+ }
+
+ // "initialize" the intersection from first
+ const isect = new Set(first);
+ // reduce over the remaining elements of iter
+ return reduce(
+ iter,
+ (isect, subarr) => {
+ // filter out all elements not present in both isect and subarr,
+ // accumulate result in new set
+ return new Set(subarr.filter(x => isect.has(x)));
+ },
+ isect
+ );
+ }
+ }
+
+ // matches anywhere on filebrowser
+ const selectorContent = '.jp-DirListing-content';
+ // matches all filebrowser items
+ const selectorItem = '.jp-DirListing-item[data-isdir]';
+ // matches only non-directory items
+ const selectorNotDir = '.jp-DirListing-item[data-isdir="false"]';
+
+ // If the user did not click on any file, we still want to show paste and new folder,
+ // so target the content rather than an item.
+ app.contextMenu.addItem({
+ command: CommandIDs.createNewDirectory,
+ selector: selectorContent,
+ rank: 1
+ });
+
+ app.contextMenu.addItem({
+ command: CommandIDs.createNewFile,
+ selector: selectorContent,
+ rank: 2
+ });
+
+ app.contextMenu.addItem({
+ command: CommandIDs.createNewMarkdownFile,
+ selector: selectorContent,
+ rank: 3
+ });
+
+ app.contextMenu.addItem({
+ command: CommandIDs.paste,
+ selector: selectorContent,
+ rank: 4
+ });
+
+ app.contextMenu.addItem({
+ command: CommandIDs.open,
+ selector: selectorItem,
+ rank: 1
+ });
+
+ const openWith = new OpenWithMenu({ commands });
+ openWith.title.label = trans.__('Open With');
+ app.contextMenu.addItem({
+ type: 'submenu',
+ submenu: openWith,
+ selector: selectorNotDir,
+ rank: 2
+ });
+
+ app.contextMenu.addItem({
+ command: CommandIDs.openBrowserTab,
+ selector: selectorNotDir,
+ rank: 3
+ });
+
+ app.contextMenu.addItem({
+ command: CommandIDs.rename,
+ selector: selectorItem,
+ rank: 4
+ });
+ app.contextMenu.addItem({
+ command: CommandIDs.del,
+ selector: selectorItem,
+ rank: 5
+ });
+ app.contextMenu.addItem({
+ command: CommandIDs.cut,
+ selector: selectorItem,
+ rank: 6
+ });
+
+ app.contextMenu.addItem({
+ command: CommandIDs.copy,
+ selector: selectorNotDir,
+ rank: 7
+ });
+
+ app.contextMenu.addItem({
+ command: CommandIDs.duplicate,
+ selector: selectorNotDir,
+ rank: 8
+ });
+ app.contextMenu.addItem({
+ command: CommandIDs.download,
+ selector: selectorNotDir,
+ rank: 9
+ });
+ app.contextMenu.addItem({
+ command: CommandIDs.shutdown,
+ selector: selectorNotDir,
+ rank: 10
+ });
+
+ app.contextMenu.addItem({
+ command: CommandIDs.share,
+ selector: selectorItem,
+ rank: 11
+ });
+ app.contextMenu.addItem({
+ command: CommandIDs.copyPath,
+ selector: selectorItem,
+ rank: 12
+ });
+ app.contextMenu.addItem({
+ command: CommandIDs.copyDownloadLink,
+ selector: selectorNotDir,
+ rank: 13
+ });
+ app.contextMenu.addItem({
+ command: CommandIDs.toggleLastModified,
+ selector: '.jp-DirListing-header',
+ rank: 14
+ });
+}
+
+/**
+ * A namespace for private module data.
+ */
+namespace Private {
+ /**
+ * Create a launcher for a given filebrowser widget.
+ */
+ export function createLauncher(
+ commands: CommandRegistry,
+ browser: FileBrowser
+ ): Promise> {
+ const { model } = browser;
+
+ return commands
+ .execute('launcher:create', { cwd: model.path })
+ .then((launcher: MainAreaWidget) => {
+ model.pathChanged.connect(() => {
+ if (launcher.content) {
+ launcher.content.cwd = model.path;
+ }
+ }, launcher);
+ return launcher;
+ });
+ }
+
+ /**
+ * Get browser object given file path.
+ */
+ export function getBrowserForPath(
+ path: string,
+ factory: IFileBrowserFactory
+ ): FileBrowser | undefined {
+ const { defaultBrowser: browser, tracker } = factory;
+ const driveName = browser.model.manager.services.contents.driveName(path);
+
+ if (driveName) {
+ const browserForPath = tracker.find(
+ _path => _path.model.driveName === driveName
+ );
+
+ if (!browserForPath) {
+ // warn that no filebrowser could be found for this driveName
+ console.warn(
+ `${CommandIDs.goToPath} failed to find filebrowser for path: ${path}`
+ );
+ return;
+ }
+
+ return browserForPath;
+ }
+
+ // if driveName is empty, assume the main filebrowser
+ return browser;
+ }
+
+ /**
+ * Navigate to a path or the path containing a file.
+ */
+ export async function navigateToPath(
+ path: string,
+ factory: IFileBrowserFactory,
+ translator: ITranslator
+ ): Promise {
+ const trans = translator.load('jupyterlab');
+ const browserForPath = Private.getBrowserForPath(path, factory);
+ if (!browserForPath) {
+ throw new Error(trans.__('No browser for path'));
+ }
+ const { services } = browserForPath.model.manager;
+ const localPath = services.contents.localPath(path);
+
+ await services.ready;
+ const item = await services.contents.get(path, { content: false });
+ const { model } = browserForPath;
+ await model.restored;
+ if (item.type === 'directory') {
+ await model.cd(`/${localPath}`);
+ } else {
+ await model.cd(`/${PathExt.dirname(localPath)}`);
+ }
+ return item;
+ }
+
+ /**
+ * Restores file browser state and overrides state if tree resolver resolves.
+ */
+ export async function restoreBrowser(
+ browser: FileBrowser,
+ commands: CommandRegistry,
+ router: IRouter | null,
+ tree: JupyterFrontEnd.ITreeResolver | null
+ ): Promise {
+ const restoring = 'jp-mod-restoring';
+
+ browser.addClass(restoring);
+
+ if (!router) {
+ await browser.model.restore(browser.id);
+ await browser.model.refresh();
+ browser.removeClass(restoring);
+ return;
+ }
+
+ const listener = async () => {
+ router.routed.disconnect(listener);
+
+ const paths = await tree?.paths;
+ if (paths?.file || paths?.browser) {
+ // Restore the model without populating it.
+ await browser.model.restore(browser.id, false);
+ if (paths.file) {
+ await commands.execute(CommandIDs.openPath, {
+ path: paths.file,
+ dontShowBrowser: true
+ });
+ }
+ if (paths.browser) {
+ await commands.execute(CommandIDs.openPath, {
+ path: paths.browser,
+ dontShowBrowser: true
+ });
+ }
+ } else {
+ await browser.model.restore(browser.id);
+ await browser.model.refresh();
+ }
+ browser.removeClass(restoring);
+ };
+ router.routed.connect(listener);
+ }
+}
diff --git a/packages/filebrowser-extension/style/base.css b/packages/filebrowser-extension/style/base.css
new file mode 100644
index 000000000..c0b120aa8
--- /dev/null
+++ b/packages/filebrowser-extension/style/base.css
@@ -0,0 +1,3 @@
+.jp-FileBrowser {
+ height: 100%;
+}
diff --git a/packages/filebrowser-extension/style/index.css b/packages/filebrowser-extension/style/index.css
new file mode 100644
index 000000000..c6a335ca5
--- /dev/null
+++ b/packages/filebrowser-extension/style/index.css
@@ -0,0 +1,3 @@
+@import url('~@jupyterlab/filebrowser/style/index.css');
+
+@import url('./base.css');
diff --git a/packages/filebrowser-extension/tsconfig.json b/packages/filebrowser-extension/tsconfig.json
new file mode 100644
index 000000000..b223e1a1b
--- /dev/null
+++ b/packages/filebrowser-extension/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfigbase",
+ "compilerOptions": {
+ "outDir": "lib",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*"],
+ "references": [
+ {
+ "path": "../application"
+ }
+ ]
+}
diff --git a/yarn.lock b/yarn.lock
index 50cae548d..162a64f03 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1806,6 +1806,22 @@
"@lumino/widgets" "^1.16.1"
react "^17.0.1"
+"@jupyterlab/launcher@^3.0.0-rc.13":
+ version "3.0.0-rc.13"
+ resolved "https://registry.yarnpkg.com/@jupyterlab/launcher/-/launcher-3.0.0-rc.13.tgz#f128c1bbb0d23b44ed38a9ff08c4f7f6e8b4b2fd"
+ integrity sha512-Rh0tELQhHcxEUtsDPaNLA2GLOBFW9U5kXqrGXs8imLyDoxxfgwjugcfab79IltDWX6c6brTHFu6Uei9zaDwdmQ==
+ dependencies:
+ "@jupyterlab/apputils" "^3.0.0-rc.13"
+ "@jupyterlab/translation" "^3.0.0-rc.13"
+ "@jupyterlab/ui-components" "^3.0.0-rc.13"
+ "@lumino/algorithm" "^1.3.3"
+ "@lumino/commands" "^1.12.0"
+ "@lumino/coreutils" "^1.5.3"
+ "@lumino/disposable" "^1.4.3"
+ "@lumino/properties" "^1.2.3"
+ "@lumino/widgets" "^1.16.1"
+ react "^17.0.1"
+
"@jupyterlab/logconsole@^3.0.0-rc.12":
version "3.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@jupyterlab/logconsole/-/logconsole-3.0.0-rc.12.tgz#cb3b9e48577542bdeeb4221c17218b59909ce5cd"
@@ -1852,6 +1868,20 @@
"@lumino/disposable" "^1.4.3"
"@lumino/widgets" "^1.16.1"
+"@jupyterlab/mainmenu@^3.0.0-rc.13":
+ version "3.0.0-rc.13"
+ resolved "https://registry.yarnpkg.com/@jupyterlab/mainmenu/-/mainmenu-3.0.0-rc.13.tgz#2a56ebff92d052e79947753fdc5c1c6fe32ed816"
+ integrity sha512-lgNL6XZdmgFIifiePB6T62N3qiiDSaWNy3S60/smJnvys89oV5b5M3VIxFgR/JgV8Dg1lelFoyrE2zur3GJn/g==
+ dependencies:
+ "@jupyterlab/apputils" "^3.0.0-rc.13"
+ "@jupyterlab/services" "^6.0.0-rc.13"
+ "@jupyterlab/ui-components" "^3.0.0-rc.13"
+ "@lumino/algorithm" "^1.3.3"
+ "@lumino/commands" "^1.12.0"
+ "@lumino/coreutils" "^1.5.3"
+ "@lumino/disposable" "^1.4.3"
+ "@lumino/widgets" "^1.16.1"
+
"@jupyterlab/mathjax2-extension@^3.0.0-rc.12":
version "3.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@jupyterlab/mathjax2-extension/-/mathjax2-extension-3.0.0-rc.13.tgz#acdf4cea112e7d4b7ade3f56b3f4385e72d0bf9f"