diff --git a/src/components/FileExplorer.tsx b/src/components/FileExplorer.tsx index 91fc119..d6c0b98 100644 --- a/src/components/FileExplorer.tsx +++ b/src/components/FileExplorer.tsx @@ -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, node: TreeNode) => void + renderLabelText: (node: TreeNode) => React.ReactNode + renderActions: ((node: TreeNode) => React.ReactNode) | undefined + visibleNodes: VisibleNodes +} + const RawFileExplorer: React.FC = 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 = 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 => ( + + ) + const renderFindInFolderButton = (node: TreeNode): React.ReactNode => + node.type === 'tree' ? ( + + ) : 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) => {render(node)}) + : 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 => ( - - ) - : undefined, - [visibleNodes, goTo], + visibleNodes && { + onNodeClick, + renderActions, + renderLabelText, + visibleNodes, + }, + [onNodeClick, renderActions, renderLabelText, visibleNodes], ) return (
{state !== 'done' ? ( = function RawFileExplor } /> ) : ( - visibleNodes && ( + visibleNodes && + renderNodeContext && ( <> updateSearchKey(value)} + onFocus={onFocusSearchBar} /> - {visibleNodes.lastMatch?.match.searchKey !== '' && visibleNodes.nodes.length === 0 && ( + {searched && visibleNodes.nodes.length === 0 && ( <> No results found. @@ -112,9 +169,7 @@ const RawFileExplorer: React.FC = function RawFileExplor @@ -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) { 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 ( ) }) @@ -173,31 +220,23 @@ const VirtualNode = React.memo(function VirtualNode({ type ListViewProps = { height: number width: number - onNodeClick(event: React.MouseEvent, node: TreeNode): void - renderActions?(node: TreeNode): React.ReactNode - visibleNodes: VisibleNodes -} + renderNodeContext: renderNodeContext +} & Pick & + Pick -function ListView({ - width, - height, - metaData, - expandTo, - onNodeClick, - renderActions, - visibleNodes, -}: ListViewProps & Pick & Pick) { +function ListView({ width, height, metaData, expandTo, renderNodeContext }: ListViewProps) { + const { visibleNodes } = renderNodeContext + const { focusedNode, nodes } = visibleNodes const listRef = React.useRef(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 ( visibleNodes?.nodes[index]?.path} - itemData={itemData} + itemData={renderNodeContext} itemCount={visibleNodes.nodes.length} itemSize={37} height={height} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index e14e7a5..c5d9f07 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -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, diff --git a/src/components/Node.tsx b/src/components/Node.tsx index f721c1f..5fa7942 100644 --- a/src/components/Node.tsx +++ b/src/components/Node.tsx @@ -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 (
- {name.includes('/') ? ( - name.split('/').map((chunk, index, arr) => ( - - - {index + 1 !== arr.length && '/'} - - )) - ) : ( - - )} + {renderLabelText(node)}
{renderActions &&
{renderActions(node)}
}
diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index ab86600..5af0649 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -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> + +export function SearchBar({ onSearch, onFocus, value }: Props) { + const configs = useConfigs() + const { searchMode } = configs.value -export function SearchBar({ onSearch, onFocus, searchKey }: Props) { return (
onSearch(value)} - value={searchKey} + value={value} /> +
+ +
) } diff --git a/src/components/searchModes/fuzzyMode.tsx b/src/components/searchModes/fuzzyMode.tsx index 5d78f47..6150485 100644 --- a/src/components/searchModes/fuzzyMode.tsx +++ b/src/components/searchModes/fuzzyMode.tsx @@ -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 ( - - (i === 0 ? `^.` : `(?<=^.{${i}}).`)).join('|'))} - text={name} - /> - - ) + + 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( + + + {index + 1 !== chunks.length && '/'} + , + ) + }) + 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 } diff --git a/src/components/searchModes/regexMode.tsx b/src/components/searchModes/regexMode.tsx index dfed221..0e1b055 100644 --- a/src/components/searchModes/regexMode.tsx +++ b/src/components/searchModes/regexMode.tsx @@ -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) => ( - + name.split('/').map((chunk, index, chunks) => ( + - {index + 1 !== arr.length && '/'} + {index + 1 !== chunks.length && '/'} )) ) : ( diff --git a/src/driver/core/FileExplorer.ts b/src/driver/core/FileExplorer.ts index 856a4a4..c650eda 100644 --- a/src/driver/core/FileExplorer.ts +++ b/src/driver/core/FileExplorer.ts @@ -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 - search: GetCreatedMethod + updateSearchKey: GetCreatedMethod onNodeClick: GetCreatedMethod onFocusSearchBar: GetCreatedMethod setUpTree: GetCreatedMethod @@ -40,20 +42,18 @@ function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode) { } } -let visibleNodesGenerator: VisibleNodesGenerator - type BoundMethodCreator = MethodCreator export const setUpTree: BoundMethodCreator< [ Required> & { - config: Pick + config: Pick }, ] > = 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) diff --git a/src/styles/index.scss b/src/styles/index.scss index b722d97..9918c8b 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -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; } } diff --git a/src/utils/VisibleNodesGenerator.ts b/src/utils/VisibleNodesGenerator.ts index 76fb255..96eafe4 100644 --- a/src/utils/VisibleNodesGenerator.ts +++ b/src/utils/VisibleNodesGenerator.ts @@ -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,