feat: search modes

This commit is contained in:
EnixCoda 2021-02-03 00:38:22 +08:00
parent 8737611c17
commit e1d0742de8
No known key found for this signature in database
GPG key ID: 0C1A07377913A1DD
9 changed files with 229 additions and 123 deletions

View file

@ -11,28 +11,49 @@ import * as React from 'react'
import { FixedSizeList, ListChildComponentProps } from 'react-window'
import { cx } from 'utils/cx'
import { focusFileExplorer } from 'utils/DOMHelper'
import { isValidRegexpSource } from 'utils/general'
import { useOnLocationChange } from 'utils/hooks/useOnLocationChange'
import { useOnPJAXDone } from 'utils/hooks/usePJAX'
import { VisibleNodes } from 'utils/VisibleNodesGenerator'
import { Icon } from './Icon'
import { searchModes } from './searchModes'
import { SizeObserver } from './SizeObserver'
type renderNodeContext = {
onNodeClick: (event: React.MouseEvent<HTMLElement, MouseEvent>, node: TreeNode) => void
renderLabelText: (node: TreeNode) => React.ReactNode
renderActions: ((node: TreeNode) => React.ReactNode) | undefined
visibleNodes: VisibleNodes
}
const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplorer(props) {
const {
state,
visibleNodes,
visibleNodesGenerator,
freeze,
onNodeClick,
searchKey,
updateSearchKey,
onFocusSearchBar,
goTo,
handleKeyDown,
toggleShowSettings,
metaData,
expandTo,
setUpTree,
treeRoot,
defer,
searched,
} = props
const { value: config } = useConfigs()
const {
value: { accessToken, compressSingletonFolder, searchMode },
} = useConfigs()
React.useEffect(() => {
if (visibleNodesGenerator) {
visibleNodesGenerator.search(searchModes[searchMode].getSearchParams(searchKey))
}
}, [visibleNodesGenerator, searchKey, searchMode])
React.useEffect(() => {
if (treeRoot) {
@ -40,43 +61,78 @@ const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplor
treeRoot,
metaData,
config: {
compressSingletonFolder: config.compressSingletonFolder,
accessToken: config.accessToken,
compressSingletonFolder,
accessToken,
searchMode,
},
})
}
}, [setUpTree, treeRoot, metaData, config.compressSingletonFolder, config.accessToken])
}, [setUpTree, treeRoot, metaData, compressSingletonFolder, accessToken])
React.useEffect(() => {
if (visibleNodes?.focusedNode) focusFileExplorer()
})
const renderActions: ((node: TreeNode) => React.ReactNode) | undefined = React.useMemo(
const renderActions: ((node: TreeNode) => React.ReactNode) | undefined = React.useMemo(() => {
const renderGoToButton = (node: TreeNode): React.ReactNode => (
<button
title={'Reveal in file tree'}
className={'go-to-button'}
onClick={e => {
e.stopPropagation()
e.preventDefault()
goTo(node.path.split('/'))
}}
>
<Icon type="go-to" />
</button>
)
const renderFindInFolderButton = (node: TreeNode): React.ReactNode =>
node.type === 'tree' ? (
<button
title={'Find in folder...'}
className={'find-in-folder-button'}
onClick={e => {
e.stopPropagation()
e.preventDefault()
updateSearchKey(node.path + '/')
}}
>
<Icon type="search" />
</button>
) : undefined
const renders: ((node: TreeNode) => React.ReactNode)[] = []
if (searchMode === 'fuzzy') renders.push(renderFindInFolderButton)
if (searched) renders.push(renderGoToButton)
return renders.length
? node => renders.map((render, i) => <React.Fragment key={i}>{render(node)}</React.Fragment>)
: undefined
}, [goTo, updateSearchKey, searched, searchMode])
const renderLabelText = React.useCallback(
node => searchModes[searchMode].renderNodeLabelText(node, searchKey),
[searchKey, searchMode],
)
const renderNodeContext: renderNodeContext | null = React.useMemo(
() =>
visibleNodes?.lastMatch?.match.searchKey
? node => (
<button
title={'Reveal in file tree'}
className={'go-to-button'}
onClick={e => {
e.stopPropagation()
e.preventDefault()
goTo(node.path.split('/'))
}}
>
<Icon type="go-to" />
</button>
)
: undefined,
[visibleNodes, goTo],
visibleNodes && {
onNodeClick,
renderActions,
renderLabelText,
visibleNodes,
},
[onNodeClick, renderActions, renderLabelText, visibleNodes],
)
return (
<div
className={cx(`file-explorer`, { freeze })}
tabIndex={-1}
onKeyDown={props.handleKeyDown}
onClick={freeze ? props.toggleShowSettings : undefined}
onKeyDown={handleKeyDown}
onClick={freeze ? toggleShowSettings : undefined}
>
{state !== 'done' ? (
<LoadingIndicator
@ -88,14 +144,15 @@ const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplor
}
/>
) : (
visibleNodes && (
visibleNodes &&
renderNodeContext && (
<>
<SearchBar
searchKey={searchKey}
onSearch={props.search}
onFocus={props.onFocusSearchBar}
value={searchKey}
onSearch={value => updateSearchKey(value)}
onFocus={onFocusSearchBar}
/>
{visibleNodes.lastMatch?.match.searchKey !== '' && visibleNodes.nodes.length === 0 && (
{searched && visibleNodes.nodes.length === 0 && (
<>
<Text marginTop={6} textAlign="center" color="text.gray">
No results found.
@ -112,9 +169,7 @@ const RawFileExplorer: React.FC<Props & ConnectorState> = function RawFileExplor
<ListView
height={height}
width={width}
onNodeClick={onNodeClick}
renderActions={renderActions}
visibleNodes={visibleNodes}
renderNodeContext={renderNodeContext}
expandTo={expandTo}
metaData={metaData}
/>
@ -139,21 +194,13 @@ export const FileExplorer = connect(FileExplorerCore)(RawFileExplorer)
const VirtualNode = React.memo(function VirtualNode({
index,
style,
data,
}: ListChildComponentProps) {
const { onNodeClick, renderActions, visibleNodes } = data
data: { onNodeClick, renderLabelText, renderActions, visibleNodes },
}: Override<ListChildComponentProps, { data: renderNodeContext }>) {
if (!visibleNodes) return null
const {
lastMatch,
nodes,
focusedNode,
expandedNodes,
loading,
depths,
} = visibleNodes as VisibleNodes
const { nodes, focusedNode, expandedNodes, loading, depths } = visibleNodes as VisibleNodes
const node = nodes[index]
const searchKey = lastMatch?.match.searchKey
return (
<Node
style={style}
@ -164,8 +211,8 @@ const VirtualNode = React.memo(function VirtualNode({
loading={loading.has(node.path)}
expanded={expandedNodes.has(node.path)}
onClick={onNodeClick}
renderLabelText={renderLabelText}
renderActions={renderActions}
regex={searchKey && isValidRegexpSource(searchKey) ? new RegExp(searchKey, 'gi') : undefined}
/>
)
})
@ -173,31 +220,23 @@ const VirtualNode = React.memo(function VirtualNode({
type ListViewProps = {
height: number
width: number
onNodeClick(event: React.MouseEvent<HTMLElement, MouseEvent>, node: TreeNode): void
renderActions?(node: TreeNode): React.ReactNode
visibleNodes: VisibleNodes
}
renderNodeContext: renderNodeContext
} & Pick<Props, 'metaData'> &
Pick<ConnectorState, 'expandTo'>
function ListView({
width,
height,
metaData,
expandTo,
onNodeClick,
renderActions,
visibleNodes,
}: ListViewProps & Pick<Props, 'metaData'> & Pick<ConnectorState, 'expandTo'>) {
function ListView({ width, height, metaData, expandTo, renderNodeContext }: ListViewProps) {
const { visibleNodes } = renderNodeContext
const { focusedNode, nodes } = visibleNodes
const listRef = React.useRef<FixedSizeList>(null)
// the change of depths indicates switch into/from search state
React.useEffect(() => {
const { focusedNode, nodes } = visibleNodes
if (listRef.current && focusedNode?.path) {
const index = nodes.findIndex(node => node.path === focusedNode.path)
if (index !== -1) {
listRef.current.scrollToItem(index, 'smart')
}
}
}, [visibleNodes])
}, [focusedNode, nodes])
// For some reason, removing the deps array above results in bug:
// If scroll fast and far, then clicking on items would result in redirect
// Not know the reason :(
@ -210,20 +249,11 @@ function ListView({
useOnLocationChange(goToCurrentItem)
useOnPJAXDone(goToCurrentItem)
const itemData = React.useMemo(
() => ({
onNodeClick,
renderActions,
visibleNodes,
}),
[onNodeClick, renderActions, visibleNodes],
)
return (
<FixedSizeList
ref={listRef}
itemKey={(index, { visibleNodes }) => visibleNodes?.nodes[index]?.path}
itemData={itemData}
itemData={renderNodeContext}
itemCount={visibleNodes.nodes.length}
itemSize={37}
height={height}

View file

@ -15,6 +15,7 @@ import {
MarkdownIcon as Markdown,
OctofaceIcon as Octoface,
ReplyIcon as Reply,
SearchIcon as Search,
XIcon as X,
} from '@primer/octicons-react'
import * as React from 'react'
@ -27,6 +28,11 @@ function getSVGIconComponent(
name: string
} {
switch (type) {
case 'search':
return {
IconComponent: Search,
name: 'Search',
}
case 'loading':
return {
IconComponent: Clock,

View file

@ -3,7 +3,6 @@ import * as React from 'react'
import { cx } from 'utils/cx'
import { OperatingSystems, os } from 'utils/general'
import { getFileIconSrc, getFolderIconSrc } from '../utils/parseIconMapCSV'
import { Highlight } from './Highlight'
import { Icon } from './Icon'
function getIconType(node: TreeNode) {
@ -25,8 +24,8 @@ type Props = {
focused: boolean
loading: boolean
renderActions?(node: TreeNode): React.ReactNode
renderLabelText(node: TreeNode): React.ReactNode
style?: React.CSSProperties
regex?: RegExp
}
export function Node({
node,
@ -35,11 +34,10 @@ export function Node({
focused,
loading,
renderActions,
renderLabelText,
style,
onClick,
regex,
}: Props) {
const { name, path } = node
return (
<a
href={node.url}
@ -57,20 +55,11 @@ export function Node({
}}
className={cx(`node-item`, { focused, disabled: node.accessDenied, expanded })}
style={{ ...style, paddingLeft: `${10 + 20 * depth}px` }}
title={path}
title={node.path}
>
<div className={'node-item-label'}>
<NodeItemIcon node={node} open={expanded} loading={loading} />
{name.includes('/') ? (
name.split('/').map((chunk, index, arr) => (
<span key={chunk} className={cx({ prefix: index + 1 !== arr.length })}>
<Highlight match={regex} text={chunk} />
{index + 1 !== arr.length && '/'}
</span>
))
) : (
<Highlight match={regex} text={name} />
)}
{renderLabelText(node)}
</div>
{renderActions && <div>{renderActions(node)}</div>}
</a>

View file

@ -1,16 +1,19 @@
import { TextInput } from '@primer/components'
import { Label, TextInput, TextInputProps } from '@primer/components'
import { SearchIcon } from '@primer/octicons-react'
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { cx } from 'utils/cx'
import { isValidRegexpSource } from 'utils/general'
type Props = {
value: string
onSearch: (searchKey: string) => void
onFocus: React.FocusEventHandler
searchKey: string
}
} & Required<Pick<TextInputProps, 'onFocus'>>
export function SearchBar({ onSearch, onFocus, value }: Props) {
const configs = useConfigs()
const { searchMode } = configs.value
export function SearchBar({ onSearch, onFocus, searchKey }: Props) {
return (
<div className={'search-input-wrapper'}>
<TextInput
@ -22,13 +25,30 @@ export function SearchBar({ onSearch, onFocus, searchKey }: Props) {
}}
tabIndex={0}
className={cx('search-input', {
error: !isValidRegexpSource(searchKey),
error: searchMode === 'regex' && !isValidRegexpSource(value),
})}
aria-label="search files"
placeholder="Search files (use RegExp)"
placeholder={`Search files (${searchMode === 'regex' ? 'use RegExp' : 'match sequence'})`}
onChange={({ target: { value } }) => onSearch(value)}
value={searchKey}
value={value}
/>
<div className={`actions`}>
<Label
className={`toggle-mode`}
variant="small"
outline
title="Toggle search mode"
onClick={() => {
configs.onChange({
searchMode: searchMode === 'regex' ? 'fuzzy' : 'regex',
})
onSearch('')
}}
aria-label="Toggle search mode"
>
{searchMode === 'regex' ? '.*?' : 'Seq'}
</Label>
</div>
</div>
)
}

View file

@ -1,9 +1,12 @@
import * as React from 'react'
import { cx } from 'utils/cx'
import { ModeShape } from '.'
import { Highlight } from '../Highlight'
export const fuzzyMode: ModeShape = {
getSearchParams(searchKey) {
if (!searchKey) return null
const matchNode = (node: TreeNode) => fuzzyMatch(searchKey, node.path)
return {
matchNode,
@ -11,15 +14,22 @@ export const fuzzyMode: ModeShape = {
},
renderNodeLabelText(node, searchKey) {
const { name, path } = node
const indexes = fuzzyMatchIndexes(searchKey, path, path.length - name.length)
return (
<span>
<Highlight
match={new RegExp(indexes.map(i => (i === 0 ? `^.` : `(?<=^.{${i}}).`)).join('|'))}
text={name}
/>
</span>
)
const result: React.ReactNode[] = []
const chunks = name.split('/')
let renderedPath = path.slice(0, path.length - name.length)
chunks.forEach((chunk, index, chunks) => {
renderedPath += '/' + chunk
const indexes = fuzzyMatchIndexes(searchKey, renderedPath, renderedPath.length - chunk.length)
const regexp = new RegExp(indexes.map(i => `(?<=^.{${i}}).`).join('|'))
result.push(
<span key={chunk} className={cx({ prefix: index + 1 !== chunks.length })}>
<Highlight match={regexp} text={chunk} />
{index + 1 !== chunks.length && '/'}
</span>,
)
})
return result
},
}
@ -27,22 +37,21 @@ function fuzzyMatch(input: string, sample: string) {
let i = 0,
j = 0
while (i < input.length && j < sample.length) {
if (input[i] === sample[j]) i++
j++
if (input[i] === sample[j++]) i++
}
return i === input.length
}
function fuzzyMatchIndexes(input: string, sample: string, shift: number = 0) {
const r: number[] = []
const indexes: number[] = []
let i = 0,
j = 0
while (i < input.length && j < sample.length) {
if (input[i] === sample[j]) {
if (j >= shift) r.push(j - shift)
if (j >= shift) indexes.push(j - shift)
i++
}
j++
}
return r
return indexes
}

View file

@ -6,6 +6,8 @@ import { Highlight } from '../Highlight'
export const regexMode: ModeShape = {
getSearchParams(searchKey) {
if (!searchKey) return null
const regexp = searchKeyToRegexp(searchKey)
if (regexp) {
const matchNode = (node: TreeNode) => regexp.test(node.name)
@ -22,10 +24,10 @@ export const regexMode: ModeShape = {
searchKey && isValidRegexpSource(searchKey) ? new RegExp(searchKey, 'gi') : undefined
const { name } = node
return name.includes('/') ? (
name.split('/').map((chunk, index, arr) => (
<span key={chunk} className={cx({ prefix: index + 1 !== arr.length })}>
name.split('/').map((chunk, index, chunks) => (
<span key={chunk} className={cx({ prefix: index + 1 !== chunks.length })}>
<Highlight match={regex} text={chunk} />
{index + 1 !== arr.length && '/'}
{index + 1 !== chunks.length && '/'}
</span>
))
) : (

View file

@ -1,3 +1,4 @@
import { searchModes } from 'components/searchModes'
import { GetCreatedMethod, MethodCreator } from 'driver/connect'
import { platform } from 'platforms'
import { Config } from 'utils/configHelper'
@ -17,12 +18,13 @@ export type Props = {
export type ConnectorState = {
state: 'pulling' | 'rendering' | 'done'
visibleNodesGenerator: VisibleNodesGenerator | null
visibleNodes: VisibleNodes | null
searchKey: string
searched: boolean // derived state from searchKey, = !!searchKey
handleKeyDown: GetCreatedMethod<typeof handleKeyDown>
search: GetCreatedMethod<typeof search>
updateSearchKey: GetCreatedMethod<typeof updateSearchKey>
onNodeClick: GetCreatedMethod<typeof onNodeClick>
onFocusSearchBar: GetCreatedMethod<typeof onFocusSearchBar>
setUpTree: GetCreatedMethod<typeof setUpTree>
@ -40,20 +42,18 @@ function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode) {
}
}
let visibleNodesGenerator: VisibleNodesGenerator
type BoundMethodCreator<Args extends any[] = []> = MethodCreator<Props, ConnectorState, Args>
export const setUpTree: BoundMethodCreator<
[
Required<Pick<Props, 'treeRoot' | 'metaData'>> & {
config: Pick<Config, 'compressSingletonFolder' | 'accessToken'>
config: Pick<Config, 'compressSingletonFolder' | 'accessToken' | 'searchMode'>
},
]
> = dispatch => async ({ treeRoot, metaData, config }) => {
dispatch.set({ state: 'rendering' })
visibleNodesGenerator = new VisibleNodesGenerator({
const visibleNodesGenerator = new VisibleNodesGenerator({
root: treeRoot,
compress: config.compressSingletonFolder,
async getTreeData(path) {
@ -61,7 +61,9 @@ export const setUpTree: BoundMethodCreator<
return root
},
})
dispatch.set({ visibleNodesGenerator })
visibleNodesGenerator.onUpdate(visibleNodes => dispatch.set({ visibleNodes }))
if (platform.shouldExpandAll?.()) {
const unsubscribe = visibleNodesGenerator.onUpdate(visibleNodes => {
unsubscribe()
@ -69,11 +71,11 @@ export const setUpTree: BoundMethodCreator<
dispatch.call(toggleNodeExpansion, node, { recursive: true }),
)
})
dispatch.call(search, '')
visibleNodesGenerator.search(searchModes[config.searchMode].getSearchParams(''))
} else {
const targetPath = platform.getCurrentPath(metaData.branchName)
if (targetPath) dispatch.call(goTo, targetPath)
else dispatch.call(search, '')
else visibleNodesGenerator.search(searchModes[config.searchMode].getSearchParams(''))
}
dispatch.set({ state: 'done' })
@ -186,12 +188,16 @@ export const handleKeyDown: BoundMethodCreator<[React.KeyboardEvent]> = dispatch
export const onFocusSearchBar: BoundMethodCreator = dispatch => () => dispatch.call(focusNode, null)
export const search: BoundMethodCreator<[string]> = dispatch => searchKey => {
export const updateSearchKey: BoundMethodCreator<[string]> = dispatch => searchKey => {
dispatch.set({ searchKey, searched: searchKey !== '' })
visibleNodesGenerator.search({ searchKey })
}
export const goTo: BoundMethodCreator<[string[]]> = dispatch => currentPath => {
const {
state: { visibleNodesGenerator },
} = dispatch.get()
if (!visibleNodesGenerator) return
dispatch.set({ searchKey: '', searched: false })
visibleNodesGenerator.search(null)
dispatch.call(expandTo, currentPath)
@ -201,6 +207,11 @@ export const setExpand: BoundMethodCreator<[TreeNode, boolean]> = dispatch => as
node,
expand = false,
) => {
const {
state: { visibleNodesGenerator },
} = dispatch.get()
if (!visibleNodesGenerator) return
await visibleNodesGenerator.setExpand(node, expand)
dispatch.call(focusNode, node)
}
@ -213,6 +224,11 @@ export const toggleNodeExpansion: BoundMethodCreator<
},
]
> = dispatch => async (node, { recursive = false }) => {
const {
state: { visibleNodesGenerator },
} = dispatch.get()
if (!visibleNodesGenerator) return
visibleNodesGenerator.focusNode(node)
await visibleNodesGenerator.toggleExpand(node, recursive)
}
@ -220,6 +236,11 @@ export const toggleNodeExpansion: BoundMethodCreator<
export const focusNode: BoundMethodCreator<[TreeNode | null]> = dispatch => (
node: TreeNode | null,
) => {
const {
state: { visibleNodesGenerator },
} = dispatch.get()
if (!visibleNodesGenerator) return
visibleNodesGenerator.focusNode(node)
}
@ -255,6 +276,11 @@ export const onNodeClick: BoundMethodCreator<
}
export const expandTo: BoundMethodCreator<[string[]]> = dispatch => async currentPath => {
const {
state: { visibleNodesGenerator },
} = dispatch.get()
if (!visibleNodesGenerator) return
const nodeExpandedTo = await visibleNodesGenerator.expandTo(currentPath.join('/'))
if (nodeExpandedTo) {
visibleNodesGenerator.focusNode(nodeExpandedTo)

View file

@ -447,6 +447,8 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
/* search input */
.search-input-wrapper {
position: relative;
.search-input {
width: 100%;
box-shadow: none; // stay low
@ -454,11 +456,33 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
border-radius: 0; // its rounded corner not match with nearby elements
color: var(--gitako-text-primary);
background: var(--gitako-bg-canvas);
padding-right: 32px; // save space for actions
&.error {
border-color: var(--gitako-border-danger);
}
}
.actions {
position: absolute;
top: 0px;
right: 0px;
height: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 0 2px;
.toggle-mode {
cursor: pointer;
&:hover {
background-color: var(--gitako-bg-tertiary);
}
&:active {
background-color: var(--gitako-bg-secondary);
}
}
}
}
.files {
@ -543,7 +567,8 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
}
}
.go-to-button {
.go-to-button,
.find-in-folder-button {
@include icon-button();
@include button-color();
width: 28px;
@ -555,7 +580,8 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-
}
}
&:not(:hover) {
.go-to-button {
.go-to-button,
.find-in-folder-button {
display: none;
}
}

View file

@ -324,7 +324,6 @@ type Options = {
export type VisibleNodes = {
loading: BaseLayer['loading']
lastMatch: ShakeLayer['lastSearchParams']
depths: CompressLayer['depths']
nodes: FlattenLayer['nodes']
expandedNodes: FlattenLayer['expandedNodes']
@ -353,7 +352,6 @@ export class VisibleNodesGenerator extends FlattenLayer {
get visibleNodes(): VisibleNodes {
return {
nodes: this.nodes,
lastMatch: this.lastSearchParams,
depths: this.depths,
expandedNodes: this.expandedNodes,
focusedNode: this.focusedNode,