From 95b39d037ea9b78f014103e01ee15b73605b2ce2 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 8 Dec 2020 16:46:37 +0100 Subject: [PATCH] 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"