From bf7c80fcbd1a34c17b4057d736dcf16889804ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Thu, 21 Apr 2022 17:43:56 +0200 Subject: [PATCH] Style the panel (#56) * Style the panel * Improve contrast of replace buttons in tree view * Load icons directly from codicons * Fix locators for integration tests * Locator fixes --- README.md | 4 +- src/icon.ts | 10 +- src/index.ts | 3 +- src/model.ts | 251 +++++++++++ src/searchReplace.tsx | 619 -------------------------- src/view.tsx | 463 +++++++++++++++++++ style/base.css | 123 ++++- style/icons/collapse-all.svg | 4 - style/icons/expand-all.svg | 5 - style/icons/replace-all.svg | 3 - style/icons/replace.svg | 3 - style/icons/whole-word.svg | 5 - ui-tests/tests/fileFilters.spec.ts | 12 +- ui-tests/tests/replacePerItem.spec.ts | 11 +- ui-tests/tests/search.spec.ts | 10 +- 15 files changed, 849 insertions(+), 677 deletions(-) create mode 100644 src/model.ts delete mode 100644 src/searchReplace.tsx create mode 100644 src/view.tsx delete mode 100644 style/icons/collapse-all.svg delete mode 100644 style/icons/expand-all.svg delete mode 100644 style/icons/replace-all.svg delete mode 100644 style/icons/replace.svg delete mode 100644 style/icons/whole-word.svg diff --git a/README.md b/README.md index c1db35f..96b0a0d 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ **WIP this is very early work in progress and nothing is yet working.** But don't hesitate to open issues and PRs if you want to help. -[![Extension status](https://img.shields.io/badge/status-draft-critical "Not yet working")](https://jupyterlab-contrib.github.io/index.html) [![Build](https://github.com/jupyterlab-contrib/search-replace/actions/workflows/build.yml/badge.svg)](https://github.com/jupyterlab-contrib/search-replace/actions/workflows/build.yml) [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/jupyterlab-contrib/search-replace.git/main?urlpath=lab) +[![Extension status](https://img.shields.io/badge/status-draft-critical 'Not yet working')](https://jupyterlab-contrib.github.io/index.html) [![Build](https://github.com/jupyterlab-contrib/search-replace/actions/workflows/build.yml/badge.svg)](https://github.com/jupyterlab-contrib/search-replace/actions/workflows/build.yml) [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/jupyterlab-contrib/search-replace.git/main?urlpath=lab) -Search and replace accross files +Search and replace accross files. This extension is composed of a Python package named `jupyterlab_search_replace` for the server extension and a NPM package named `search-replace` diff --git a/src/icon.ts b/src/icon.ts index de43edc..f065b10 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -1,10 +1,10 @@ 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'; -import replace from '../style/icons/replace.svg'; +import wholeWord from '@vscode/codicons/src/icons/whole-word.svg'; +import expandAll from '@vscode/codicons/src/icons/expand-all.svg'; +import collapseAll from '@vscode/codicons/src/icons/collapse-all.svg'; +import replaceAll from '@vscode/codicons/src/icons/replace-all.svg'; +import replace from '@vscode/codicons/src/icons/replace.svg'; export const wholeWordIcon = new LabIcon({ name: 'search-replace:wholeWord', diff --git a/src/index.ts b/src/index.ts index ee962cd..b04ea86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,8 @@ import { IChangedArgs } from '@jupyterlab/coreutils'; import { FileBrowserModel, IFileBrowserFactory } from '@jupyterlab/filebrowser'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { searchIcon } from '@jupyterlab/ui-components'; -import { SearchReplaceModel, SearchReplaceView } from './searchReplace'; +import { SearchReplaceView } from './view'; +import { SearchReplaceModel } from './model'; /** * Initialization data for the search-replace extension. diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..68605bb --- /dev/null +++ b/src/model.ts @@ -0,0 +1,251 @@ +import { VDomModel } from '@jupyterlab/apputils'; +import { Debouncer } from '@lumino/polling'; +import { requestAPI } from './handler'; + +export interface IQueryResult { + matches: IResults[]; +} + +/** + * Interface to represent matches in a file + * @interface IResults + * @member path -- path of file + * @member matches -- all matches within that file + * @field line -- line containing the match + * @field start -- starting offset of the match + * @field end -- ending offset of the match + * @field match -- the actual match itself + * @field line_number -- the line number where the match occurs + * @field absolute_offset -- the offset from the beginning of file + */ +export interface IResults { + path: string; + matches: { + line: string; + start: number; + end: number; + match: string; + line_number: number; + absolute_offset: number; + }[]; +} + +export class SearchReplaceModel extends VDomModel { + constructor() { + super(); + this._isLoading = false; + this._searchString = ''; + this._queryResults = []; + this._caseSensitive = false; + this._wholeWord = false; + this._useRegex = false; + this._filesFilter = ''; + this._excludeToggle = false; + this._path = ''; + this._replaceString = ''; + this._debouncedStartSearch = new Debouncer(() => { + this.getSearchString( + this._searchString, + this._caseSensitive, + this._wholeWord, + this._useRegex, + this._filesFilter, + this._excludeToggle, + this._path + ); + }); + } + + refreshResults(): void { + this._debouncedStartSearch + .invoke() + .catch(reason => console.error(`failed query for due to ${reason}`)); + } + + get isLoading(): boolean { + return this._isLoading; + } + + set isLoading(v: boolean) { + if (v !== this._isLoading) { + this._isLoading = v; + this.stateChanged.emit(); + } + } + + get searchString(): string { + return this._searchString; + } + + set searchString(v: string) { + if (v !== this._searchString) { + this._searchString = v; + this.stateChanged.emit(); + this.refreshResults(); + } + } + + get caseSensitive(): boolean { + return this._caseSensitive; + } + + set caseSensitive(v: boolean) { + if (v !== this._caseSensitive) { + this._caseSensitive = v; + this.stateChanged.emit(); + this.refreshResults(); + } + } + + get wholeWord(): boolean { + return this._wholeWord; + } + + set wholeWord(v: boolean) { + if (v !== this._wholeWord) { + this._wholeWord = v; + this.stateChanged.emit(); + this.refreshResults(); + } + } + + get useRegex(): boolean { + return this._useRegex; + } + + set useRegex(v: boolean) { + if (v !== this._useRegex) { + this._useRegex = v; + this.stateChanged.emit(); + this.refreshResults(); + } + } + + get filesFilter(): string { + return this._filesFilter; + } + + set filesFilter(v: string) { + if (v !== this._filesFilter) { + this._filesFilter = v; + this.stateChanged.emit(); + this.refreshResults(); + } + } + + get excludeToggle(): boolean { + return this._excludeToggle; + } + + set excludeToggle(v: boolean) { + if (v !== this._excludeToggle) { + this._excludeToggle = v; + this.stateChanged.emit(); + this.refreshResults(); + } + } + + get queryResults(): IResults[] { + return this._queryResults; + } + + get path(): string { + return this._path; + } + + set path(v: string) { + if (v !== this._path) { + this._path = v; + this.stateChanged.emit(); + this.refreshResults(); + } + } + + 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, + wholeWord: boolean, + useRegex: boolean, + includeFiles: string, + excludeToggle: boolean, + path: string + ): Promise { + if (search === '') { + this._queryResults = []; + this.stateChanged.emit(); + return Promise.resolve(); + } + try { + this.isLoading = true; + let excludeFiles = ''; + if (excludeToggle) { + excludeFiles = includeFiles; + includeFiles = ''; + } + const data = await requestAPI( + path + + '?' + + new URLSearchParams([ + ['query', search], + ['case_sensitive', caseSensitive.toString()], + ['whole_word', wholeWord.toString()], + ['use_regex', useRegex.toString()], + ['include', includeFiles], + ['exclude', excludeFiles] + ]).toString(), + { + method: 'GET' + } + ); + this._queryResults = data.matches; + this.stateChanged.emit(); + } catch (reason) { + console.error( + `The jupyterlab_search_replace server extension appears to be missing.\n${reason}` + ); + } finally { + this.isLoading = false; + } + } + + async postReplaceString(results: IResults[]): Promise { + try { + await requestAPI(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; + private _filesFilter: string; + private _excludeToggle: boolean; + private _path: string; + private _queryResults: IResults[]; + private _debouncedStartSearch: Debouncer; +} diff --git a/src/searchReplace.tsx b/src/searchReplace.tsx deleted file mode 100644 index f97db5f..0000000 --- a/src/searchReplace.tsx +++ /dev/null @@ -1,619 +0,0 @@ -import { - Badge, - Breadcrumb, - BreadcrumbItem, - Button, - Progress, - Search, - Switch, - TextField, - Toolbar, - TreeItem, - TreeView -} from '@jupyter-notebook/react-components'; -import { VDomModel, VDomRenderer } from '@jupyterlab/apputils'; -import { PathExt } from '@jupyterlab/coreutils'; -import { TranslationBundle } from '@jupyterlab/translation'; -import { - caseSensitiveIcon, - folderIcon, - refreshIcon, - regexIcon -} from '@jupyterlab/ui-components'; -import { CommandRegistry } from '@lumino/commands'; -import { Debouncer } from '@lumino/polling'; -import React, { useEffect, useState } from 'react'; -import { requestAPI } from './handler'; -import { - collapseAllIcon, - expandAllIcon, - replaceAllIcon, - replaceIcon, - wholeWordIcon -} from './icon'; - -export class SearchReplaceModel extends VDomModel { - constructor() { - super(); - this._isLoading = false; - this._searchString = ''; - this._queryResults = []; - this._caseSensitive = false; - this._wholeWord = false; - this._useRegex = false; - this._filesFilter = ''; - this._excludeToggle = false; - this._path = ''; - this._replaceString = ''; - this._debouncedStartSearch = new Debouncer(() => { - this.getSearchString( - this._searchString, - this._caseSensitive, - this._wholeWord, - this._useRegex, - this._filesFilter, - this._excludeToggle, - this._path - ); - }); - } - - refreshResults(): void { - this._debouncedStartSearch - .invoke() - .catch(reason => console.error(`failed query for due to ${reason}`)); - } - - get isLoading(): boolean { - return this._isLoading; - } - - set isLoading(v: boolean) { - if (v !== this._isLoading) { - this._isLoading = v; - this.stateChanged.emit(); - } - } - - get searchString(): string { - return this._searchString; - } - - set searchString(v: string) { - if (v !== this._searchString) { - this._searchString = v; - this.stateChanged.emit(); - this.refreshResults(); - } - } - - get caseSensitive(): boolean { - return this._caseSensitive; - } - - set caseSensitive(v: boolean) { - if (v !== this._caseSensitive) { - this._caseSensitive = v; - this.stateChanged.emit(); - this.refreshResults(); - } - } - - get wholeWord(): boolean { - return this._wholeWord; - } - - set wholeWord(v: boolean) { - if (v !== this._wholeWord) { - this._wholeWord = v; - this.stateChanged.emit(); - this.refreshResults(); - } - } - - get useRegex(): boolean { - return this._useRegex; - } - - set useRegex(v: boolean) { - if (v !== this._useRegex) { - this._useRegex = v; - this.stateChanged.emit(); - this.refreshResults(); - } - } - - get filesFilter(): string { - return this._filesFilter; - } - - set filesFilter(v: string) { - if (v !== this._filesFilter) { - this._filesFilter = v; - this.stateChanged.emit(); - this.refreshResults(); - } - } - - get excludeToggle(): boolean { - return this._excludeToggle; - } - - set excludeToggle(v: boolean) { - if (v !== this._excludeToggle) { - this._excludeToggle = v; - this.stateChanged.emit(); - this.refreshResults(); - } - } - - get queryResults(): IResults[] { - return this._queryResults; - } - - get path(): string { - return this._path; - } - - set path(v: string) { - if (v !== this._path) { - this._path = v; - this.stateChanged.emit(); - this.refreshResults(); - } - } - - 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, - wholeWord: boolean, - useRegex: boolean, - includeFiles: string, - excludeToggle: boolean, - path: string - ): Promise { - if (search === '') { - this._queryResults = []; - this.stateChanged.emit(); - return Promise.resolve(); - } - try { - this.isLoading = true; - let excludeFiles = ''; - if (excludeToggle) { - excludeFiles = includeFiles; - includeFiles = ''; - } - const data = await requestAPI( - path + - '?' + - new URLSearchParams([ - ['query', search], - ['case_sensitive', caseSensitive.toString()], - ['whole_word', wholeWord.toString()], - ['use_regex', useRegex.toString()], - ['include', includeFiles], - ['exclude', excludeFiles] - ]).toString(), - { - method: 'GET' - } - ); - this._queryResults = data.matches; - this.stateChanged.emit(); - } catch (reason) { - console.error( - `The jupyterlab_search_replace server extension appears to be missing.\n${reason}` - ); - } finally { - this.isLoading = false; - } - } - - async postReplaceString(results: IResults[]): Promise { - try { - await requestAPI(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; - private _filesFilter: string; - private _excludeToggle: boolean; - private _path: string; - private _queryResults: IResults[]; - private _debouncedStartSearch: Debouncer; -} - -interface IQueryResult { - matches: IResults[]; -} - -/** - * Interface to represent matches in a file - * @interface IResults - * @member path -- path of file - * @member matches -- all matches within that file - * @field line -- line containing the match - * @field start -- starting offset of the match - * @field end -- ending offset of the match - * @field match -- the actual match itself - * @field line_number -- the line number where the match occurs - * @field absolute_offset -- the offset from the beginning of file - */ -interface IResults { - path: string; - matches: { - line: string; - start: number; - end: number; - match: string; - line_number: number; - absolute_offset: number; - }[]; -} - -function openFile(prefixDir: string, path: string, _commands: CommandRegistry) { - _commands.execute('docmanager:open', { path: PathExt.join(prefixDir, path) }); -} - -function createTreeView( - results: IResults[], - path: string, - _commands: CommandRegistry, - expandStatus: boolean[], - setExpandStatus: (v: boolean[]) => void, - onReplace: (r: IResults[]) => void, - trans: TranslationBundle -): JSX.Element { - results.sort((a, b) => (a.path > b.path ? 1 : -1)); - const items = results.map((file, index) => { - return ( - { - const expandStatusNew = [...expandStatus]; - expandStatusNew[index] = !expandStatusNew[index]; - setExpandStatus(expandStatusNew); - }} - > - {file.path} - - {file.matches.length} - {file.matches.map(match => ( - { - openFile(path, file.path, _commands); - event.stopPropagation(); - }} - > - - {match.line.slice(0, match.start)} - {match.match} - {match.line.slice(match.end)} - - - - ))} - - ); - }); - - if (items.length === 0) { - return

{trans.__('No Matches Found')}

; - } else { - return ( -
- {items} -
- ); - } -} - -//TODO: fix css issue with buttons -export class SearchReplaceView extends VDomRenderer { - private _commands: CommandRegistry; - - constructor( - searchModel: SearchReplaceModel, - commands: CommandRegistry, - protected trans: TranslationBundle - ) { - super(searchModel); - this._commands = commands; - this.addClass('jp-search-replace-tab'); - } - - render(): JSX.Element | null { - return ( - { - this.model.searchString = s; - }} - replaceString={this.model.replaceString} - onReplaceString={(s: string) => { - this.model.replaceString = s; - }} - onReplace={(r: IResults[]) => { - this.model.postReplaceString(r); - }} - commands={this._commands} - isLoading={this.model.isLoading} - queryResults={this.model.queryResults} - refreshResults={() => { - this.model.refreshResults(); - }} - path={this.model.path} - onPathChanged={(s: string) => { - this.model.path = s; - }} - options={ - <> - - - - - } - trans={this.trans} - > - { - this.model.filesFilter = event.target.value; - }} - value={this.model.filesFilter} - > - {this.trans.__('File filters')} - - { - this.model.excludeToggle = event.target.checked; - }} - checked={this.model.excludeToggle} - > - - {this.trans.__('Files to Exclude')} - - - {this.trans.__('Files to Include')} - - - - ); - } -} - -interface ISearchReplaceProps { - searchString: string; - queryResults: IResults[]; - commands: CommandRegistry; - isLoading: boolean; - onSearchChanged: (s: string) => void; - replaceString: string; - onReplaceString: (s: string) => void; - onReplace: (r: IResults[]) => void; - children: React.ReactNode; - refreshResults: () => void; - path: string; - onPathChanged: (s: string) => void; - options: React.ReactNode; - trans: TranslationBundle; -} - -interface IBreadcrumbProps { - path: string; - onPathChanged: (s: string) => void; -} - -const Breadcrumbs = (props: IBreadcrumbProps) => { - const pathItems = props.path.split('/'); - return ( - - - - - {props.path && - pathItems.map((item, index) => { - return ( - - - - ); - })} - - ); -}; - -const SearchReplaceElement = (props: ISearchReplaceProps) => { - const [expandStatus, setExpandStatus] = useState( - new Array(props.queryResults.length).fill(true) - ); - - useEffect(() => { - setExpandStatus(new Array(props.queryResults.length).fill(true)); - }, [props.queryResults]); - - const collapseAll = expandStatus.some(elem => elem); - - return ( - <> -
-

{props.trans.__('Search')}

- - -
-
- -
-
- { - props.onSearchChanged(event.target.value); - }} - value={props.searchString} - /> - {props.options} -
-
- { - props.onReplaceString(event.target.value); - }} - value={props.replaceString} - > - -
-
{props.children}
- {props.isLoading ? ( - - ) : ( - props.searchString && - createTreeView( - props.queryResults, - props.path, - props.commands, - expandStatus, - setExpandStatus, - props.onReplace, - props.trans - ) - )} - - ); -}; diff --git a/src/view.tsx b/src/view.tsx new file mode 100644 index 0000000..1f06343 --- /dev/null +++ b/src/view.tsx @@ -0,0 +1,463 @@ +import { + Badge, + Breadcrumb, + BreadcrumbItem, + Button, + Progress, + Search, + Switch, + TextField, + Toolbar, + TreeItem, + TreeView +} from '@jupyter-notebook/react-components'; +import { VDomRenderer } from '@jupyterlab/apputils'; +import { PathExt } from '@jupyterlab/coreutils'; +import { TranslationBundle } from '@jupyterlab/translation'; +import { + caretDownIcon, + caretRightIcon, + caseSensitiveIcon, + ellipsesIcon, + folderIcon, + refreshIcon, + regexIcon +} from '@jupyterlab/ui-components'; +import { CommandRegistry } from '@lumino/commands'; +import React, { useEffect, useState } from 'react'; +import { + collapseAllIcon, + expandAllIcon, + replaceAllIcon, + replaceIcon, + wholeWordIcon +} from './icon'; +import { IResults, SearchReplaceModel } from './model'; + +function openFile(prefixDir: string, path: string, _commands: CommandRegistry) { + _commands.execute('docmanager:open', { path: PathExt.join(prefixDir, path) }); +} + +function createTreeView( + results: IResults[], + path: string, + _commands: CommandRegistry, + expandStatus: boolean[], + setExpandStatus: (v: boolean[]) => void, + onReplace: ((r: IResults[]) => void) | null, + trans: TranslationBundle +): JSX.Element { + results.sort((a, b) => (a.path > b.path ? 1 : -1)); + const items = results.map((file, index) => { + return ( + { + const expandStatusNew = [...expandStatus]; + expandStatusNew[index] = !expandStatusNew[index]; + setExpandStatus(expandStatusNew); + }} + > + {file.path} + {onReplace && ( + + )} + {file.matches.length} + {file.matches.map(match => ( + { + openFile(path, file.path, _commands); + event.stopPropagation(); + }} + > + + {match.line.slice(0, match.start)} + {match.match} + {match.line.slice(match.end)} + + {onReplace && ( + + )} + + ))} + + ); + }); + + if (items.length === 0) { + return

{trans.__('No Matches Found')}

; + } else { + return ( +
+ {items} +
+ ); + } +} + +export class SearchReplaceView extends VDomRenderer { + private _commands: CommandRegistry; + + constructor( + searchModel: SearchReplaceModel, + commands: CommandRegistry, + protected trans: TranslationBundle + ) { + super(searchModel); + this._commands = commands; + this.addClass('jp-search-replace-tab'); + } + + render(): JSX.Element | null { + return ( + { + this.model.searchString = s; + }} + replaceString={this.model.replaceString} + onReplaceString={(s: string) => { + this.model.replaceString = s; + }} + onReplace={(r: IResults[]) => { + this.model.postReplaceString(r); + }} + commands={this._commands} + isLoading={this.model.isLoading} + queryResults={this.model.queryResults} + refreshResults={() => { + this.model.refreshResults(); + }} + path={this.model.path} + onPathChanged={(s: string) => { + this.model.path = s; + }} + options={ + <> + + + + + } + trans={this.trans} + > + { + this.model.filesFilter = f; + }} + onIsExcludeChange={(b: boolean) => { + this.model.excludeToggle = b; + }} + trans={this.trans} + > + + ); + } +} + +interface IBreadcrumbProps { + path: string; + onPathChanged: (s: string) => void; +} + +const Breadcrumbs = React.memo((props: IBreadcrumbProps) => { + const pathItems = props.path.split('/'); + return ( + + + + + {props.path && + pathItems.map((item, index) => { + return ( + + + + ); + })} + + ); +}); + +interface ISearchReplaceProps { + searchString: string; + queryResults: IResults[]; + commands: CommandRegistry; + isLoading: boolean; + onSearchChanged: (s: string) => void; + replaceString: string; + onReplaceString: (s: string) => void; + onReplace: (r: IResults[]) => void; + children: React.ReactNode; + refreshResults: () => void; + path: string; + onPathChanged: (s: string) => void; + options: React.ReactNode; + trans: TranslationBundle; +} + +const SearchReplaceElement = (props: ISearchReplaceProps) => { + const [showReplace, setShowReplace] = useState(false); + const [expandStatus, setExpandStatus] = useState( + new Array(props.queryResults.length).fill(true) + ); + + useEffect(() => { + setExpandStatus(new Array(props.queryResults.length).fill(true)); + }, [props.queryResults]); + + const collapseAll = expandStatus.some(elem => elem); + const canReplace = + showReplace && props.replaceString !== '' && props.queryResults.length > 0; + + return ( + <> +
+
+

{props.trans.__('Search')}

+
+ + + + +
+ +
+
+ +
+
+ { + props.onSearchChanged(event.target.value); + }} + onInput={(event: any) => { + props.onSearchChanged(event.target.value); + }} + value={props.searchString} + /> + {props.options} +
+ {showReplace && ( +
+ { + props.onReplaceString(event.target.value); + }} + value={props.replaceString} + > + +
+ )} +
+
+ {props.children} + {props.isLoading ? ( + + ) : ( + props.searchString && + createTreeView( + props.queryResults, + props.path, + props.commands, + expandStatus, + setExpandStatus, + canReplace ? props.onReplace : null, + props.trans + ) + )} + + ); +}; + +interface IFilterBoxProps { + filters: string; + onFilterChange: (f: string) => void; + isExcludeFilter: boolean; + onIsExcludeChange: (b: boolean) => void; + trans: TranslationBundle; +} + +const FilterBox = React.memo((props: IFilterBoxProps) => { + const [show, setShow] = useState(false); + const { filters, onFilterChange, isExcludeFilter, onIsExcludeChange, trans } = + props; + return ( +
+ + {show && ( + <> + { + onFilterChange(event.target.value); + }} + onInput={(event: any) => { + onFilterChange(event.target.value); + }} + value={filters} + > + {trans.__('File filters')} + + { + onIsExcludeChange(event.target.checked); + }} + checked={isExcludeFilter} + > + {trans.__('Files to Exclude')} + {trans.__('Files to Include')} + + + )} +
+ ); +}); diff --git a/style/base.css b/style/base.css index 94b5fe9..91d57ff 100644 --- a/style/base.css +++ b/style/base.css @@ -1,16 +1,102 @@ .jp-search-replace-tab { - background: var(--jp-layout-color0); + --density: -3; display: flex; flex-direction: column; + background: var(--jp-layout-color1); + color: var(--jp-ui-font-color1); } -.jp-search-replace-tab > * { - padding: 5px; +.jp-search-replace-column { + display: flex; + flex-direction: column; +} + +.jp-search-replace-row { + display: flex; + align-items: center; +} + +.jp-search-replace-tab jp-toolbar { + background-color: inherit; +} + +.jp-search-replace-tab jp-toolbar, +.jp-search-replace-tab jp-button.jp-mod-icon-only { + --design-unit: 3.5; +} + +.jp-search-replace-tab jp-button.jp-mod-icon-only::part(control) { + line-height: 0; + padding: 0; +} + +.jp-search-replace-tab .jp-stack-panel-header { + border-bottom: solid var(--jp-border-width) var(--jp-border-color1); + box-shadow: var(--jp-toolbar-box-shadow); + display: flex; + flex-direction: column; + padding: 2px 8px; +} + +.jp-search-replace-row > jp-search, +.jp-search-replace-row > jp-text-field { + flex: 1 1 auto; +} + +.jp-search-replace-row > jp-toolbar { + flex: 0 0 auto; +} + +.jp-search-replace-header { + height: 24px; +} + +.jp-search-replace-header > h2 { + text-transform: uppercase; + font-weight: 600; + font-size: var(--jp-ui-font-size1); + margin: 0; +} + +.jp-search-replace-tab .jp-Toolbar-spacer { + flex: 1 1 0; +} + +.jp-search-replace-row.jp-search-replace-collapser { + align-items: flex-start; + padding: 4px 4px 2px 0px; +} + +/* Fix JupyterLab icons colors */ +.jp-search-replace-tab .jp-icon2[fill] { + fill: none; +} + +.jp-search-replace-tab .jp-icon-accent2[fill] { + fill: currentColor; +} + +.jp-search-replace-filtersBox { + flex: 0 0 auto; + position: relative; + padding: 4px 8px; + display: flex; + flex-direction: column; + min-height: 6px; +} + +.jp-search-replace-filters-collapser { + position: absolute; + top: -4px; + right: 0; + max-height: 16px; + margin: 0; } .jp-search-replace-list { overflow-y: auto; flex-grow: 1; + flex-shrink: 1; } .jp-search-replace-list > jp-tree-view { @@ -21,6 +107,14 @@ background: none; } +.jp-search-replace-tab > p { + padding: 4px 8px; +} + +.jp-search-replace-tab > jp-progress { + padding: 4px; +} + .search-tree-files > span, .search-tree-matches > span { text-overflow: ellipsis; @@ -30,24 +124,17 @@ .search-tree-files > span { flex-grow: 1; + flex-shrink: 1; } -.search-bar-with-options { - display: flex; -} - -.replace-bar-with-button { - display: flex; +.jp-search-replace-item-button { + display: none; + margin-left: auto; } -.search-title-with-refresh { +jp-tree-item:hover > .jp-search-replace-item-button, +jp-tree-item:active > .jp-search-replace-item-button, +jp-tree-item:focus-visible > .jp-search-replace-item-button, +jp-tree-item:focus-within > .jp-search-replace-item-button { display: flex; } - -.search-bar-with-options > * { - flex: 0 0 auto; -} - -.search-bar-with-options > jp-search:first-child { - flex: 1 1 auto; -} diff --git a/style/icons/collapse-all.svg b/style/icons/collapse-all.svg deleted file mode 100644 index c0e4cfa..0000000 --- a/style/icons/collapse-all.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/style/icons/expand-all.svg b/style/icons/expand-all.svg deleted file mode 100644 index 924c7c5..0000000 --- a/style/icons/expand-all.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/style/icons/replace-all.svg b/style/icons/replace-all.svg deleted file mode 100644 index e4e8341..0000000 --- a/style/icons/replace-all.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/style/icons/replace.svg b/style/icons/replace.svg deleted file mode 100644 index 888f9fe..0000000 --- a/style/icons/replace.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/style/icons/whole-word.svg b/style/icons/whole-word.svg deleted file mode 100644 index 3c28d26..0000000 --- a/style/icons/whole-word.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/ui-tests/tests/fileFilters.spec.ts b/ui-tests/tests/fileFilters.spec.ts index caf987a..126c2ed 100644 --- a/ui-tests/tests/fileFilters.spec.ts +++ b/ui-tests/tests/fileFilters.spec.ts @@ -47,6 +47,10 @@ test('should test for include filter', async ({ page }) => { }) ]); + await page + .locator('#jp-search-replace >> .jp-search-replace-filters-collapser') + .click(); + await Promise.all([ page.waitForResponse( response => @@ -54,7 +58,7 @@ test('should test for include filter', async ({ page }) => { response.request().method() === 'GET' ), await page - .locator('text=File filters >> [placeholder="Files\\ filter"]') + .locator('text=File filters >> [placeholder="e.g. *.py, src/**/include"]') .fill('conftest.py') ]); @@ -83,6 +87,10 @@ test('should test for exclude filter', async ({ page }) => { }) ]); + await page + .locator('#jp-search-replace >> .jp-search-replace-filters-collapser') + .click(); + await page.locator('[title="Toggle File Filter Mode"]').click(); await Promise.all([ @@ -92,7 +100,7 @@ test('should test for exclude filter', async ({ page }) => { response.request().method() === 'GET' ), await page - .locator('text=File filters >> [placeholder="Files\\ filter"]') + .locator('text=File filters >> [placeholder="e.g. *.py, src/**/include"]') .fill('conftest.py') ]); diff --git a/ui-tests/tests/replacePerItem.spec.ts b/ui-tests/tests/replacePerItem.spec.ts index 3b3be6f..9c3486c 100644 --- a/ui-tests/tests/replacePerItem.spec.ts +++ b/ui-tests/tests/replacePerItem.spec.ts @@ -55,6 +55,7 @@ test('should replace results for a particular file only', async ({ page }) => { '.search-tree-files:has-text("conftest.py") >> .search-tree-matches:has-text(\' "Is that Strange enough?",\')' ); + await page.locator('#jp-search-replace >> [title="Toggle Replace"]').click(); await page .locator('#jp-search-replace >> jp-text-field[placeholder="Replace"]') .click(); @@ -62,12 +63,10 @@ test('should replace results for a particular file only', async ({ page }) => { .locator('#jp-search-replace >> input[placeholder="Replace"]') .fill('hello'); + const entry = page.locator('.search-tree-files:has-text("conftest.py")'); + await entry.hover(); // press replace all matches for `conftest.py` only - await page - .locator( - '.search-tree-files:has-text("conftest.py") >> [title="Replace All in File"]' - ) - .click(); + await entry.locator('[title="Replace All in File"]').click(); // new results for previous query 'strange' should only have `test_handlers.py` await page.waitForTimeout(800); @@ -129,6 +128,7 @@ test('should replace results for a particular match only', async ({ page }) => { ); await itemMatch.first().waitFor(); + await page.locator('#jp-search-replace >> [title="Toggle Replace"]').click(); await page .locator('#jp-search-replace >> jp-text-field[placeholder="Replace"]') .click(); @@ -136,6 +136,7 @@ test('should replace results for a particular match only', async ({ page }) => { .locator('#jp-search-replace >> input[placeholder="Replace"]') .fill('helloqs'); + await itemMatch.nth(1).hover(); // press replace match for a particular match in `test_handlers.py` only await itemMatch.nth(1).locator('[title="Replace"]').click(); diff --git a/ui-tests/tests/search.spec.ts b/ui-tests/tests/search.spec.ts index 0eb933a..85cc3e9 100644 --- a/ui-tests/tests/search.spec.ts +++ b/ui-tests/tests/search.spec.ts @@ -44,11 +44,11 @@ test('should get 5 matches', async ({ page }) => { await page.waitForSelector('jp-tree-view[role="tree"] >> text=5') ).toBeTruthy(); - await expect(page.locator('jp-tree-item:nth-child(5)')).toHaveText( + await expect(page.locator('jp-tree-item').nth(2)).toHaveText( ' "Is that Strange enough?",' ); - await page.locator('jp-tree-item:nth-child(5)').click(); + await page.locator('jp-tree-item').nth(2).click(); await expect(page).toHaveURL( 'http://localhost:8888/lab/tree/search-replace-test/conftest.py' ); @@ -237,10 +237,11 @@ test('should replace results on replace-all button', async ({ page }) => { await page.waitForSelector('jp-tree-view[role="tree"] >> text=5') ).toBeTruthy(); - await expect(page.locator('jp-tree-item:nth-child(5)')).toHaveText( + await expect(page.locator('jp-tree-item').nth(2)).toHaveText( ' "Is that Strange enough?",' ); + await page.locator('#jp-search-replace >> [title="Toggle Replace"]').click(); await page .locator('#jp-search-replace >> jp-text-field[placeholder="Replace"]') .click(); @@ -265,8 +266,7 @@ test('should replace results on replace-all button', async ({ page }) => { expect( await page.waitForSelector('jp-tree-view[role="tree"] >> text=5') ).toBeTruthy(); - - await expect(page.locator('jp-tree-item:nth-child(5)')).toHaveText( + await expect(page.locator('jp-tree-item').nth(2)).toHaveText( ' "Is that hello enough?",' ); });