Skip to content

Commit

Permalink
frontend bar, debouncer, task cancellation (#30)
Browse files Browse the repository at this point in the history
* frontend bar, debouncer, task cancellation

* adhere to lint

* add updated yarn.lock

* adhere to eslint

* Change import order

Co-authored-by: Frédéric Collonval <[email protected]>

* add docs for task tracking and cancellation

Co-authored-by: Frédéric Collonval <[email protected]>

* check if task is done before cancellation

Co-authored-by: Frédéric Collonval <[email protected]>

* re-order import for handler

Co-authored-by: Frédéric Collonval <[email protected]>

* access task underneath

Co-authored-by: Frédéric Collonval <[email protected]>
  • Loading branch information
madhur-tandon and fcollonval authored Mar 7, 2022
1 parent 36d28b4 commit aabc6ef
Show file tree
Hide file tree
Showing 7 changed files with 248 additions and 63 deletions.
24 changes: 14 additions & 10 deletions jupyterlab_search_replace/handlers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import json

import tornado
Expand All @@ -20,16 +21,19 @@ async def get(self, path: str = ""):
include = self.get_query_argument("include", None)
exclude = self.get_query_argument("exclude", None)
use_regex = self.get_query_argument("use_regex", False)
r = await self._engine.search(
query,
path,
max_count,
case_sensitive,
whole_word,
include,
exclude,
use_regex,
)
try:
r = await self._engine.search(
query,
path,
max_count,
case_sensitive,
whole_word,
include,
exclude,
use_regex,
)
except asyncio.exceptions.CancelledError:
r = {"code": 1, "message": "task was cancelled"}

if r.get("code") is not None:
self.set_status(500)
Expand Down
10 changes: 8 additions & 2 deletions jupyterlab_search_replace/search_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from functools import partial
from subprocess import Popen, PIPE
from typing import List, Optional, Tuple
from typing import ClassVar, List, Optional, Tuple

import tornado
from jupyter_server.utils import url2path
Expand Down Expand Up @@ -50,6 +50,9 @@ class SearchEngine:
The implementation is using `ripgrep <https://github.com/BurntSushi/ripgrep>`_.
"""

# Keep track of the previous search task to run only one task at a time
search_task: ClassVar[Optional[asyncio.Task]] = None

def __init__(self, root_dir: str) -> None:
"""
Args:
Expand Down Expand Up @@ -122,7 +125,10 @@ async def search(
query, max_count, case_sensitive, whole_word, include, exclude, use_regex
)
cwd = os.path.join(self._root_dir, url2path(path))
code, output = await self._execute(command, cwd=cwd)
if SearchEngine.search_task is not None and not SearchEngine.search_task.done():
SearchEngine.search_task.cancel()
SearchEngine.search_task = asyncio.create_task(self._execute(command, cwd=cwd))
code, output = await SearchEngine.search_task

if code == 0:
matches_per_files = []
Expand Down
30 changes: 30 additions & 0 deletions jupyterlab_search_replace/tests/test_handlers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import asyncio
import json

import pytest
from jsonschema import validate
from tornado.httpclient import HTTPClientError

from ..search_engine import SearchEngine


async def test_search_get(test_content, schema, jp_fetch):
response = await jp_fetch("search", params={"query": "strange"}, method="GET")
Expand Down Expand Up @@ -321,3 +325,29 @@ async def test_search_regex(test_content, schema, jp_fetch):
],
},
]


@pytest.mark.asyncio
async def test_two_search_operations(test_content, schema, jp_root_dir):
engine = SearchEngine(jp_root_dir)
task_1 = asyncio.create_task(engine.search(query="s"))
payload = await asyncio.create_task(engine.search(query="str.*"))
assert task_1.cancelled() == True
validate(instance=payload, schema=schema)
assert len(payload["matches"]) == 1
assert len(payload["matches"][0]["matches"]) == 1
assert sorted(payload["matches"], key=lambda x: x["path"]) == [
{
"path": "test_lab_search_replace/text_1.txt",
"matches": [
{
"line": "Unicode histrange file, very str.*ange\n",
"match": "str.*",
"start": 29,
"end": 34,
"line_number": 1,
"absolute_offset": 0,
},
],
},
]
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"contributors": [
{
"name": "Mariana Meireles"
},
{
"name": "Madhur Tandon"
}
],
"files": [
Expand Down Expand Up @@ -51,6 +54,7 @@
"watch:labextension": "jupyter labextension watch ."
},
"dependencies": {
"@jupyter-notebook/react-components": "^0.6.2",
"@jupyterlab/application": "^3.1.0",
"@jupyterlab/apputils": "^3.1.9",
"@jupyterlab/coreutils": "^5.1.0",
Expand Down
47 changes: 0 additions & 47 deletions src/searchReplace.ts

This file was deleted.

81 changes: 81 additions & 0 deletions src/searchReplace.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';
import { Debouncer } from '@lumino/polling';
import { requestAPI } from './handler';
import { VDomModel, VDomRenderer } from '@jupyterlab/apputils';
import { Search } from '@jupyter-notebook/react-components';

export class SearchReplaceModel extends VDomModel {
constructor() {
super();
this._searchString = '';
this._debouncedStartSearch = new Debouncer(() => {
this.getSearchString(this._searchString);
});
}

get searchString(): string {
return this._searchString;
}

set searchString(v: string) {
if (v !== this._searchString) {
this._searchString = v;
this.stateChanged.emit();
this._debouncedStartSearch
.invoke()
.catch(reason =>
console.error(`failed query for ${v} due to ${reason}`)
);
}
}

get queryResults(): string {
return this._queryResults;
}

async getSearchString(search: string): Promise<void> {
try {
const data = await requestAPI<any>(
'?' + new URLSearchParams([['query', search]]).toString(),
{
method: 'GET'
}
);
this._queryResults = data;
this.stateChanged.emit();
console.log(data);
} catch (reason) {
console.error(
`The jupyterlab_search_replace server extension appears to be missing.\n${reason}`
);
}
}

private _searchString: string;
private _queryResults: any;
private _debouncedStartSearch: Debouncer;
}

//TODO: fix css issue with buttons
export class SearchReplaceView extends VDomRenderer<SearchReplaceModel> {
constructor(searchModel: SearchReplaceModel) {
super(searchModel);
this.addClass('jp-search-replace-tab');
}

render(): JSX.Element | null {
return (
<>
<Search
appearance="outline"
placeholder="<pre>{matches}</pre>"
label="Search"
onInput={(event: any) =>
(this.model.searchString = event.target.value)
}
/>
<pre>{JSON.stringify(this.model.queryResults, undefined, 4)}</pre>
</>
);
}
}
Loading

0 comments on commit aabc6ef

Please sign in to comment.