From 95b39d037ea9b78f014103e01ee15b73605b2ce2 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 8 Dec 2020 16:46:37 +0100 Subject: [PATCH 1/8] Add a tree page with the filebrowser --- builder/index.js | 48 +- builder/package.json | 1 + builder/style.css | 1 + jupyterlab_classic/app.py | 28 +- jupyterlab_classic/templates/notebooks.html | 3 + jupyterlab_classic/templates/tree.html | 36 + packages/application/src/shell.ts | 8 +- packages/filebrowser-extension/package.json | 67 + packages/filebrowser-extension/src/index.ts | 1170 +++++++++++++++++ packages/filebrowser-extension/style/base.css | 3 + .../filebrowser-extension/style/index.css | 3 + packages/filebrowser-extension/tsconfig.json | 13 + yarn.lock | 30 + 13 files changed, 1387 insertions(+), 24 deletions(-) create mode 100644 jupyterlab_classic/templates/tree.html create mode 100644 packages/filebrowser-extension/package.json create mode 100644 packages/filebrowser-extension/src/index.ts create mode 100644 packages/filebrowser-extension/style/base.css create mode 100644 packages/filebrowser-extension/style/index.css create mode 100644 packages/filebrowser-extension/tsconfig.json 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" From ebfeaf35c7baab19cb94e8a47c0fa8a070a529d9 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 8 Dec 2020 16:59:57 +0100 Subject: [PATCH 2/8] Move some plugins to the notebook extension --- builder/index.js | 4 + builder/package.json | 1 + builder/style.css | 1 + packages/application-extension/package.json | 6 +- packages/application-extension/src/index.ts | 255 --------------- packages/application-extension/style/base.css | 74 ----- packages/filebrowser-extension/package.json | 2 +- packages/notebook-extension/package.json | 56 ++++ packages/notebook-extension/src/index.ts | 298 ++++++++++++++++++ packages/notebook-extension/style/base.css | 79 +++++ packages/notebook-extension/style/index.css | 1 + packages/notebook-extension/tsconfig.json | 13 + 12 files changed, 456 insertions(+), 334 deletions(-) create mode 100644 packages/notebook-extension/package.json create mode 100644 packages/notebook-extension/src/index.ts create mode 100644 packages/notebook-extension/style/base.css create mode 100644 packages/notebook-extension/style/index.css create mode 100644 packages/notebook-extension/tsconfig.json diff --git a/builder/index.js b/builder/index.js index b6c2a20b9..b25419f93 100644 --- a/builder/index.js +++ b/builder/index.js @@ -68,7 +68,11 @@ async function main() { // TODO: formalize the way the set of initial extensions and plugins are specified let mods = [ + // @jupyterlab-classic plugins require('@jupyterlab-classic/application-extension'), + require('@jupyterlab-classic/notebook-extension'), + + // @jupyterlab plugins require('@jupyterlab/apputils-extension').default.filter(({ id }) => [ '@jupyterlab/apputils-extension:palette', diff --git a/builder/package.json b/builder/package.json index f863269b2..11036c5d5 100644 --- a/builder/package.json +++ b/builder/package.json @@ -13,6 +13,7 @@ "@jupyterlab-classic/application": "^0.1.0", "@jupyterlab-classic/application-extension": "^0.1.0", "@jupyterlab-classic/filebrowser-extension": "^0.1.0", + "@jupyterlab-classic/notebook-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 3a3c0da66..7a8664963 100644 --- a/builder/style.css +++ b/builder/style.css @@ -1,5 +1,6 @@ @import url('~@jupyterlab-classic/application-extension/style/index.css'); @import url('~@jupyterlab-classic/filebrowser-extension/style/index.css'); +@import url('~@jupyterlab-classic/notebook-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/packages/application-extension/package.json b/packages/application-extension/package.json index c33d3d32c..7220c9f1b 100644 --- a/packages/application-extension/package.json +++ b/packages/application-extension/package.json @@ -36,16 +36,14 @@ "watch": "tsc -b --watch" }, "dependencies": { - "@jupyterlab-classic/application": "0.1.0", - "@jupyterlab-classic/ui-components": "0.1.0", + "@jupyterlab-classic/application": "^0.1.0", + "@jupyterlab-classic/ui-components": "^0.1.0", "@jupyterlab/application": "^3.0.0-rc.12", "@jupyterlab/apputils": "^3.0.0-rc.12", "@jupyterlab/codeeditor": "^3.0.0-rc.12", "@jupyterlab/codemirror": "^3.0.0-rc.12", "@jupyterlab/docregistry": "^3.0.0-rc.12", - "@jupyterlab/docmanager": "^3.0.0-rc.12", "@jupyterlab/mainmenu": "^3.0.0-rc.12", - "@jupyterlab/notebook": "^3.0.0-rc.12", "@jupyterlab/settingregistry": "^3.0.0-rc.12", "@jupyterlab/translation": "^3.0.0-rc.12", "@lumino/widgets": "^1.14.0" diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 2a4525959..94aa79d14 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -11,19 +11,12 @@ import { import { sessionContextDialogs, ISessionContextDialogs, - ISessionContext, DOMUtils, ICommandPalette } from '@jupyterlab/apputils'; -import { PageConfig, Text, Time } from '@jupyterlab/coreutils'; - -import { IDocumentManager, renameDialog } from '@jupyterlab/docmanager'; - import { IMainMenu } from '@jupyterlab/mainmenu'; -import { NotebookPanel } from '@jupyterlab/notebook'; - import { ITranslator, TranslationManager } from '@jupyterlab/translation'; import { @@ -41,26 +34,6 @@ import { Widget } from '@lumino/widgets'; */ const NOTEBOOK_FACTORY = 'Notebook'; -/** - * The class for kernel status errors. - */ -const KERNEL_STATUS_ERROR_CLASS = 'jp-ClassicKernelStatus-error'; - -/** - * The class for kernel status warnings. - */ -const KERNEL_STATUS_WARN_CLASS = 'jp-ClassicKernelStatus-warn'; - -/** - * The class for kernel status infos. - */ -const KERNEL_STATUS_INFO_CLASS = 'jp-ClassicKernelStatus-info'; - -/** - * The class to fade out the kernel status. - */ -const KERNEL_STATUS_FADE_OUT_CLASS = 'jp-ClassicKernelStatus-fade'; - /** * The command IDs used by the application plugin. */ @@ -76,168 +49,6 @@ namespace CommandIDs { export const toggleZen = 'application:toggle-zen'; } -/** - * A plugin for the checkpoint indicator - */ -const checkpoints: JupyterFrontEndPlugin = { - id: '@jupyterlab-classic/application-extension:checkpoints', - autoStart: true, - requires: [IDocumentManager], - optional: [IClassicShell], - activate: ( - app: JupyterFrontEnd, - docManager: IDocumentManager, - classicShell: IClassicShell - ) => { - const { shell } = app; - const widget = new Widget(); - widget.id = DOMUtils.createDomID(); - widget.addClass('jp-ClassicCheckpoint'); - app.shell.add(widget, 'top', { rank: 100 }); - - const onChange = async () => { - const current = shell.currentWidget; - if (!current) { - return; - } - const context = docManager.contextForWidget(current); - - context?.fileChanged.disconnect(onChange); - context?.fileChanged.connect(onChange); - - const checkpoints = await context?.listCheckpoints(); - if (!checkpoints) { - return; - } - const checkpoint = checkpoints[checkpoints.length - 1]; - widget.node.textContent = `Last Checkpoint: ${Time.formatHuman( - new Date(checkpoint.last_modified) - )}`; - }; - - if (classicShell) { - classicShell.currentChanged.connect(onChange); - } - // TODO: replace by a Poll - onChange(); - setInterval(onChange, 2000); - } -}; - -/** - * The kernel logo plugin. - */ -const kernelLogo: JupyterFrontEndPlugin = { - id: '@jupyterlab-classic/application-extension:kernel-logo', - autoStart: true, - requires: [IClassicShell], - activate: (app: JupyterFrontEnd, shell: IClassicShell) => { - const { serviceManager } = app; - const baseUrl = PageConfig.getBaseUrl(); - - let widget: Widget; - // TODO: this signal might not be needed if we assume there is always only - // one notebook in the main area - const onChange = async () => { - if (widget) { - widget.dispose(); - widget.parent = null; - } - const current = shell.currentWidget; - if (!(current instanceof NotebookPanel)) { - return; - } - - await current.sessionContext.ready; - current.sessionContext.kernelChanged.disconnect(onChange); - current.sessionContext.kernelChanged.connect(onChange); - - const name = current.sessionContext.session?.kernel?.name ?? ''; - const spec = serviceManager.kernelspecs?.specs?.kernelspecs[name]; - if (!spec) { - return; - } - - let kernelIconUrl = spec.resources['logo-64x64']; - if (!kernelIconUrl) { - return; - } - - const index = kernelIconUrl.indexOf('kernelspecs'); - kernelIconUrl = baseUrl + kernelIconUrl.slice(index); - const node = document.createElement('div'); - const img = document.createElement('img'); - img.src = kernelIconUrl; - img.title = spec.display_name; - node.appendChild(img); - widget = new Widget({ node }); - widget.addClass('jp-ClassicKernelLogo'); - app.shell.add(widget, 'top', { rank: 10_010 }); - }; - - shell.currentChanged.connect(onChange); - } -}; - -/** - * A plugin to display the kernel status; - */ -const kernelStatus: JupyterFrontEndPlugin = { - id: '@jupyterlab-classic/application-extension:kernel-status', - autoStart: true, - requires: [IClassicShell], - activate: (app: JupyterFrontEnd, shell: IClassicShell) => { - const widget = new Widget(); - widget.addClass('jp-ClassicKernelStatus'); - app.shell.add(widget, 'menu', { rank: 10_010 }); - - const removeClasses = () => { - widget.removeClass(KERNEL_STATUS_ERROR_CLASS); - widget.removeClass(KERNEL_STATUS_WARN_CLASS); - widget.removeClass(KERNEL_STATUS_INFO_CLASS); - widget.removeClass(KERNEL_STATUS_FADE_OUT_CLASS); - }; - - const onStatusChanged = (sessionContext: ISessionContext) => { - const status = sessionContext.kernelDisplayStatus; - let text = `Kernel ${Text.titleCase(status)}`; - removeClasses(); - switch (status) { - case 'busy': - case 'idle': - text = ''; - widget.addClass(KERNEL_STATUS_FADE_OUT_CLASS); - break; - case 'dead': - case 'terminating': - widget.addClass(KERNEL_STATUS_ERROR_CLASS); - break; - case 'unknown': - widget.addClass(KERNEL_STATUS_WARN_CLASS); - break; - default: - widget.addClass(KERNEL_STATUS_INFO_CLASS); - widget.addClass(KERNEL_STATUS_FADE_OUT_CLASS); - break; - } - widget.node.textContent = text; - }; - - // TODO: this signal might not be needed if we assume there is always only - // one notebook in the main area - const onChange = async () => { - const current = shell.currentWidget; - if (!(current instanceof NotebookPanel)) { - return; - } - const sessionContext = current.sessionContext; - sessionContext.statusChanged.connect(onStatusChanged); - }; - - shell.currentChanged.connect(onChange); - } -}; - /** * A plugin to dispose the Tabs menu */ @@ -270,17 +81,6 @@ const logo: JupyterFrontEndPlugin = { } }; -/** - * The main plugin. - */ -const main: JupyterFrontEndPlugin = { - id: '@jupyterlab-classic/application-extension:main', - autoStart: true, - activate: () => { - console.log(main.id, 'activated'); - } -}; - /** * The default paths for a JupyterLab Classic app. */ @@ -365,56 +165,6 @@ const spacer: JupyterFrontEndPlugin = { } }; -/** - * A plugin to display the title of the notebook - */ -const title: JupyterFrontEndPlugin = { - id: '@jupyterlab-classic/application-extension:title', - autoStart: true, - requires: [IClassicShell], - optional: [IDocumentManager, IRouter], - activate: ( - app: JupyterFrontEnd, - shell: IClassicShell, - docManager: IDocumentManager | null, - router: IRouter | null - ) => { - // TODO: this signal might not be needed if we assume there is always only - // one notebook in the main area - const widget = new Widget(); - widget.id = 'jp-title'; - app.shell.add(widget, 'top', { rank: 10 }); - - shell.currentChanged.connect(async () => { - const current = shell.currentWidget; - if (!(current instanceof NotebookPanel)) { - return; - } - const h = document.createElement('h1'); - h.textContent = current.title.label; - widget.node.appendChild(h); - widget.node.style.marginLeft = '10px'; - if (docManager) { - widget.node.onclick = async () => { - const result = await renameDialog( - docManager, - current.sessionContext.path - ); - if (result) { - h.textContent = result.path; - if (router) { - // TODO: better handle this - router.navigate(`/classic/notebooks/${result.path}`, { - skipRouting: true - }); - } - } - }; - } - }); - } -}; - /** * Plugin to toggle the top header visibility. */ @@ -555,18 +305,13 @@ const zen: JupyterFrontEndPlugin = { * Export the plugins as default. */ const plugins: JupyterFrontEndPlugin[] = [ - checkpoints, - kernelLogo, - kernelStatus, logo, - main, noTabsMenu, paths, router, sessionDialogs, shell, spacer, - title, topVisibility, translator, tree, diff --git a/packages/application-extension/style/base.css b/packages/application-extension/style/base.css index fac71eb20..70f46ed68 100644 --- a/packages/application-extension/style/base.css +++ b/packages/application-extension/style/base.css @@ -8,77 +8,3 @@ flex-grow: 1; flex-shrink: 1; } - -.jp-ClassicKernelLogo { - flex: 0 0 auto; - display: flex; - align-items: center; - text-align: center; - margin-right: 8px; -} - -.jp-ClassicKernelLogo img { - max-width: 28px; - max-height: 28px; - display: flex; -} - -.jp-ClassicKernelStatus { - font-size: 12px; - margin: 0; - font-weight: normal; - color: var(--jp-ui-font-color0); - font-family: var(--jp-ui-font-family); - line-height: var(--jp-private-title-panel-height); - padding-left: 5px; - padding-right: 5px; -} - -.jp-ClassicKernelStatus-error { - background-color: var(--jp-error-color0); -} - -.jp-ClassicKernelStatus-warn { - background-color: var(--jp-warn-color0); -} - -.jp-ClassicKernelStatus-info { - background-color: var(--jp-info-color0); -} - -.jp-ClassicKernelStatus-fade { - animation: 0.5s fade-out forwards; -} - -@keyframes fade-out { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} - -#jp-title h1 { - cursor: pointer; - font-size: 18px; - margin: 0; - font-weight: normal; - color: var(--jp-ui-font-color0); - font-family: var(--jp-ui-font-family); - line-height: calc(1.5 * var(--jp-private-title-panel-height)); -} - -#jp-title h1:hover { - background: var(--jp-layout-color2); -} - -.jp-ClassicCheckpoint { - font-size: 14px; - margin-left: 5px; - margin-right: 5px; - font-weight: normal; - color: var(--jp-ui-font-color0); - font-family: var(--jp-ui-font-family); - line-height: calc(1.5 * var(--jp-private-title-panel-height)); -} diff --git a/packages/filebrowser-extension/package.json b/packages/filebrowser-extension/package.json index 0ba9c4ccc..7f018fa7b 100644 --- a/packages/filebrowser-extension/package.json +++ b/packages/filebrowser-extension/package.json @@ -36,7 +36,7 @@ "watch": "tsc -b --watch" }, "dependencies": { - "@jupyterlab-classic/application": "0.1.0", + "@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", diff --git a/packages/notebook-extension/package.json b/packages/notebook-extension/package.json new file mode 100644 index 000000000..c649fd008 --- /dev/null +++ b/packages/notebook-extension/package.json @@ -0,0 +1,56 @@ +{ + "name": "@jupyterlab-classic/notebook-extension", + "version": "0.1.0", + "description": "JupyterLab Classic - Notebook 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.12", + "@jupyterlab/apputils": "^3.0.0-rc.12", + "@jupyterlab/docmanager": "^3.0.0-rc.12", + "@jupyterlab/notebook": "^3.0.0-rc.12", + "@lumino/widgets": "^1.14.0" + }, + "devDependencies": { + "rimraf": "~3.0.0", + "typescript": "~4.0.2" + }, + "publishConfig": { + "access": "public" + }, + "jupyterlab": { + "extension": true + } +} diff --git a/packages/notebook-extension/src/index.ts b/packages/notebook-extension/src/index.ts new file mode 100644 index 000000000..2b652c964 --- /dev/null +++ b/packages/notebook-extension/src/index.ts @@ -0,0 +1,298 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + IRouter, + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; + +import { ISessionContext, DOMUtils } from '@jupyterlab/apputils'; + +import { PageConfig, Text, Time } from '@jupyterlab/coreutils'; + +import { IDocumentManager, renameDialog } from '@jupyterlab/docmanager'; + +import { NotebookPanel } from '@jupyterlab/notebook'; + +import { + App, + ClassicShell, + IClassicShell +} from '@jupyterlab-classic/application'; + +import { Widget } from '@lumino/widgets'; + +/** + * The class for kernel status errors. + */ +const KERNEL_STATUS_ERROR_CLASS = 'jp-ClassicKernelStatus-error'; + +/** + * The class for kernel status warnings. + */ +const KERNEL_STATUS_WARN_CLASS = 'jp-ClassicKernelStatus-warn'; + +/** + * The class for kernel status infos. + */ +const KERNEL_STATUS_INFO_CLASS = 'jp-ClassicKernelStatus-info'; + +/** + * The class to fade out the kernel status. + */ +const KERNEL_STATUS_FADE_OUT_CLASS = 'jp-ClassicKernelStatus-fade'; + +/** + * A plugin for the checkpoint indicator + */ +const checkpoints: JupyterFrontEndPlugin = { + id: '@jupyterlab-classic/application-extension:checkpoints', + autoStart: true, + requires: [IDocumentManager], + optional: [IClassicShell], + activate: ( + app: JupyterFrontEnd, + docManager: IDocumentManager, + classicShell: IClassicShell + ) => { + const { shell } = app; + const widget = new Widget(); + widget.id = DOMUtils.createDomID(); + widget.addClass('jp-ClassicCheckpoint'); + app.shell.add(widget, 'top', { rank: 100 }); + + const onChange = async () => { + const current = shell.currentWidget; + if (!current) { + return; + } + const context = docManager.contextForWidget(current); + + context?.fileChanged.disconnect(onChange); + context?.fileChanged.connect(onChange); + + const checkpoints = await context?.listCheckpoints(); + if (!checkpoints) { + return; + } + const checkpoint = checkpoints[checkpoints.length - 1]; + widget.node.textContent = `Last Checkpoint: ${Time.formatHuman( + new Date(checkpoint.last_modified) + )}`; + }; + + if (classicShell) { + classicShell.currentChanged.connect(onChange); + } + // TODO: replace by a Poll + onChange(); + setInterval(onChange, 2000); + } +}; + +/** + * The kernel logo plugin. + */ +const kernelLogo: JupyterFrontEndPlugin = { + id: '@jupyterlab-classic/application-extension:kernel-logo', + autoStart: true, + requires: [IClassicShell], + activate: (app: JupyterFrontEnd, shell: IClassicShell) => { + const { serviceManager } = app; + const baseUrl = PageConfig.getBaseUrl(); + + let widget: Widget; + // TODO: this signal might not be needed if we assume there is always only + // one notebook in the main area + const onChange = async () => { + if (widget) { + widget.dispose(); + widget.parent = null; + } + const current = shell.currentWidget; + if (!(current instanceof NotebookPanel)) { + return; + } + + await current.sessionContext.ready; + current.sessionContext.kernelChanged.disconnect(onChange); + current.sessionContext.kernelChanged.connect(onChange); + + const name = current.sessionContext.session?.kernel?.name ?? ''; + const spec = serviceManager.kernelspecs?.specs?.kernelspecs[name]; + if (!spec) { + return; + } + + let kernelIconUrl = spec.resources['logo-64x64']; + if (!kernelIconUrl) { + return; + } + + const index = kernelIconUrl.indexOf('kernelspecs'); + kernelIconUrl = baseUrl + kernelIconUrl.slice(index); + const node = document.createElement('div'); + const img = document.createElement('img'); + img.src = kernelIconUrl; + img.title = spec.display_name; + node.appendChild(img); + widget = new Widget({ node }); + widget.addClass('jp-ClassicKernelLogo'); + app.shell.add(widget, 'top', { rank: 10_010 }); + }; + + shell.currentChanged.connect(onChange); + } +}; + +/** + * A plugin to display the kernel status; + */ +const kernelStatus: JupyterFrontEndPlugin = { + id: '@jupyterlab-classic/application-extension:kernel-status', + autoStart: true, + requires: [IClassicShell], + activate: (app: JupyterFrontEnd, shell: IClassicShell) => { + const widget = new Widget(); + widget.addClass('jp-ClassicKernelStatus'); + app.shell.add(widget, 'menu', { rank: 10_010 }); + + const removeClasses = () => { + widget.removeClass(KERNEL_STATUS_ERROR_CLASS); + widget.removeClass(KERNEL_STATUS_WARN_CLASS); + widget.removeClass(KERNEL_STATUS_INFO_CLASS); + widget.removeClass(KERNEL_STATUS_FADE_OUT_CLASS); + }; + + const onStatusChanged = (sessionContext: ISessionContext) => { + const status = sessionContext.kernelDisplayStatus; + let text = `Kernel ${Text.titleCase(status)}`; + removeClasses(); + switch (status) { + case 'busy': + case 'idle': + text = ''; + widget.addClass(KERNEL_STATUS_FADE_OUT_CLASS); + break; + case 'dead': + case 'terminating': + widget.addClass(KERNEL_STATUS_ERROR_CLASS); + break; + case 'unknown': + widget.addClass(KERNEL_STATUS_WARN_CLASS); + break; + default: + widget.addClass(KERNEL_STATUS_INFO_CLASS); + widget.addClass(KERNEL_STATUS_FADE_OUT_CLASS); + break; + } + widget.node.textContent = text; + }; + + // TODO: this signal might not be needed if we assume there is always only + // one notebook in the main area + const onChange = async () => { + const current = shell.currentWidget; + if (!(current instanceof NotebookPanel)) { + return; + } + const sessionContext = current.sessionContext; + sessionContext.statusChanged.connect(onStatusChanged); + }; + + shell.currentChanged.connect(onChange); + } +}; + +/** + * The default paths for a JupyterLab Classic app. + */ +const paths: JupyterFrontEndPlugin = { + id: '@jupyterlab-classic/application-extension:paths', + activate: (app: JupyterFrontEnd): JupyterFrontEnd.IPaths => { + if (!(app instanceof App)) { + throw new Error(`${paths.id} must be activated in JupyterLab Classic.`); + } + return app.paths; + }, + autoStart: true, + provides: JupyterFrontEnd.IPaths +}; + +/** + * The default JupyterLab Classic application shell. + */ +const shell: JupyterFrontEndPlugin = { + id: '@jupyterlab-classic/application-extension:shell', + activate: (app: JupyterFrontEnd) => { + if (!(app.shell instanceof ClassicShell)) { + throw new Error(`${shell.id} did not find a ClassicShell instance.`); + } + return app.shell; + }, + autoStart: true, + provides: IClassicShell +}; + +/** + * A plugin to display the title of the notebook + */ +const title: JupyterFrontEndPlugin = { + id: '@jupyterlab-classic/application-extension:title', + autoStart: true, + requires: [IClassicShell], + optional: [IDocumentManager, IRouter], + activate: ( + app: JupyterFrontEnd, + shell: IClassicShell, + docManager: IDocumentManager | null, + router: IRouter | null + ) => { + // TODO: this signal might not be needed if we assume there is always only + // one notebook in the main area + const widget = new Widget(); + widget.id = 'jp-title'; + app.shell.add(widget, 'top', { rank: 10 }); + + shell.currentChanged.connect(async () => { + const current = shell.currentWidget; + if (!(current instanceof NotebookPanel)) { + return; + } + const h = document.createElement('h1'); + h.textContent = current.title.label; + widget.node.appendChild(h); + widget.node.style.marginLeft = '10px'; + if (docManager) { + widget.node.onclick = async () => { + const result = await renameDialog( + docManager, + current.sessionContext.path + ); + if (result) { + h.textContent = result.path; + if (router) { + // TODO: better handle this + router.navigate(`/classic/notebooks/${result.path}`, { + skipRouting: true + }); + } + } + }; + } + }); + } +}; + +/** + * Export the plugins as default. + */ +const plugins: JupyterFrontEndPlugin[] = [ + checkpoints, + kernelLogo, + kernelStatus, + title +]; + +export default plugins; diff --git a/packages/notebook-extension/style/base.css b/packages/notebook-extension/style/base.css new file mode 100644 index 000000000..80215fcd2 --- /dev/null +++ b/packages/notebook-extension/style/base.css @@ -0,0 +1,79 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +.jp-ClassicKernelLogo { + flex: 0 0 auto; + display: flex; + align-items: center; + text-align: center; + margin-right: 8px; +} + +.jp-ClassicKernelLogo img { + max-width: 28px; + max-height: 28px; + display: flex; +} + +.jp-ClassicKernelStatus { + font-size: 12px; + margin: 0; + font-weight: normal; + color: var(--jp-ui-font-color0); + font-family: var(--jp-ui-font-family); + line-height: var(--jp-private-title-panel-height); + padding-left: 5px; + padding-right: 5px; +} + +.jp-ClassicKernelStatus-error { + background-color: var(--jp-error-color0); +} + +.jp-ClassicKernelStatus-warn { + background-color: var(--jp-warn-color0); +} + +.jp-ClassicKernelStatus-info { + background-color: var(--jp-info-color0); +} + +.jp-ClassicKernelStatus-fade { + animation: 0.5s fade-out forwards; +} + +@keyframes fade-out { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +#jp-title h1 { + cursor: pointer; + font-size: 18px; + margin: 0; + font-weight: normal; + color: var(--jp-ui-font-color0); + font-family: var(--jp-ui-font-family); + line-height: calc(1.5 * var(--jp-private-title-panel-height)); +} + +#jp-title h1:hover { + background: var(--jp-layout-color2); +} + +.jp-ClassicCheckpoint { + font-size: 14px; + margin-left: 5px; + margin-right: 5px; + font-weight: normal; + color: var(--jp-ui-font-color0); + font-family: var(--jp-ui-font-family); + line-height: calc(1.5 * var(--jp-private-title-panel-height)); +} diff --git a/packages/notebook-extension/style/index.css b/packages/notebook-extension/style/index.css new file mode 100644 index 000000000..f5246e666 --- /dev/null +++ b/packages/notebook-extension/style/index.css @@ -0,0 +1 @@ +@import url('./base.css'); diff --git a/packages/notebook-extension/tsconfig.json b/packages/notebook-extension/tsconfig.json new file mode 100644 index 000000000..b223e1a1b --- /dev/null +++ b/packages/notebook-extension/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfigbase", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": ["src/**/*"], + "references": [ + { + "path": "../application" + } + ] +} From b9d0cdc5f79237bbf107cdc9ddc2278e316ffe72 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 8 Dec 2020 17:06:11 +0100 Subject: [PATCH 3/8] Add link to the Jupyter logo --- jupyterlab_classic/templates/notebooks.html | 2 +- jupyterlab_classic/templates/tree.html | 2 +- packages/application-extension/package.json | 1 + packages/application-extension/src/index.ts | 11 +++++++++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/jupyterlab_classic/templates/notebooks.html b/jupyterlab_classic/templates/notebooks.html index 6f794eba6..e0d63b637 100644 --- a/jupyterlab_classic/templates/notebooks.html +++ b/jupyterlab_classic/templates/notebooks.html @@ -3,7 +3,7 @@ - {{page_config['appName'] | e}} + {{page_config['appName'] | e}} - Notebook diff --git a/jupyterlab_classic/templates/tree.html b/jupyterlab_classic/templates/tree.html index f8abf0f27..afd11eab5 100644 --- a/jupyterlab_classic/templates/tree.html +++ b/jupyterlab_classic/templates/tree.html @@ -3,7 +3,7 @@ - {{page_config['appName'] | e}} + {{page_config['appName'] | e}} - Tree diff --git a/packages/application-extension/package.json b/packages/application-extension/package.json index 7220c9f1b..fd75796dc 100644 --- a/packages/application-extension/package.json +++ b/packages/application-extension/package.json @@ -42,6 +42,7 @@ "@jupyterlab/apputils": "^3.0.0-rc.12", "@jupyterlab/codeeditor": "^3.0.0-rc.12", "@jupyterlab/codemirror": "^3.0.0-rc.12", + "@jupyterlab/coreutils": "^5.0.0-rc.12", "@jupyterlab/docregistry": "^3.0.0-rc.12", "@jupyterlab/mainmenu": "^3.0.0-rc.12", "@jupyterlab/settingregistry": "^3.0.0-rc.12", diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 94aa79d14..c53e81d65 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -15,6 +15,8 @@ import { ICommandPalette } from '@jupyterlab/apputils'; +import { PageConfig } from '@jupyterlab/coreutils'; + import { IMainMenu } from '@jupyterlab/mainmenu'; import { ITranslator, TranslationManager } from '@jupyterlab/translation'; @@ -68,9 +70,14 @@ const logo: JupyterFrontEndPlugin = { id: '@jupyterlab-classic/application-extension:logo', autoStart: true, activate: (app: JupyterFrontEnd) => { - const logo = new Widget(); + const baseUrl = PageConfig.getBaseUrl(); + const node = document.createElement('a'); + node.href = `${baseUrl}classic/tree`; + node.target = '_blank'; + node.rel = 'noopener noreferrer'; + const logo = new Widget({ node }); jupyterIcon.element({ - container: logo.node, + container: node, elementPosition: 'center', padding: '2px 2px 2px 8px', height: '28px', From 595b1e21836416e2c6d33e69ade249466c8163e7 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 8 Dec 2020 19:05:17 +0100 Subject: [PATCH 4/8] Open notebooks in a new browser tab --- builder/index.js | 1 + builder/package.json | 1 + packages/application/src/shell.ts | 16 +++--- packages/docmanager-extension/package.json | 53 +++++++++++++++++++ packages/docmanager-extension/src/index.ts | 42 +++++++++++++++ packages/docmanager-extension/style/base.css | 0 packages/docmanager-extension/style/index.css | 1 + packages/docmanager-extension/tsconfig.json | 8 +++ packages/notebook-extension/tsconfig.json | 7 +-- 9 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 packages/docmanager-extension/package.json create mode 100644 packages/docmanager-extension/src/index.ts create mode 100644 packages/docmanager-extension/style/base.css create mode 100644 packages/docmanager-extension/style/index.css create mode 100644 packages/docmanager-extension/tsconfig.json diff --git a/builder/index.js b/builder/index.js index b25419f93..81e274d90 100644 --- a/builder/index.js +++ b/builder/index.js @@ -70,6 +70,7 @@ async function main() { let mods = [ // @jupyterlab-classic plugins require('@jupyterlab-classic/application-extension'), + require('@jupyterlab-classic/docmanager-extension'), require('@jupyterlab-classic/notebook-extension'), // @jupyterlab plugins diff --git a/builder/package.json b/builder/package.json index 11036c5d5..f9a5c0ce5 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/docmanager-extension": "^0.1.0", "@jupyterlab-classic/filebrowser-extension": "^0.1.0", "@jupyterlab-classic/notebook-extension": "^0.1.0", "@jupyterlab-classic/ui-components": "^0.1.0", diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index c1790e76e..a74b6add4 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -102,13 +102,15 @@ export class ClassicShell extends Widget implements JupyterFrontEnd.IShell { if (area === 'menu') { return this._menuHandler.addWidget(widget, rank); } - // TODO: better handle this - this._main.widgets.forEach(w => { - w.close(); - }); - this._main.addWidget(widget); - this._main.update(); - this._currentChanged.emit(void 0); + if (area === 'main') { + if (this._main.widgets.length > 0) { + // do not add the widget if there is already one + return; + } + this._main.addWidget(widget); + this._main.update(); + this._currentChanged.emit(void 0); + } } /** diff --git a/packages/docmanager-extension/package.json b/packages/docmanager-extension/package.json new file mode 100644 index 000000000..b70274b95 --- /dev/null +++ b/packages/docmanager-extension/package.json @@ -0,0 +1,53 @@ +{ + "name": "@jupyterlab-classic/docmanager-extension", + "version": "0.1.0", + "description": "JupyterLab Classic - Document Manager 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/application": "^3.0.0-rc.12", + "@jupyterlab/coreutils": "^5.0.0-rc.12", + "@lumino/algorithm": "^1.3.3" + }, + "devDependencies": { + "rimraf": "~3.0.0", + "typescript": "~4.0.2" + }, + "publishConfig": { + "access": "public" + }, + "jupyterlab": { + "extension": true + } +} diff --git a/packages/docmanager-extension/src/index.ts b/packages/docmanager-extension/src/index.ts new file mode 100644 index 000000000..34d6cfe8d --- /dev/null +++ b/packages/docmanager-extension/src/index.ts @@ -0,0 +1,42 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; + +import { PageConfig } from '@jupyterlab/coreutils'; + +import { IDocumentManager } from '@jupyterlab/docmanager'; + +import { toArray } from '@lumino/algorithm'; + +/** + * A plugin to open document in a new browser tab. + * + * TODO: remove and use a custom doc manager? + */ +const opener: JupyterFrontEndPlugin = { + id: '@jupyterlab-classic/docmanager-extension:opener', + autoStart: true, + activate: (app: JupyterFrontEnd, docManager: IDocumentManager) => { + const { commands, shell } = app; + const baseUrl = PageConfig.getBaseUrl(); + commands.commandExecuted.connect((sender, executedArgs) => { + const widgets = toArray(shell.widgets('main')); + const { id, args } = executedArgs; + const path = args['path'] as string; + if (id === 'docmanager:open' && widgets.length > 0 && path) { + window.open(`${baseUrl}classic/notebooks/${path}`, '_blank'); + } + }); + } +}; + +/** + * Export the plugins as default. + */ +const plugins: JupyterFrontEndPlugin[] = [opener]; + +export default plugins; diff --git a/packages/docmanager-extension/style/base.css b/packages/docmanager-extension/style/base.css new file mode 100644 index 000000000..e69de29bb diff --git a/packages/docmanager-extension/style/index.css b/packages/docmanager-extension/style/index.css new file mode 100644 index 000000000..f5246e666 --- /dev/null +++ b/packages/docmanager-extension/style/index.css @@ -0,0 +1 @@ +@import url('./base.css'); diff --git a/packages/docmanager-extension/tsconfig.json b/packages/docmanager-extension/tsconfig.json new file mode 100644 index 000000000..399b75b7a --- /dev/null +++ b/packages/docmanager-extension/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfigbase", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/packages/notebook-extension/tsconfig.json b/packages/notebook-extension/tsconfig.json index b223e1a1b..399b75b7a 100644 --- a/packages/notebook-extension/tsconfig.json +++ b/packages/notebook-extension/tsconfig.json @@ -4,10 +4,5 @@ "outDir": "lib", "rootDir": "src" }, - "include": ["src/**/*"], - "references": [ - { - "path": "../application" - } - ] + "include": ["src/**/*"] } From ead4af0b013f86da6d4c310e4c00e891e75a3606 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 8 Dec 2020 19:37:49 +0100 Subject: [PATCH 5/8] Close widget if it cannot be added to the shell --- packages/application/src/shell.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index a74b6add4..24ded8f43 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -105,6 +105,8 @@ export class ClassicShell extends Widget implements JupyterFrontEnd.IShell { if (area === 'main') { if (this._main.widgets.length > 0) { // do not add the widget if there is already one + // TODO: should the widget be closed? + widget.close(); return; } this._main.addWidget(widget); From f0a237a1807ed77656a15c6042542b84db155b74 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 8 Dec 2020 20:45:06 +0100 Subject: [PATCH 6/8] Patch docManager.open to better open in new tab --- packages/application-extension/src/index.ts | 40 ------------------- packages/docmanager-extension/package.json | 3 ++ packages/docmanager-extension/src/index.ts | 29 +++++++++----- packages/notebook-extension/src/index.ts | 43 ++++++++++++++++++++- 4 files changed, 65 insertions(+), 50 deletions(-) diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index c53e81d65..e9330693b 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -31,11 +31,6 @@ import { jupyterIcon } from '@jupyterlab-classic/ui-components'; import { Widget } from '@lumino/widgets'; -/** - * The default notebook factory. - */ -const NOTEBOOK_FACTORY = 'Notebook'; - /** * The command IDs used by the application plugin. */ @@ -214,40 +209,6 @@ const translator: JupyterFrontEndPlugin = { provides: ITranslator }; -/** - * The default tree route resolver plugin. - */ -const tree: JupyterFrontEndPlugin = { - id: '@jupyterlab-classic/application-extension:tree-resolver', - autoStart: true, - requires: [IRouter], - activate: (app: JupyterFrontEnd, router: IRouter): void => { - const { commands } = app; - const treePattern = new RegExp('/notebooks/(.*)'); - - const command = 'router:tree'; - commands.addCommand(command, { - execute: (args: any) => { - const parsed = args as IRouter.ILocation; - const matches = parsed.path.match(treePattern); - if (!matches) { - return; - } - const [, path] = matches; - - app.restored.then(() => { - commands.execute('docmanager:open', { - path, - factory: NOTEBOOK_FACTORY - }); - }); - } - }); - - router.register({ command, pattern: treePattern }); - } -}; - /** * Zen mode plugin */ @@ -321,7 +282,6 @@ const plugins: JupyterFrontEndPlugin[] = [ spacer, topVisibility, translator, - tree, zen ]; diff --git a/packages/docmanager-extension/package.json b/packages/docmanager-extension/package.json index b70274b95..38fc3bd2a 100644 --- a/packages/docmanager-extension/package.json +++ b/packages/docmanager-extension/package.json @@ -38,6 +38,9 @@ "dependencies": { "@jupyterlab/application": "^3.0.0-rc.12", "@jupyterlab/coreutils": "^5.0.0-rc.12", + "@jupyterlab/docmanager": "^3.0.0-rc.12", + "@jupyterlab/docregistry": "^3.0.0-rc.12", + "@jupyterlab/services": "^6.0.0-rc.12", "@lumino/algorithm": "^1.3.3" }, "devDependencies": { diff --git a/packages/docmanager-extension/src/index.ts b/packages/docmanager-extension/src/index.ts index 34d6cfe8d..8c93a0ff0 100644 --- a/packages/docmanager-extension/src/index.ts +++ b/packages/docmanager-extension/src/index.ts @@ -10,7 +10,9 @@ import { PageConfig } from '@jupyterlab/coreutils'; import { IDocumentManager } from '@jupyterlab/docmanager'; -import { toArray } from '@lumino/algorithm'; +import { IDocumentWidget, DocumentRegistry } from '@jupyterlab/docregistry'; + +import { Kernel } from '@jupyterlab/services'; /** * A plugin to open document in a new browser tab. @@ -19,18 +21,27 @@ import { toArray } from '@lumino/algorithm'; */ const opener: JupyterFrontEndPlugin = { id: '@jupyterlab-classic/docmanager-extension:opener', + requires: [IDocumentManager], autoStart: true, activate: (app: JupyterFrontEnd, docManager: IDocumentManager) => { - const { commands, shell } = app; const baseUrl = PageConfig.getBaseUrl(); - commands.commandExecuted.connect((sender, executedArgs) => { - const widgets = toArray(shell.widgets('main')); - const { id, args } = executedArgs; - const path = args['path'] as string; - if (id === 'docmanager:open' && widgets.length > 0 && path) { - window.open(`${baseUrl}classic/notebooks/${path}`, '_blank'); + + // patch the `docManager.open` option to prevent the default behavior + const docOpen = docManager.open; + docManager.open = ( + path: string, + widgetName = 'default', + kernel?: Partial, + options?: DocumentRegistry.IOpenOptions + ): IDocumentWidget | undefined => { + const ref = options?.ref; + if (ref === 'noref') { + docOpen.call(docManager, path, widgetName, kernel, options); + return; } - }); + window.open(`${baseUrl}classic/notebooks/${path}`); + return undefined; + }; } }; diff --git a/packages/notebook-extension/src/index.ts b/packages/notebook-extension/src/index.ts index 2b652c964..38074f2a4 100644 --- a/packages/notebook-extension/src/index.ts +++ b/packages/notebook-extension/src/index.ts @@ -23,6 +23,11 @@ import { import { Widget } from '@lumino/widgets'; +/** + * The default notebook factory. + */ +const NOTEBOOK_FACTORY = 'Notebook'; + /** * The class for kernel status errors. */ @@ -285,6 +290,41 @@ const title: JupyterFrontEndPlugin = { } }; +/** + * The default tree route resolver plugin. + */ +const tree: JupyterFrontEndPlugin = { + id: '@jupyterlab-classic/application-extension:tree-resolver', + autoStart: true, + requires: [IRouter, IDocumentManager], + activate: ( + app: JupyterFrontEnd, + router: IRouter, + docManager: IDocumentManager + ): void => { + const { commands } = app; + const treePattern = new RegExp('/notebooks/(.*)'); + + const command = 'router:tree'; + commands.addCommand(command, { + execute: (args: any) => { + const parsed = args as IRouter.ILocation; + const matches = parsed.path.match(treePattern); + if (!matches) { + return; + } + const [, path] = matches; + + app.restored.then(() => { + docManager.open(path, NOTEBOOK_FACTORY, undefined, { ref: 'noref' }); + }); + } + }); + + router.register({ command, pattern: treePattern }); + } +}; + /** * Export the plugins as default. */ @@ -292,7 +332,8 @@ const plugins: JupyterFrontEndPlugin[] = [ checkpoints, kernelLogo, kernelStatus, - title + title, + tree ]; export default plugins; From 0d6fa76ef939e37da68150af0ac08aeeed7e7a80 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 8 Dec 2020 20:50:57 +0100 Subject: [PATCH 7/8] Update README.md --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8452f3602..4a7b19345 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # jupyterlab-classic ![Github Actions Status](https://github.com/jtpio/jupyterlab-classic/workflows/Build/badge.svg) -[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/jtpio/jupyterlab-classic/main?urlpath=classic/notebooks/binder/example.ipynb) +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/jtpio/jupyterlab-classic/main?urlpath=classic/tree) The next gen old-school Notebook UI. @@ -37,6 +37,10 @@ jupyter labextension list Should also be available when starting `jupyterlab-classic`. +This package also install a JupyterLab extension by default, to make it easier to transition to classic view: + +![open-jupyterlab-classic](https://user-images.githubusercontent.com/591645/101534129-02844580-3997-11eb-962a-5475bcc831bb.gif) + ## Motivation JupyterLab is the next-gen UI for Project Jupyter. Approaching version 3.0, it is becoming more mature and provides an advanced computational environment, that can sometimes be compared to what traditional IDEs offer. From c92f07dd0932c0ed2662ae5c486c5d9dc52ee168 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 8 Dec 2020 21:04:35 +0100 Subject: [PATCH 8/8] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a7b19345..b051714b2 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ jupyter labextension list Should also be available when starting `jupyterlab-classic`. -This package also install a JupyterLab extension by default, to make it easier to transition to classic view: +This package also adds a toolbar button by default, to make it easier to switch to the classic view: ![open-jupyterlab-classic](https://user-images.githubusercontent.com/591645/101534129-02844580-3997-11eb-962a-5475bcc831bb.gif)