Browse Source

Merge pull request #8615 from datalayer-contrib/filetree-filter

File Browser Filter
Steven Silvester 4 years ago
parent
commit
b0a1ca6262

+ 2 - 2
examples/filebrowser/src/index.ts

@@ -32,7 +32,7 @@ import { DocumentManager } from '@jupyterlab/docmanager';
 
 import { DocumentRegistry } from '@jupyterlab/docregistry';
 
-import { FileBrowser, FileBrowserModel } from '@jupyterlab/filebrowser';
+import { FileBrowser, FilterFileBrowserModel } from '@jupyterlab/filebrowser';
 
 import { FileEditorFactory } from '@jupyterlab/fileeditor';
 
@@ -89,7 +89,7 @@ function createApp(manager: ServiceManager.IManager): void {
 
   const commands = new CommandRegistry();
 
-  const fbModel = new FileBrowserModel({
+  const fbModel = new FilterFileBrowserModel({
     manager: docManager
   });
   const fbWidget = new FileBrowser({

+ 1 - 1
packages/extensionmanager/src/widget.tsx

@@ -66,7 +66,7 @@ export class SearchBar extends React.Component<
           placeholder={this.props.placeholder}
           onChange={this.handleChange}
           value={this.state.value}
-          rightIcon="search"
+          rightIcon="ui-components:search"
           disabled={this.props.disabled}
         />
       </div>

+ 6 - 0
packages/filebrowser-extension/schema/browser.json

@@ -21,6 +21,12 @@
       "title": "Navigate to current directory",
       "description": "Whether to automatically navigate to a document's current directory",
       "default": false
+    },
+    "useFuzzyFilter": {
+      "type": "boolean",
+      "title": "Filter on file name with a fuzzy search",
+      "description": "Whether to apply fuzzy algorithm while filtering on file names",
+      "default": true
     }
   },
   "additionalProperties": false,

+ 16 - 2
packages/filebrowser-extension/src/index.ts

@@ -25,7 +25,7 @@ import { PageConfig, PathExt, URLExt } from '@jupyterlab/coreutils';
 import { IDocumentManager } from '@jupyterlab/docmanager';
 
 import {
-  FileBrowserModel,
+  FilterFileBrowserModel,
   FileBrowser,
   FileUploadStatus,
   IFileBrowserFactory
@@ -125,6 +125,8 @@ namespace CommandIDs {
     'filebrowser:toggle-navigate-to-current-directory';
 
   export const toggleLastModified = 'filebrowser:toggle-last-modified';
+
+  export const search = 'filebrowser:search';
 }
 
 /**
@@ -240,7 +242,7 @@ async function activateFactory(
     id: string,
     options: IFileBrowserFactory.IOptions = {}
   ) => {
-    const model = new FileBrowserModel({
+    const model = new FilterFileBrowserModel({
       auto: options.auto ?? true,
       manager: docManager,
       driveName: options.driveName || '',
@@ -354,6 +356,7 @@ function activateBrowser(
     });
 
     let navigateToCurrentDirectory: boolean = false;
+    let useFuzzyFilter: boolean = true;
 
     void settingRegistry
       .load('@jupyterlab/filebrowser-extension:browser')
@@ -367,6 +370,12 @@ function activateBrowser(
         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
@@ -844,6 +853,11 @@ function addCommands(
     }
   });
 
+  commands.addCommand(CommandIDs.search, {
+    label: 'Search on File Names',
+    execute: () => alert('search')
+  });
+
   if (mainMenu) {
     mainMenu.settingsMenu.addGroup(
       [{ command: CommandIDs.toggleNavigateToCurrentDirectory }],

+ 1 - 0
packages/filebrowser/package.json

@@ -56,6 +56,7 @@
     "@lumino/messaging": "^1.3.3",
     "@lumino/polling": "^1.1.1",
     "@lumino/signaling": "^1.3.5",
+    "@lumino/virtualdom": "^1.6.1",
     "@lumino/widgets": "^1.11.1",
     "react": "~16.9.0"
   },

+ 61 - 11
packages/filebrowser/src/browser.ts

@@ -1,7 +1,12 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { showErrorMessage, Toolbar, ToolbarButton } from '@jupyterlab/apputils';
+import {
+  showErrorMessage,
+  Toolbar,
+  ToolbarButton,
+  ReactWidget
+} from '@jupyterlab/apputils';
 
 import { IDocumentManager } from '@jupyterlab/docmanager';
 
@@ -17,10 +22,12 @@ import { BreadCrumbs } from './crumbs';
 
 import { DirListing } from './listing';
 
-import { FileBrowserModel } from './model';
+import { FilterFileBrowserModel } from './model';
 
 import { Uploader } from './upload';
 
+import { FilenameSearcher } from './search';
+
 /**
  * The class name added to file browsers.
  */
@@ -31,6 +38,11 @@ const FILE_BROWSER_CLASS = 'jp-FileBrowser';
  */
 const CRUMBS_CLASS = 'jp-FileBrowser-crumbs';
 
+/**
+ * The class name added to the filebrowser filterbox node.
+ */
+const FILTERBOX_CLASS = 'jp-FileBrowser-filterBox';
+
 /**
  * The class name added to the filebrowser toolbar node.
  */
@@ -76,6 +88,7 @@ export class FileBrowser extends Widget {
       tooltip: 'New Folder'
     });
     const uploader = new Uploader({ model });
+
     const refresher = new ToolbarButton({
       icon: refreshIcon,
       onClick: () => {
@@ -90,16 +103,22 @@ export class FileBrowser extends Widget {
 
     this._listing = new DirListing({ model, renderer });
 
+    this._filenameSearcher = FilenameSearcher({
+      listing: this._listing,
+      useFuzzyFilter: this._useFuzzyFilter,
+      placeholder: 'Filter files by name'
+    });
+
     this._crumbs.addClass(CRUMBS_CLASS);
     this.toolbar.addClass(TOOLBAR_CLASS);
+    this._filenameSearcher.addClass(FILTERBOX_CLASS);
     this._listing.addClass(LISTING_CLASS);
 
-    const layout = new PanelLayout();
-
-    layout.addWidget(this.toolbar);
-    layout.addWidget(this._crumbs);
-    layout.addWidget(this._listing);
-    this.layout = layout;
+    this.layout = new PanelLayout();
+    this.layout.addWidget(this.toolbar);
+    this.layout.addWidget(this._filenameSearcher);
+    this.layout.addWidget(this._crumbs);
+    this.layout.addWidget(this._listing);
 
     if (options.restore !== false) {
       void model.restore(this.id);
@@ -109,7 +128,7 @@ export class FileBrowser extends Widget {
   /**
    * The model used by the file browser.
    */
-  readonly model: FileBrowserModel;
+  readonly model: FilterFileBrowserModel;
 
   /**
    * The toolbar used by the file browser.
@@ -259,7 +278,10 @@ export class FileBrowser extends Widget {
   /**
    * Handle a connection lost signal from the model.
    */
-  private _onConnectionFailure(sender: FileBrowserModel, args: Error): void {
+  private _onConnectionFailure(
+    sender: FilterFileBrowserModel,
+    args: Error
+  ): void {
     if (
       args instanceof ServerConnection.ResponseError &&
       args.response.status === 404
@@ -281,11 +303,39 @@ export class FileBrowser extends Widget {
     this._navigateToCurrentDirectory = value;
   }
 
+  /**
+   * Whether to use fuzzy filtering on file names.
+   */
+  set useFuzzyFilter(value: boolean) {
+    this._useFuzzyFilter = value;
+
+    this._filenameSearcher = FilenameSearcher({
+      listing: this._listing,
+      useFuzzyFilter: this._useFuzzyFilter,
+      placeholder: 'Filter files by name',
+      forceRefresh: true
+    });
+    this._filenameSearcher.addClass(FILTERBOX_CLASS);
+
+    this.layout.removeWidget(this._filenameSearcher);
+    this.layout.removeWidget(this._crumbs);
+    this.layout.removeWidget(this._listing);
+
+    this.layout.addWidget(this._filenameSearcher);
+    this.layout.addWidget(this._crumbs);
+    this.layout.addWidget(this._listing);
+  }
+
+  // Override Widget.layout with a more specific PanelLayout type.
+  layout: PanelLayout;
+
   private _crumbs: BreadCrumbs;
   private _listing: DirListing;
+  private _filenameSearcher: ReactWidget;
   private _manager: IDocumentManager;
   private _directoryPending: boolean;
   private _navigateToCurrentDirectory: boolean;
+  private _useFuzzyFilter: boolean = true;
 }
 
 /**
@@ -304,7 +354,7 @@ export namespace FileBrowser {
     /**
      * A file browser model instance.
      */
-    model: FileBrowserModel;
+    model: FilterFileBrowserModel;
 
     /**
      * An optional renderer for the directory listing area.

+ 1 - 0
packages/filebrowser/src/index.ts

@@ -7,5 +7,6 @@ export * from './tokens';
 export * from './listing';
 export * from './model';
 export * from './opendialog';
+export * from './search';
 export * from './upload';
 export * from './uploadstatus';

+ 25 - 15
packages/filebrowser/src/listing.ts

@@ -10,6 +10,8 @@ import {
 
 import { PathExt, Time } from '@jupyterlab/coreutils';
 
+import {} from '@jupyterlab/apputils';
+
 import {
   IDocumentManager,
   isValidFileName,
@@ -30,6 +32,7 @@ import {
 import {
   ArrayExt,
   ArrayIterator,
+  StringExt,
   each,
   filter,
   find,
@@ -50,7 +53,9 @@ import { ISignal, Signal } from '@lumino/signaling';
 
 import { Widget } from '@lumino/widgets';
 
-import { FileBrowserModel } from './model';
+import { VirtualDOM, h } from '@lumino/virtualdom';
+
+import { FilterFileBrowserModel } from './model';
 
 /**
  * The class name added to DirListing widget.
@@ -228,7 +233,7 @@ export class DirListing extends Widget {
   /**
    * Get the model used by the listing.
    */
-  get model(): FileBrowserModel {
+  get model(): FilterFileBrowserModel {
     return this._model;
   }
 
@@ -788,15 +793,18 @@ export class DirListing extends Widget {
     each(this._model.sessions(), session => {
       const index = ArrayExt.firstIndexOf(paths, session.path);
       const node = nodes[index];
-      let name = session.kernel?.name;
-      const specs = this._model.specs;
-
-      node.classList.add(RUNNING_CLASS);
-      if (specs && name) {
-        const spec = specs.kernelspecs[name];
-        name = spec ? spec.display_name : 'unknown';
+      // Node may have been filtered out.
+      if (node) {
+        let name = session.kernel?.name;
+        const specs = this._model.specs;
+
+        node.classList.add(RUNNING_CLASS);
+        if (specs && name) {
+          const spec = specs.kernelspecs[name];
+          name = spec ? spec.display_name : 'unknown';
+        }
+        node.title = `${node.title}\nKernel: ${name}`;
       }
-      node.title = `${node.title}\nKernel: ${name}`;
     });
 
     this._prevPath = this._model.path;
@@ -1489,7 +1497,7 @@ export class DirListing extends Widget {
    * Handle a `fileChanged` signal from the model.
    */
   private _onFileChanged(
-    sender: FileBrowserModel,
+    sender: FilterFileBrowserModel,
     args: Contents.IChangedArgs
   ) {
     const newValue = args.newValue;
@@ -1527,7 +1535,7 @@ export class DirListing extends Widget {
     });
   }
 
-  private _model: FileBrowserModel;
+  private _model: FilterFileBrowserModel;
   private _editNode: HTMLInputElement;
   private _items: HTMLElement[] = [];
   private _sortedItems: Contents.IModel[] = [];
@@ -1567,7 +1575,7 @@ export namespace DirListing {
     /**
      * A file browser model instance.
      */
-    model: FileBrowserModel;
+    model: FilterFileBrowserModel;
 
     /**
      * A renderer for file items.
@@ -1881,8 +1889,10 @@ export namespace DirListing {
         node.removeAttribute('data-is-dot');
       }
       // If an item is being edited currently, its text node is unavailable.
-      if (text && text.textContent !== model.name) {
-        text.textContent = model.name;
+      if (text) {
+        const indices = !model.indices ? [] : model.indices;
+        let highlightedName = StringExt.highlight(model.name, indices, h.mark);
+        VirtualDOM.render(h.div(highlightedName), text);
       }
 
       let modText = '';

+ 6 - 0
packages/filebrowser/src/model.ts

@@ -231,6 +231,7 @@ export class FileBrowserModel implements IDisposable {
   async refresh(): Promise<void> {
     await this._poll.refresh();
     await this._poll.tick;
+    this._refreshed.emit(void 0);
   }
 
   /**
@@ -703,6 +704,11 @@ export class FilterFileBrowserModel extends FileBrowserModel {
     });
   }
 
+  setFilter(filter: (value: Contents.IModel) => boolean) {
+    this._filter = filter;
+    void this.refresh();
+  }
+
   private _filter: (value: Contents.IModel) => boolean;
 }
 

+ 161 - 0
packages/filebrowser/src/search.tsx

@@ -0,0 +1,161 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import React, { useState, useEffect } from 'react';
+
+import { StringExt } from '@lumino/algorithm';
+
+import { InputGroup } from '@jupyterlab/ui-components';
+
+import { ReactWidget } from '@jupyterlab/apputils';
+
+import { Contents } from '@jupyterlab/services';
+
+import { DirListing } from './listing';
+
+/**
+ * The class name added to the filebrowser crumbs node.
+ */
+export interface IFilterBoxProps {
+  listing: DirListing;
+  useFuzzyFilter: boolean;
+  placeholder?: string;
+  forceRefresh?: boolean;
+}
+
+/**
+ * A text match score with associated content item.
+ */
+interface IScore {
+  /**
+   * The numerical score for the text match.
+   */
+  score: number;
+
+  /**
+   * The indices of the text matches.
+   */
+  indices: number[] | null;
+
+  /**
+   * The command item associated with the match.
+   */
+  item: Contents.IModel;
+}
+
+/**
+ * Perform a fuzzy search on a single item.
+ */
+function fuzzySearch(item: Contents.IModel, query: string): IScore | null {
+  let source = `${item.name}`;
+
+  // Set up the match score and indices array.
+  let score = Infinity;
+  let indices: number[] | null = null;
+
+  // The regex for search word boundaries
+  const rgx = /\b\w/g;
+
+  let continueSearch = true;
+
+  // Search the source by word boundary.
+  while (continueSearch) {
+    // Find the next word boundary in the source.
+    let rgxMatch = rgx.exec(source);
+
+    // Break if there is no more source context.
+    if (!rgxMatch) {
+      break;
+    }
+
+    // Run the string match on the relevant substring.
+    let match = StringExt.matchSumOfDeltas(source, query, rgxMatch.index);
+
+    // Break if there is no match.
+    if (!match) {
+      break;
+    }
+
+    // Update the match if the score is better.
+    if (match && match.score <= score) {
+      score = match.score;
+      indices = match.indices;
+    }
+  }
+
+  // Bail if there was no match.
+  if (!indices || score === Infinity) {
+    return null;
+  }
+
+  // Handle a split match.
+  return {
+    score,
+    indices,
+    item
+  };
+}
+
+const FilterBox = (props: IFilterBoxProps) => {
+  const [filter, setFilter] = useState('');
+
+  if (props.forceRefresh) {
+    useEffect(() => {
+      props.listing.model.setFilter((item: Contents.IModel) => {
+        return true;
+      });
+    }, []);
+  }
+
+  /**
+   * Handler for search input changes.
+   */
+  const handleChange = (e: React.FormEvent<HTMLElement>) => {
+    const target = e.target as HTMLInputElement;
+    setFilter(target.value);
+    props.listing.model.setFilter((item: Contents.IModel) => {
+      if (props.useFuzzyFilter) {
+        // Run the fuzzy search for the item and query.
+        let score = fuzzySearch(item, target.value);
+        // Ignore the item if it is not a match.
+        if (!score) {
+          item.indices = [];
+          return false;
+        }
+        item.indices = score.indices;
+        return true;
+      }
+      const i = item.name.indexOf(target.value);
+      if (i === -1) {
+        item.indices = [];
+        return false;
+      }
+      item.indices = [...Array(target.value.length).keys()].map(x => x + i);
+      return true;
+    });
+  };
+
+  return (
+    <InputGroup
+      type="text"
+      rightIcon="ui-components:search"
+      placeholder={props.placeholder}
+      onChange={handleChange}
+      value={filter}
+    />
+  );
+};
+
+/**
+ * A widget which hosts a input textbox to filter on file names.
+ */
+export const FilenameSearcher = (props: IFilterBoxProps) => {
+  return ReactWidget.create(
+    <FilterBox
+      listing={props.listing}
+      useFuzzyFilter={props.useFuzzyFilter}
+      placeholder={props.placeholder}
+      forceRefresh={props.forceRefresh}
+    />
+  );
+};

+ 10 - 0
packages/filebrowser/style/base.css

@@ -139,6 +139,12 @@
   background-color: var(--jp-layout-color1);
 }
 
+.jp-DirListing-content mark {
+  color: var(--jp-ui-font-color0);
+  background-color: transparent;
+  font-weight: bold;
+}
+
 /* Style the directory listing content when a user drops a file to upload */
 .jp-DirListing.jp-mod-native-drop .jp-DirListing-content {
   outline: 5px dashed rgba(128, 128, 128, 0.5);
@@ -244,3 +250,7 @@
 .jp-LastModified-hidden {
   display: none;
 }
+
+.jp-FileBrowser-filterBox {
+  padding: 4px;
+}

+ 1 - 1
packages/json-extension/src/component.tsx

@@ -58,7 +58,7 @@ export class Component extends React.Component<IProps, IState> {
           placeholder="Filter..."
           onChange={this.handleChange}
           value={this.state.value}
-          rightIcon="search"
+          rightIcon="ui-components:search"
         />
         <JSONTree
           data={data}

+ 5 - 0
packages/services/src/contents/index.ts

@@ -101,6 +101,11 @@ export namespace Contents {
      * The size of then file in bytes.
      */
     readonly size?: number;
+
+    /**
+     * The indices of the matched characters in the name.
+     */
+    indices?: ReadonlyArray<number> | null;
   }
 
   /**

+ 3 - 6
packages/ui-components/src/blueprint.tsx

@@ -6,10 +6,6 @@ import {
   Button as BPButton,
   IButtonProps as IBPButtonProps
 } from '@blueprintjs/core/lib/cjs/components/button/buttons';
-import {
-  Icon as BPIcon,
-  IIconProps
-} from '@blueprintjs/core/lib/cjs/components/icon/icon';
 import {
   Collapse as BPCollapse,
   ICollapseProps
@@ -26,6 +22,7 @@ import {
   Checkbox as BPCheckbox,
   ICheckboxProps
 } from '@blueprintjs/core/lib/cjs/components/forms/controls';
+import { LabIcon } from './icon';
 export { Intent } from '@blueprintjs/core/lib/cjs/common/intent';
 
 import { classes } from './utils';
@@ -36,7 +33,7 @@ interface IButtonProps extends IBPButtonProps {
 }
 
 interface IInputGroupProps extends IBPInputGroupProps {
-  rightIcon?: IIconProps['icon'];
+  rightIcon?: string;
 }
 
 type CommonProps<T> = React.DOMAttributes<T>;
@@ -60,7 +57,7 @@ export const InputGroup = (props: IInputGroupProps & CommonProps<any>) => {
         className={classes(props.className, 'jp-InputGroup')}
         rightElement={
           <div className="jp-InputGroupAction">
-            <BPIcon className={'jp-BPIcon'} icon={props.rightIcon} />
+            <LabIcon.resolveReact icon={props.rightIcon} />
           </div>
         }
       />