feat: search with vanilla regexp

This commit is contained in:
Enix 2019-10-10 12:21:25 +08:00
parent 27fe8bf8ef
commit cda88d3956
No known key found for this signature in database
GPG key ID: FE06F5DFC1C9B8E4
7 changed files with 58 additions and 79 deletions

View file

@ -26,7 +26,6 @@ class FileExplorer extends React.Component<Props & ConnectorState> {
static defaultProps: Partial<Props & ConnectorState> = {
freeze: false,
searchKey: '',
searched: false,
visibleNodes: null,
}
@ -126,10 +125,9 @@ class FileExplorer extends React.Component<Props & ConnectorState> {
})
private renderActions: Node['props']['renderActions'] = node => {
const { searchKey, searched, goTo } = this.props
const { searchKey, goTo } = this.props
return (
searchKey &&
searched && (
searchKey && (
<button
title={'Reveal in file tree'}
className={'go-to-button'}
@ -158,7 +156,7 @@ class FileExplorer extends React.Component<Props & ConnectorState> {
visibleNodes,
freeze,
handleKeyDown,
handleSearchKeyChange,
search,
toggleShowSettings,
onFocusSearchBar,
searchKey,
@ -175,11 +173,7 @@ class FileExplorer extends React.Component<Props & ConnectorState> {
) : (
visibleNodes && (
<React.Fragment>
<SearchBar
searchKey={searchKey}
onSearchKeyChange={handleSearchKeyChange}
onFocus={onFocusSearchBar}
/>
<SearchBar searchKey={searchKey} onSearch={search} onFocus={onFocusSearchBar} />
{this.renderFiles(visibleNodes)}
</React.Fragment>
)

View file

@ -1,24 +1,36 @@
import * as React from 'react'
import cx from 'utils/cx'
type Props = {
onSearchKeyChange: React.FormEventHandler
onSearch: (searchKey: string) => void
onFocus: React.FocusEventHandler
searchKey: string
}
export default function SearchBar({ onSearchKeyChange, onFocus, searchKey }: Props) {
export default function SearchBar({ onSearch, onFocus, searchKey }: Props) {
return (
<div className={'search-input-wrapper'}>
<input
onFocus={onFocus}
tabIndex={0}
className="form-control search-input"
className={cx('form-control search-input', {
error: !isValidRegexpSource(searchKey),
})}
aria-label="search files"
placeholder="Search files (RegEx)"
placeholder="Search files (use RegExp)"
type="text"
onChange={onSearchKeyChange}
onChange={({ target: { value } }) => onSearch(value)}
value={searchKey}
/>
</div>
)
}
function isValidRegexpSource(source: string) {
try {
new RegExp(source)
return true
} catch (err) {
return false
}
}

View file

@ -306,6 +306,10 @@
input[type='text'].form-control {
box-shadow: none;
width: 100%;
&.error {
border-color: #d73a49;
}
}
}

View file

@ -4,7 +4,7 @@ import { GetCreatedMethod, MethodCreator } from 'driver/connect'
import * as ini from 'ini'
import { Base64 } from 'js-base64'
import DOMHelper from 'utils/DOMHelper'
import { findNode } from 'utils/general'
import { findNode, searchKeyToRegexps } from 'utils/general'
import GitHubHelper, { BlobData } from 'utils/GitHubHelper'
import treeParser from 'utils/treeParser'
import URLHelper from 'utils/URLHelper'
@ -14,12 +14,12 @@ export type ConnectorState = {
stateText: string
visibleNodes: VisibleNodes | null
searchKey: string
searched: boolean
searched: boolean // derived state from searchKey, = !!searchKey
init: GetCreatedMethod<typeof init>
execAfterRender: GetCreatedMethod<typeof execAfterRender>
handleKeyDown: GetCreatedMethod<typeof handleKeyDown>
handleSearchKeyChange: GetCreatedMethod<typeof handleSearchKeyChange>
search: GetCreatedMethod<typeof search>
onNodeClick: GetCreatedMethod<typeof onNodeClick>
onFocusSearchBar: GetCreatedMethod<typeof onFocusSearchBar>
setUpTree: GetCreatedMethod<typeof setUpTree>
@ -150,7 +150,7 @@ const setUpTree: BoundMethodCreator<
compress: compressSingletonFolder,
})
await visibleNodesGenerator.init()
visibleNodesGenerator.init()
tasksAfterRender.push(DOMHelper.focusSearchInput)
dispatch.call(setStateText, '')
@ -278,28 +278,15 @@ const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent]> = dispatch => eve
const onFocusSearchBar: BoundMethodCreator = dispatch => () => dispatch.call(focusNode, null, false)
const handleSearchKeyChange: BoundMethodCreator<
[React.FormEvent<HTMLInputElement>]
> = dispatch => async event => {
const searchKey = event.currentTarget.value
await dispatch.call(search, searchKey)
}
const search: BoundMethodCreator<[string]> = dispatch => {
let i = 0
return async searchKey => {
dispatch.set({ searchKey })
const j = (i += 1)
await visibleNodesGenerator.search(searchKey)
if (i === j) {
dispatch.set(({ searched }) => ({ searched: !(searched && searchKey === '') }))
dispatch.call(updateVisibleNodes)
}
}
const search: BoundMethodCreator<[string]> = dispatch => searchKey => {
dispatch.set({ searchKey, searched: searchKey !== '' })
const regexps = searchKeyToRegexps(searchKey)
visibleNodesGenerator.search(regexps)
dispatch.call(updateVisibleNodes)
}
const goTo: BoundMethodCreator<[string[]]> = dispatch => async currentPath => {
await visibleNodesGenerator.search('')
visibleNodesGenerator.search([])
tasksAfterRender.push(() => {
const nodeExpandedTo = visibleNodesGenerator.expandTo(currentPath.join('/'))
if (nodeExpandedTo) {
@ -360,7 +347,6 @@ export default {
handleKeyDown,
onFocusSearchBar,
search,
handleSearchKeyChange,
setExpand,
goTo,
toggleNodeExpansion,

View file

@ -9,9 +9,7 @@ export default function cx(...classNames: any[]): string {
case 'string':
return className
case 'object':
return cx(
...Object.entries(className).map(([key, value]) => (Boolean(value) ? key : null)),
)
return cx(...Object.entries(className).map(([key, value]) => (value ? key : null)))
default:
return ''
}

View file

@ -128,3 +128,14 @@ export async function JSONRequest(url: string, data: any, method = 'post') {
body: JSON.stringify(data),
})).json()
}
export function searchKeyToRegexps(searchKey: string) {
if (!searchKey) return []
try {
// case-sensitive when searchKey contains uppercase char
return [new RegExp(searchKey, /[A-Z]/i.test(searchKey) ? '' : 'i')]
} catch (err) {
return [/$^/] // matching nothing if failed transforming regexp
}
}

View file

@ -31,41 +31,15 @@ export type TreeNode = {
accessDenied?: boolean
}
function getFilterFunc(keyRegex: RegExp) {
return function filterFunc({ name }: TreeNode) {
return keyRegex.test(name)
}
}
function filterDuplications<T>(arr: T[]) {
return Array.from(new Set(arr))
}
function search(treeNodes: TreeNode[], searchKey: string): TreeNode[] {
if (!searchKey) return treeNodes
/**
* if searchKey is 'abcd'
* then keyRegex will be /abcd/i and /a.*?b.*?c.*?d/i
*/
const regexpGenerators: ((raw: string) => RegExp)[] = [
raw => new RegExp(raw, 'i'),
raw => new RegExp(raw.split('').join('.*?'), 'i'),
]
const keyRegexes: RegExp[] = []
for (const generator of regexpGenerators) {
try {
const regExp = generator(searchKey)
if (keyRegexes.find(keyRegex => keyRegex.source === regExp.source)) continue
// prevent duplicated regExp
keyRegexes.push(regExp)
} catch (err) {
// ignore invalid regexp
}
}
function search(treeNodes: TreeNode[], regexps: RegExp[]): TreeNode[] {
if (!regexps.length) return treeNodes
const searchResults = ([] as TreeNode[]).concat(
...keyRegexes.map(keyRegex => treeNodes.filter(getFilterFunc(keyRegex))),
...regexps.map(keyRegex => treeNodes.filter(({ name }) => keyRegex.test(name))),
)
return filterDuplications(searchResults)
}
@ -121,10 +95,10 @@ class L2 {
this.searchedNodes = null
}
search = async (searchKey: string) => {
this.compressed = !searchKey
this.searchedNodes = searchKey
? search(this.l1.nodes, searchKey)
search = (regexps: RegExp[]) => {
this.compressed = !regexps.length
this.searchedNodes = regexps.length
? search(this.l1.nodes, regexps)
: this.getRoot().contents || []
}
@ -246,8 +220,8 @@ export default class VisibleNodesGenerator {
this.l3 = new L3(this.l1, this.l2)
this.l4 = new L4(this.l1, this.l2, this.l3)
this.search = async (...args) => {
const r = await this.l2.search(...args)
this.search = (...args) => {
const r = this.l2.search(...args)
this.l3.generateVisibleNodes()
this.l4.focusNode(null)
return r
@ -258,8 +232,8 @@ export default class VisibleNodesGenerator {
this.focusNode = (...args) => this.l4.focusNode(...args)
}
async init() {
await this.l2.search('')
init() {
this.l2.search([])
this.l3.generateVisibleNodes()
}