Browse Source

Merge pull request #5409 from telamonian/migrate-filebrowser-contextmenu-to-app

Migrate filebrowser contextmenu to application-wide contextmenu
Ian Rose 6 years ago
parent
commit
474d49d0a8

+ 1 - 0
packages/filebrowser-extension/package.json

@@ -40,6 +40,7 @@
     "@jupyterlab/services": "^3.2.1-alpha.0",
     "@phosphor/algorithm": "^1.1.2",
     "@phosphor/commands": "^1.6.1",
+    "@phosphor/messaging": "^1.2.2",
     "@phosphor/widgets": "^1.6.0"
   },
   "devDependencies": {

+ 183 - 74
packages/filebrowser-extension/src/index.ts

@@ -19,8 +19,6 @@ import { IStateDB, PageConfig, PathExt, URLExt } from '@jupyterlab/coreutils';
 
 import { IDocumentManager } from '@jupyterlab/docmanager';
 
-import { DocumentRegistry } from '@jupyterlab/docregistry';
-
 import {
   FileBrowserModel,
   FileBrowser,
@@ -31,10 +29,12 @@ import { Launcher } from '@jupyterlab/launcher';
 
 import { Contents } from '@jupyterlab/services';
 
-import { map, toArray } from '@phosphor/algorithm';
+import { IIterator, map, reduce, toArray } from '@phosphor/algorithm';
 
 import { CommandRegistry } from '@phosphor/commands';
 
+import { Message } from '@phosphor/messaging';
+
 import { Menu } from '@phosphor/widgets';
 
 /**
@@ -67,6 +67,9 @@ namespace CommandIDs {
 
   export const paste = 'filebrowser:paste';
 
+  // paste command used when user did not click on an item
+  export const pasteNotItem = 'filebrowser:paste-not-item';
+
   export const rename = 'filebrowser:rename';
 
   // For main browser only.
@@ -139,7 +142,6 @@ function activateFactory(
       model,
       commands: options.commands || commands
     });
-    const { registry } = docManager;
 
     // Add a launcher toolbar item.
     let launcher = new ToolbarButton({
@@ -151,15 +153,6 @@ function activateFactory(
     });
     widget.toolbar.insertItem(0, 'launch', launcher);
 
-    // Add a context menu handler to the file browser's directory listing.
-    let node = widget.node.getElementsByClassName('jp-DirListing-content')[0];
-    node.addEventListener('contextmenu', (event: MouseEvent) => {
-      event.preventDefault();
-      const model = widget.modelForClick(event);
-      const menu = createContextMenu(model, commands, registry);
-      menu.open(event.clientX, event.clientY);
-    });
-
     // Track the newly created file browser.
     tracker.add(widget);
 
@@ -226,6 +219,8 @@ function addCommands(
   tracker: InstanceTracker<FileBrowser>,
   browser: FileBrowser
 ): void {
+  const registry = app.docRegistry;
+
   const getBrowserForPath = (path: string): FileBrowser => {
     const driveName = app.serviceManager.contents.driveName(path);
 
@@ -348,7 +343,8 @@ function addCommands(
   });
 
   commands.addCommand(CommandIDs.open, {
-    execute: () => {
+    execute: args => {
+      const factory = (args['factory'] as string) || void 0;
       const widget = tracker.currentWidget;
 
       if (!widget) {
@@ -362,13 +358,31 @@ function addCommands(
               return widget.model.cd(item.name);
             }
 
-            return commands.execute('docmanager:open', { path: item.path });
+            return commands.execute('docmanager:open', {
+              factory: factory,
+              path: item.path
+            });
           })
         )
       );
     },
-    iconClass: 'jp-MaterialIcon jp-OpenFolderIcon',
-    label: 'Open',
+    iconClass: args => {
+      const factory = (args['factory'] as string) || void 0;
+      if (factory) {
+        // if an explicit factory is passed...
+        const ft = registry.getFileType(factory);
+        if (ft) {
+          // ...set an icon if the factory name corresponds to a file type name...
+          return ft.iconClass;
+        } else {
+          // ...or leave the icon blank
+          return '';
+        }
+      } else {
+        return 'jp-MaterialIcon jp-OpenFolderIcon';
+      }
+    },
+    label: args => (args['label'] || args['factory'] || 'Open') as string,
     mnemonic: 0
   });
 
@@ -533,74 +547,169 @@ function addCommands(
     label: 'New Launcher',
     execute: () => createLauncher(commands, browser)
   });
-}
 
-/**
- * Create a context menu for the file browser listing.
- *
- * #### Notes
- * This function generates temporary commands with an incremented name. These
- * commands are disposed when the menu itself is disposed.
- */
-function createContextMenu(
-  model: Contents.IModel | undefined,
-  commands: CommandRegistry,
-  registry: DocumentRegistry
-): Menu {
-  const menu = new Menu({ commands });
-
-  // If the user did not click on any file, we still want to show
-  // paste as a possibility.
-  if (!model) {
-    menu.addItem({ command: CommandIDs.paste });
-    return menu;
-  }
+  /**
+   * 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
+      let factories = OpenWithMenu._intersection(
+        map(tracker.currentWidget.selectedItems(), i => {
+          return OpenWithMenu._getFactories(i);
+        })
+      );
+
+      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<string> {
+      let factories = registry
+        .preferredWidgetFactories(item.path)
+        .map(f => f.name);
+      const notebookFactory = registry.getWidgetFactory('notebook').name;
+      if (
+        item.type === 'notebook' &&
+        factories.indexOf(notebookFactory) === -1
+      ) {
+        factories.unshift(notebookFactory);
+      }
 
-  menu.addItem({ command: CommandIDs.open });
-
-  const path = model.path;
-  if (model.type !== 'directory') {
-    const factories = registry.preferredWidgetFactories(path).map(f => f.name);
-    const notebookFactory = registry.getWidgetFactory('notebook').name;
-    if (
-      model.type === 'notebook' &&
-      factories.indexOf(notebookFactory) === -1
-    ) {
-      factories.unshift(notebookFactory);
+      return factories;
     }
-    if (path && factories.length > 1) {
-      const command = 'docmanager:open';
-      const openWith = new Menu({ commands });
-      openWith.title.label = 'Open With';
-      factories.forEach(factory => {
-        openWith.addItem({ args: { factory, path }, command });
-      });
-      menu.addItem({ type: 'submenu', submenu: openWith });
+
+    static _intersection<T>(iter: IIterator<Array<T>>): Set<T> | void {
+      // pop the first element of iter
+      let first = iter.next();
+      // first will be undefined if iter is empty
+      if (!first) {
+        return;
+      }
+
+      // "initialize" the intersection from first
+      let 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
+      );
     }
-    menu.addItem({ command: CommandIDs.openBrowserTab });
   }
 
-  menu.addItem({ command: CommandIDs.rename });
-  menu.addItem({ command: CommandIDs.del });
-  menu.addItem({ command: CommandIDs.cut });
+  // matches anywhere on filebrowser that is not an item
+  const selectorDeadSpace = '.jp-DirListing-deadSpace';
+  // 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
+  app.contextMenu.addItem({
+    command: CommandIDs.paste,
+    selector: selectorDeadSpace,
+    rank: 1
+  });
 
-  if (model.type !== 'directory') {
-    menu.addItem({ command: CommandIDs.copy });
-  }
+  app.contextMenu.addItem({
+    command: CommandIDs.open,
+    selector: selectorItem,
+    rank: 1
+  });
 
-  menu.addItem({ command: CommandIDs.paste });
+  const openWith = new OpenWithMenu({ commands });
+  openWith.title.label = 'Open With';
+  app.contextMenu.addItem({
+    type: 'submenu',
+    submenu: openWith,
+    selector: selectorNotDir,
+    rank: 2
+  });
 
-  if (model.type !== 'directory') {
-    menu.addItem({ command: CommandIDs.duplicate });
-    menu.addItem({ command: CommandIDs.download });
-    menu.addItem({ command: CommandIDs.shutdown });
-  }
+  app.contextMenu.addItem({
+    command: CommandIDs.openBrowserTab,
+    selector: selectorNotDir,
+    rank: 3
+  });
 
-  menu.addItem({ command: CommandIDs.share });
-  menu.addItem({ command: CommandIDs.copyPath });
-  menu.addItem({ command: CommandIDs.copyDownloadLink });
+  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
+  });
 
-  return menu;
+  app.contextMenu.addItem({
+    command: CommandIDs.paste,
+    selector: selectorItem,
+    rank: 8
+  });
+
+  app.contextMenu.addItem({
+    command: CommandIDs.duplicate,
+    selector: selectorNotDir,
+    rank: 9
+  });
+  app.contextMenu.addItem({
+    command: CommandIDs.download,
+    selector: selectorNotDir,
+    rank: 10
+  });
+  app.contextMenu.addItem({
+    command: CommandIDs.shutdown,
+    selector: selectorNotDir,
+    rank: 11
+  });
+
+  app.contextMenu.addItem({
+    command: CommandIDs.share,
+    selector: selectorItem,
+    rank: 12
+  });
+  app.contextMenu.addItem({
+    command: CommandIDs.copyPath,
+    selector: selectorItem,
+    rank: 13
+  });
+  app.contextMenu.addItem({
+    command: CommandIDs.copyDownloadLink,
+    selector: selectorItem,
+    rank: 14
+  });
 }
 
 /**

+ 11 - 0
packages/filebrowser/src/listing.ts

@@ -93,6 +93,8 @@ const ITEM_ICON_CLASS = 'jp-DirListing-itemIcon';
  */
 const ITEM_MODIFIED_CLASS = 'jp-DirListing-itemModified';
 
+const DEAD_SPACE_CLASS = 'jp-DirListing-deadSpace';
+
 /**
  * The class name added to the dir listing editor node.
  */
@@ -754,6 +756,12 @@ export class DirListing extends Widget {
           node.classList.add(CUT_CLASS);
         }
       }
+
+      // add metadata to the node
+      node.setAttribute(
+        'data-isdir',
+        item.type === 'directory' ? 'true' : 'false'
+      );
     });
 
     // Handle the selectors on the widget node.
@@ -1624,10 +1632,13 @@ export namespace DirListing {
       let node = document.createElement('div');
       let header = document.createElement('div');
       let content = document.createElement('ul');
+      let deadSpace = document.createElement('ul');
       content.className = CONTENT_CLASS;
+      deadSpace.className = DEAD_SPACE_CLASS;
       header.className = HEADER_CLASS;
       node.appendChild(header);
       node.appendChild(content);
+      node.appendChild(deadSpace);
       node.tabIndex = 1;
       return node;
     }

+ 10 - 1
packages/filebrowser/style/index.css

@@ -144,7 +144,7 @@
 
 /* increase specificity to override bundled default */
 .jp-DirListing-content {
-  flex: 1 1 auto;
+  flex: 0 1 auto;
   margin: 0;
   padding: 0;
   list-style-type: none;
@@ -269,6 +269,15 @@
   transform: translateX(-40%) translateY(-58%);
 }
 
+.jp-DirListing-deadSpace {
+  flex: 1 1 auto;
+  margin: 0;
+  padding: 0;
+  list-style-type: none;
+  overflow: auto;
+  background-color: var(--jp-layout-color1);
+}
+
 .jp-Document {
   min-width: 120px;
   min-height: 120px;