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

UI for replace action #51

Merged
merged 5 commits into from
Apr 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 29 additions & 32 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 ClassVar, Dict, List, Optional, Tuple
from typing import ClassVar, List, Optional, Tuple

import tornado
from jupyter_server.utils import url2path
Expand Down Expand Up @@ -193,35 +193,32 @@ def group_matches_by_line(self, line_matches):
d[each_line_number] = sorted(d[each_line_number], key=lambda tup: tup[0])
return d

def replace(self, results: Dict, prefix_path: str, query: str):
def replace(self, results: List, prefix_path: str, query: str):
query = bytes(query, "utf-8")
if "matches" in results:
for file_match in results["matches"]:
file_path = file_match["path"]
line_matches = file_match["matches"]

file_path = os.path.join(
self._root_dir, prefix_path, url2path(file_path)
)
grouped_line_matches = self.group_matches_by_line(line_matches)

with open(file_path, "rb") as fp:
data = fp.readlines()
for line_number, offsets in grouped_line_matches.items():
original_line = data[line_number - 1]
replaced_line = b""
start = 0
end = offsets[0][0]
replaced_line += original_line[start:end] + query
for i in range(len(offsets)):
if i + 1 < len(offsets):
end = offsets[i + 1][0]
start = offsets[i][1]
if start < end:
replaced_line += original_line[start:end] + query
else:
replaced_line += original_line[start:]
data[line_number - 1] = replaced_line

with open(file_path, "wb") as fp:
fp.writelines(data)
for each_result in results:
file_path = each_result["path"]
line_matches = each_result["matches"]

file_path = os.path.join(self._root_dir, url2path(prefix_path), file_path)
grouped_line_matches = self.group_matches_by_line(line_matches)

with open(file_path, "rb") as fp:
data = fp.readlines()
for line_number, offsets in grouped_line_matches.items():
original_line = data[line_number - 1]
replaced_line = b""
start = 0
end = offsets[0][0]
replaced_line += original_line[start:end] + query
for i in range(len(offsets)):
if i + 1 < len(offsets):
end = offsets[i + 1][0]
start = offsets[i][1]
if start < end:
replaced_line += original_line[start:end] + query
else:
replaced_line += original_line[start:]
data[line_number - 1] = replaced_line

with open(file_path, "wb") as fp:
fp.writelines(data)
4 changes: 3 additions & 1 deletion jupyterlab_search_replace/tests/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,9 @@ async def test_replace_operation(test_content, schema, jp_fetch):
payload = json.loads(response.body)
validate(instance=payload, schema=schema)
response = jp_fetch(
"search", body=json.dumps({"results": payload, "query": "hello"}), method="POST"
"search",
body=json.dumps({"results": payload["matches"], "query": "hello"}),
method="POST",
)
response = await jp_fetch(
"search", params={"query": "hello", "exclude": "*_1.txt"}, method="GET"
Expand Down
6 changes: 6 additions & 0 deletions src/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { LabIcon } from '@jupyterlab/ui-components';
import wholeWord from '../style/icons/whole-word.svg';
import expandAll from '../style/icons/expand-all.svg';
import collapseAll from '../style/icons/collapse-all.svg';
import replaceAll from '../style/icons/replace-all.svg';

export const wholeWordIcon = new LabIcon({
name: 'search-replace:wholeWord',
Expand All @@ -18,3 +19,8 @@ export const collapseAllIcon = new LabIcon({
name: 'search-replace:collapseAll',
svgstr: collapseAll
});

export const replaceAllIcon = new LabIcon({
name: 'search-replace:replaceAll',
svgstr: replaceAll
});
68 changes: 67 additions & 1 deletion src/searchReplace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { Debouncer } from '@lumino/polling';
import { CommandRegistry } from '@lumino/commands';
import { requestAPI } from './handler';
import { VDomModel, VDomRenderer } from '@jupyterlab/apputils';
import { wholeWordIcon, expandAllIcon, collapseAllIcon } from './icon';
import {
wholeWordIcon,
expandAllIcon,
collapseAllIcon,
replaceAllIcon
} from './icon';
import {
Search,
TreeView,
Expand Down Expand Up @@ -36,6 +41,7 @@ export class SearchReplaceModel extends VDomModel {
this._filesFilter = '';
this._excludeToggle = false;
this._path = '';
this._replaceString = '';
this._debouncedStartSearch = new Debouncer(() => {
this.getSearchString(
this._searchString,
Expand Down Expand Up @@ -154,6 +160,17 @@ export class SearchReplaceModel extends VDomModel {
}
}

get replaceString(): string {
return this._replaceString;
}

set replaceString(v: string) {
if (v !== this._replaceString) {
this._replaceString = v;
this.stateChanged.emit();
}
}

private async getSearchString(
search: string,
caseSensitive: boolean,
Expand Down Expand Up @@ -201,8 +218,27 @@ export class SearchReplaceModel extends VDomModel {
}
}

async postReplaceString(results: IResults[]): Promise<void> {
try {
await requestAPI<void>(this.path, {
method: 'POST',
body: JSON.stringify({
results,
query: this.replaceString
})
});
} catch (reason) {
console.error(
`The jupyterlab_search_replace server extension appears to be missing.\n${reason}`
);
} finally {
this.refreshResults();
}
}

private _isLoading: boolean;
private _searchString: string;
private _replaceString: string;
private _caseSensitive: boolean;
private _wholeWord: boolean;
private _useRegex: boolean;
Expand Down Expand Up @@ -317,6 +353,13 @@ export class SearchReplaceView extends VDomRenderer<SearchReplaceModel> {
onExcludeToggle={(v: boolean) => {
this.model.excludeToggle = v;
}}
replaceString={this.model.replaceString}
onReplaceString={(s: string) => {
this.model.replaceString = s;
}}
onReplace={(r: IResults[]) => {
this.model.postReplaceString(r);
}}
fileFilter={this.model.filesFilter}
onFileFilter={(s: string) => {
this.model.filesFilter = s;
Expand Down Expand Up @@ -374,6 +417,9 @@ interface IProps {
onExcludeToggle: (v: boolean) => void;
fileFilter: string;
onFileFilter: (s: string) => void;
replaceString: string;
onReplaceString: (s: string) => void;
onReplace: (r: IResults[]) => void;
children: React.ReactNode;
refreshResults: () => void;
path: string;
Expand Down Expand Up @@ -473,6 +519,26 @@ const SearchReplaceElement = (props: IProps) => {
/>
{props.children}
</div>
<div className="replace-bar-with-button">
<TextField
appearance="outline"
placeholder="Replace"
onInput={(event: any) => {
props.onReplaceString(event.target.value);
}}
value={props.replaceString}
>
Replace
</TextField>
<Button
title="button to replace all matches with query"
onClick={() => {
props.onReplace(props.queryResults);
}}
>
<replaceAllIcon.react></replaceAllIcon.react>
</Button>
</div>
<div>
<TextField
appearance="outline"
Expand Down
4 changes: 4 additions & 0 deletions style/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
display: flex;
}

.replace-bar-with-button {
display: flex;
}

.search-title-with-refresh {
display: flex;
}
Expand Down
3 changes: 3 additions & 0 deletions style/icons/replace-all.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions ui-tests/tests/search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,56 @@ test('should expand and collapse tree view on clicking expand-collapse button',
await page.waitForTimeout(20);
expect(await page.locator('.search-tree-files').getAttribute('aria-expanded')).toEqual("true");
});


test('should replace results on replace-all button', async ({ page }) => {
// Click #tab-key-0 .lm-TabBar-tabIcon svg >> nth=0
await page.locator('[title="Search and replace"]').click();
// Fill input[type="search"]
await page.locator('input[type="search"]').fill('strange');

await Promise.all([
page.waitForResponse(
response =>
/.*search\/[\w-]+\?query=strange/.test(response.url()) &&
response.request().method() === 'GET'
),
page.locator('input[type="search"]').press('Enter'),
page.waitForSelector('.jp-search-replace-tab >> .jp-progress', {
state: 'hidden'
})
]);

expect(
await page.waitForSelector('jp-tree-view[role="tree"] >> text=5')
).toBeTruthy();

await expect(page.locator('jp-tree-item:nth-child(4)')).toHaveText(
' "Is that Strange enough?",'
);

await page.locator('#jp-search-replace >> text=Replace >> [placeholder="Replace"]').click();
await page.locator('#jp-search-replace >> text=Replace >> [placeholder="Replace"]').fill('hello');
await page.locator('[title="button to replace all matches with query"]').click();

await page.locator('input[type="search"]').fill('hello');
await Promise.all([
page.waitForResponse(
response =>
/.*search\/[\w-]+\?query=hello/.test(response.url()) &&
response.request().method() === 'GET'
),
page.locator('input[type="search"]').press('Enter'),
page.waitForSelector('.jp-search-replace-tab >> .jp-progress', {
state: 'hidden'
})
]);

expect(
await page.waitForSelector('jp-tree-view[role="tree"] >> text=5')
).toBeTruthy();

await expect(page.locator('jp-tree-item:nth-child(4)')).toHaveText(
' "Is that hello enough?",'
);
});
Loading