Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Convert CSV to use document searcher #5937

Merged
merged 12 commits into from Feb 4, 2019
4 changes: 0 additions & 4 deletions packages/codemirror-extension/src/index.ts
Expand Up @@ -398,10 +398,6 @@ function activateEditorCommands(
// Add find-replace capabilities to the edit menu.
mainMenu.editMenu.findReplacers.add({
tracker,
find: (widget: IDocumentWidget<FileEditor>) => {
let editor = widget.content.editor as CodeMirrorEditor;
editor.execCommand('find');
},
findAndReplace: (widget: IDocumentWidget<FileEditor>) => {
let editor = widget.content.editor as CodeMirrorEditor;
editor.execCommand('replace');
Expand Down
4 changes: 3 additions & 1 deletion packages/csvviewer-extension/package.json
Expand Up @@ -34,8 +34,10 @@
"@jupyterlab/apputils": "^0.19.1",
"@jupyterlab/csvviewer": "^0.19.1",
"@jupyterlab/docregistry": "^0.19.1",
"@jupyterlab/documentsearch-extension": "^0.19.1",
ian-r-rose marked this conversation as resolved.
Show resolved Hide resolved
"@jupyterlab/mainmenu": "^0.8.1",
"@phosphor/datagrid": "^0.1.6"
"@phosphor/datagrid": "^0.1.6",
"@phosphor/signaling": "^1.2.2"
},
"devDependencies": {
"rimraf": "~2.6.2",
Expand Down
32 changes: 15 additions & 17 deletions packages/csvviewer-extension/src/index.ts
Expand Up @@ -9,6 +9,8 @@ import {

import { InstanceTracker, IThemeManager, Dialog } from '@jupyterlab/apputils';

import { ISearchProviderRegistry } from '@jupyterlab/documentsearch-extension';

import {
CSVViewer,
TextRenderConfig,
Expand All @@ -21,6 +23,7 @@ import { IDocumentWidget } from '@jupyterlab/docregistry';
import { DataGrid } from '@phosphor/datagrid';

import { IMainMenu, IEditMenu } from '@jupyterlab/mainmenu';
import { CSVSearchProvider } from './searchprovider';

/**
* The name of the factories that creates widgets.
Expand All @@ -35,6 +38,7 @@ const csv: JupyterFrontEndPlugin<void> = {
activate: activateCsv,
id: '@jupyterlab/csvviewer-extension:csv',
requires: [ILayoutRestorer, IThemeManager, IMainMenu],
optional: [ISearchProviderRegistry],
autoStart: true
};

Expand All @@ -45,6 +49,7 @@ const tsv: JupyterFrontEndPlugin<void> = {
activate: activateTsv,
id: '@jupyterlab/csvviewer-extension:tsv',
requires: [ILayoutRestorer, IThemeManager, IMainMenu],
optional: [ISearchProviderRegistry],
autoStart: true
};

Expand All @@ -55,21 +60,6 @@ function addMenuEntries(
mainMenu: IMainMenu,
tracker: InstanceTracker<IDocumentWidget<CSVViewer>>
) {
// Add find capability to the edit menu.
mainMenu.editMenu.findReplacers.add({
tracker,
find: (widget: IDocumentWidget<CSVViewer>) => {
return Dialog.prompt<string>(
'Search Text',
widget.content.searchService.searchText
).then(value => {
if (value.button.accept) {
widget.content.searchService.find(value.value);
}
});
}
} as IEditMenu.IFindReplacer<IDocumentWidget<CSVViewer>>);

// Add go to line capability to the edit menu.
mainMenu.editMenu.goToLiners.add({
tracker,
Expand All @@ -90,7 +80,8 @@ function activateCsv(
app: JupyterFrontEnd,
restorer: ILayoutRestorer,
themeManager: IThemeManager,
mainMenu: IMainMenu
mainMenu: IMainMenu,
searchregistry: ISearchProviderRegistry = null
): void {
const factory = new CSVViewerFactory({
name: FACTORY_CSV,
Expand Down Expand Up @@ -147,6 +138,9 @@ function activateCsv(
themeManager.themeChanged.connect(updateThemes);

addMenuEntries(mainMenu, tracker);
if (searchregistry) {
searchregistry.registerProvider('csv', CSVSearchProvider);
}
}

/**
Expand All @@ -156,7 +150,8 @@ function activateTsv(
app: JupyterFrontEnd,
restorer: ILayoutRestorer,
themeManager: IThemeManager,
mainMenu: IMainMenu
mainMenu: IMainMenu,
searchregistry: ISearchProviderRegistry = null
): void {
const factory = new TSVViewerFactory({
name: FACTORY_TSV,
Expand Down Expand Up @@ -213,6 +208,9 @@ function activateTsv(
themeManager.themeChanged.connect(updateThemes);

addMenuEntries(mainMenu, tracker);
if (searchregistry) {
searchregistry.registerProvider('tsv', CSVSearchProvider);
}
}

/**
Expand Down
103 changes: 103 additions & 0 deletions packages/csvviewer-extension/src/searchprovider.ts
@@ -0,0 +1,103 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import {
ISearchProvider,
ISearchMatch
} from '@jupyterlab/documentsearch-extension';
import { CSVViewer } from '@jupyterlab/csvviewer';
import { IDocumentWidget, DocumentWidget } from '@jupyterlab/docregistry';
import { Signal, ISignal } from '@phosphor/signaling';

export class CSVSearchProvider implements ISearchProvider {
/**
* Report whether or not this provider has the ability to search on the given object
*/
static canSearchOn(domain: any): boolean {
ian-r-rose marked this conversation as resolved.
Show resolved Hide resolved
// check to see if the CMSearchProvider can search on the
// first cell, false indicates another editor is present
return (
domain instanceof DocumentWidget && domain.content instanceof CSVViewer
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other places in the codebase we would do a check on an InstanceTracker, which seems safer and more idiomatic. Is there a reason for the instanceof checks instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tracker won't work here because the csv viewer doesn't expose a tracker. Granted that's a problem with the csv viewer, but I was trying to limit scope here in this PR.

);
}

/**
* Initialize the search using the provided options. Should update the UI
* to highlight all matches and "select" whatever the first match should be.
*
* @param query A RegExp to be use to perform the search
* @param searchTarget The widget to be searched
*
* @returns A promise that resolves with a list of all matches
*/
async startQuery(
query: RegExp,
searchTarget: IDocumentWidget<CSVViewer>
): Promise<ISearchMatch[]> {
this._target = searchTarget;
this._query = query;
searchTarget.content.searchService.find(query);
return this.matches;
}

/**
* Clears state of a search provider to prepare for startQuery to be called
* in order to start a new query or refresh an existing one.
*
* @returns A promise that resolves when the search provider is ready to
* begin a new search.
*/
async endQuery(): Promise<void> {
this._target.content.searchService.clear();
}

/**
* Resets UI state as it was before the search process began. Cleans up and
* disposes of all internal state.
*
* @returns A promise that resolves when all state has been cleaned up.
*/
async endSearch(): Promise<void> {
this._target.content.searchService.clear();
}

/**
* Move the current match indicator to the next match.
*
* @returns A promise that resolves once the action has completed.
*/
async highlightNext(): Promise<ISearchMatch | undefined> {
this._target.content.searchService.find(this._query);
return undefined;
}

/**
* Move the current match indicator to the previous match.
*
* @returns A promise that resolves once the action has completed.
*/
highlightPrevious(): Promise<ISearchMatch | undefined> {
this._target.content.searchService.find(this._query, true);
return undefined;
}

/**
* Signal indicating that something in the search has changed, so the UI should update
*/
get changed(): ISignal<this, void> {
return this._changed;
}

/**
* The same list of matches provided by the startQuery promise resoluton
*/
readonly matches: ISearchMatch[] = [];

/**
* The current index of the selected match.
*/
readonly currentMatchIndex: number | null = null;

private _target: IDocumentWidget<CSVViewer>;
private _query: RegExp;
private _changed = new Signal<this, void>(this);
}
3 changes: 3 additions & 0 deletions packages/csvviewer-extension/tsconfig.json
Expand Up @@ -18,6 +18,9 @@
{
"path": "../docregistry"
},
{
"path": "../documentsearch-extension"
},
{
"path": "../mainmenu"
}
Expand Down
102 changes: 82 additions & 20 deletions packages/csvviewer/src/widget.ts
Expand Up @@ -68,22 +68,22 @@ export class TextRenderConfig {
export class GridSearchService {
constructor(grid: DataGrid) {
this._grid = grid;
this._searchText = '';
this._query = null;
this._row = 0;
this._column = 0;
this._column = -1;
}

/**
* Returs a cellrenderer config function to render each cell background.
* Returns a cellrenderer config function to render each cell background.
* If cell match, background is matchBackgroundColor, if it's the current
* match, background is currentMatchBackgroundColor.
*/
cellBackgroundColorRendererFunc(
config: TextRenderConfig
): CellRenderer.ConfigFunc<string> {
return ({ value, row, column }) => {
if (this._searchText) {
if ((value as string).indexOf(this._searchText) !== -1) {
if (this._query) {
if ((value as string).match(this._query)) {
if (this._row === row && this._column === column) {
return config.currentMatchBackgroundColor;
}
Expand All @@ -93,18 +93,30 @@ export class GridSearchService {
};
}

/**
* Clear the search.
*/
clear() {
this._query = null;
this._row = 0;
this._column = -1;
this._grid.repaint();
}

/**
* incrementally look for searchText.
*/
find(searchText: string) {
find(query: RegExp, reverse = false): boolean {
const model = this._grid.model;
if (this._searchText !== searchText) {
const rowCount = model.rowCount('body');
const columnCount = model.columnCount('body');

if (this._query !== query) {
// reset search
this._row = 0;
this._column = -1;
}
this._column++; // incremental search
this._searchText = searchText;
this._query = query;

// check if the match is in current viewport
const minRow = this._grid.scrollY / this._grid.baseRowSize;
Expand All @@ -122,40 +134,90 @@ export class GridSearchService {
);
};

for (; this._row < model.rowCount('body'); this._row++) {
for (; this._column < model.columnCount('body'); this._column++) {
const cellData = model.data('body', this._row, this._column) as string;
if (cellData.indexOf(searchText) !== -1) {
const increment = reverse ? -1 : 1;
this._column += increment;
for (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this perform on a large CSV? Is there any danger of this hanging?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. I left the searching logic basically alone, so it probably performs on par with what it was before (with the exception that now I do a regex match instead of a string lookup).

let row = this._row;
reverse ? row >= 0 : row < rowCount;
row += increment
) {
for (
let col = this._column;
reverse ? col >= 0 : col < columnCount;
col += increment
) {
const cellData = model.data('body', row, col) as string;
if (cellData.match(query)) {
// to update the background of matching cells.

// TODO: we only really need to invalidate the previous and current
// cell rects, not the entire grid.
this._grid.repaint();

if (!isMatchInViewport()) {
// scroll the matching cell into view
let scrollX = this._grid.scrollX;
let scrollY = this._grid.scrollY;
/* see also https://github.com/jupyterlab/jupyterlab/pull/5523#issuecomment-432621391 */
for (let i = scrollY; i < this._row - 1; i++) {
for (let i = scrollY; i < row - 1; i++) {
scrollY += this._grid.sectionSize('row', i);
}
for (let j = scrollX; j < this._column - 1; j++) {
for (let j = scrollX; j < col - 1; j++) {
scrollX += this._grid.sectionSize('column', j);
}
this._grid.scrollTo(scrollX, scrollY);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scrolling into view doesn't seem to be working in my testing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks for pointing it out.

}
return;
this._row = row;
this._column = col;
return true;
}
}
this._column = 0;
this._column = reverse ? columnCount - 1 : 0;
}
// We've finished searching all the way to the limits of the grid. If this
// is the first time through (looping is true), wrap the indices and search
// again. Otherwise, give up.
if (this._looping) {
this._looping = false;
this._row = reverse ? 0 : rowCount - 1;
this._wrapRows(reverse);
try {
return this.find(query, reverse);
} finally {
this._looping = true;
}
}
return false;
}

/**
* Wrap indices if needed to just before the start or just after the end.
*/
private _wrapRows(reverse = false) {
const model = this._grid.model;
const rowCount = model.rowCount('body');
const columnCount = model.columnCount('body');

if (reverse && this._row <= 0) {
// if we are at the front, wrap to just past the end.
this._row = rowCount - 1;
this._column = columnCount;
} else if (!reverse && this._row >= rowCount - 1) {
// if we are at the end, wrap to just before the front.
this._row = 0;
this._column = -1;
}
}

get searchText(): string {
return this._searchText;
get query(): RegExp {
return this._query;
}

private _grid: DataGrid;
private _searchText: string;
private _query: RegExp | null;
private _row: number;
private _column: number;
private _looping = true;
}

/**
Expand Down