From 23c0b8118a34d36dcde493d2c423e61251eae307 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sat, 14 May 2022 23:00:52 +0800 Subject: [PATCH] refactor: extract hooks --- src/components/FileExplorer.tsx | 315 ------------------ .../{ => FileExplorer}/DiffStatGraph.tsx | 2 +- .../{ => FileExplorer}/DiffStatText.tsx | 2 +- src/components/FileExplorer/ListView.tsx | 65 ++++ src/components/{ => FileExplorer}/Node.tsx | 2 +- src/components/FileExplorer/VirtualNode.tsx | 30 ++ src/components/FileExplorer/index.tsx | 217 ++++++++++++ .../FileExplorer/useNodeRenderers.tsx | 103 ++++++ src/utils/general.ts | 4 + 9 files changed, 422 insertions(+), 318 deletions(-) delete mode 100644 src/components/FileExplorer.tsx rename src/components/{ => FileExplorer}/DiffStatGraph.tsx (97%) rename src/components/{ => FileExplorer}/DiffStatText.tsx (96%) create mode 100644 src/components/FileExplorer/ListView.tsx rename src/components/{ => FileExplorer}/Node.tsx (98%) create mode 100644 src/components/FileExplorer/VirtualNode.tsx create mode 100644 src/components/FileExplorer/index.tsx create mode 100644 src/components/FileExplorer/useNodeRenderers.tsx diff --git a/src/components/FileExplorer.tsx b/src/components/FileExplorer.tsx deleted file mode 100644 index 0f4fdc5..0000000 --- a/src/components/FileExplorer.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { Label, Text } from '@primer/react' -import { LoadingIndicator } from 'components/LoadingIndicator' -import { Node } from 'components/Node' -import { SearchBar } from 'components/SearchBar' -import { useConfigs } from 'containers/ConfigsContext' -import { connect } from 'driver/connect' -import { FileExplorerCore } from 'driver/core' -import { ConnectorState, Props } from 'driver/core/FileExplorer' -import { platform } from 'platforms' -import * as React from 'react' -import { FixedSizeList, ListChildComponentProps } from 'react-window' -import { cx } from 'utils/cx' -import { focusFileExplorer } from 'utils/DOMHelper' -import { run } from 'utils/general' -import { useLoadedContext } from 'utils/hooks/useLoadedContext' -import { useOnLocationChange } from 'utils/hooks/useOnLocationChange' -import { useOnPJAXDone } from 'utils/hooks/usePJAX' -import { useSequentialEffect } from 'utils/hooks/useSequentialEffect' -import { VisibleNodes } from 'utils/VisibleNodesGenerator' -import { SideBarStateContext } from '../containers/SideBarState' -import { DiffStatGraph } from './DiffStatGraph' -import { DiffStatText } from './DiffStatText' -import { Icon } from './Icon' -import { SearchMode, 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 { - visibleNodes, - visibleNodesGenerator, - freeze, - onNodeClick, - searchKey, - updateSearchKey, - onFocusSearchBar, - goTo, - handleKeyDown, - metaData, - expandTo, - setUpTree, - defer, - searched, - } = props - const { - value: { - accessToken, - compressSingletonFolder, - searchMode, - commentToggle, - restoreExpandedFolders, - showDiffInText, - }, - } = useConfigs() - - const onSearch = React.useCallback( - (searchKey: string, searchMode: SearchMode) => { - updateSearchKey(searchKey) - if (visibleNodesGenerator) { - visibleNodesGenerator.search( - searchModes[searchMode].getSearchParams(searchKey), - restoreExpandedFolders, - ) - } - }, - [updateSearchKey, visibleNodesGenerator, restoreExpandedFolders], - ) - - const stateContext = useLoadedContext(SideBarStateContext) - const state = stateContext.value - - useSequentialEffect( - checker => { - setUpTree( - { - metaData, - config: { - compressSingletonFolder, - accessToken, - }, - stateContext, - }, - checker, - ) - }, - [setUpTree, metaData, compressSingletonFolder, accessToken], - ) - - React.useEffect(() => { - focusFileExplorer() - }, []) - - 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 renderFileCommentAmounts = (node: TreeNode): React.ReactNode => - node.comments?.active ? ( - - {node.comments.active > 9 ? '9+' : node.comments.active} - - ) : null - const renderFileStatus = ({ diff }: TreeNode): React.ReactNode => - diff && ( - - {showDiffInText ? : } - - ) - - const renders: ((node: TreeNode) => React.ReactNode)[] = [] - if (searchMode === 'fuzzy') renders.push(renderFindInFolderButton) - if (searched) renders.push(renderGoToButton) - if (commentToggle) renders.push(renderFileCommentAmounts) - renders.push(renderFileStatus) - - return renders.length - ? node => renders.map((render, i) => {render(node)}) - : undefined - }, [goTo, onSearch, searched, searchMode, commentToggle, showDiffInText]) - - const renderLabelText = React.useCallback( - (node: TreeNode) => searchModes[searchMode].renderNodeLabelText(node, searchKey), - [searchKey, searchMode], - ) - - const renderNodeContext: renderNodeContext | null = React.useMemo( - () => - visibleNodes && { - onNodeClick, - renderActions, - renderLabelText, - visibleNodes, - }, - [onNodeClick, renderActions, renderLabelText, visibleNodes], - ) - - return ( -
- {run(() => { - switch (state) { - case 'tree-loading': - return - case 'tree-rendering': - return - case 'tree-rendered': - return ( - visibleNodes && - renderNodeContext && ( - <> - {defer && ( -
- -
- )} - - {searched && visibleNodes.nodes.length === 0 && ( - <> - - No results found. - - {defer && ( - - Search results are limited to loaded folders in Lazy Mode. - - )} - - )} - - {({ width = 0, height = 0 }) => ( -
- -
- )} -
- - ) - ) - } - })} -
- ) -} - -RawFileExplorer.defaultProps = { - freeze: false, - searchKey: '', - visibleNodes: null, -} - -export const FileExplorer = connect(FileExplorerCore)(RawFileExplorer) - -const VirtualNode = React.memo(function VirtualNode({ - index, - style, - data: { onNodeClick, renderLabelText, renderActions, visibleNodes }, -}: Override) { - if (!visibleNodes) return null - - const { nodes, focusedNode, expandedNodes, loading, depths } = visibleNodes as VisibleNodes - const node = nodes[index] - - return ( - - ) -}) - -type ListViewProps = { - height: number - width: number - renderNodeContext: renderNodeContext -} & 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(() => { - if (listRef.current && focusedNode?.path) { - const index = nodes.findIndex(node => node.path === focusedNode.path) - if (index !== -1) { - listRef.current.scrollToItem(index, 'auto') - } - } - }, [focusedNode?.path, 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 :( - - const goToCurrentItem = React.useCallback(() => { - const targetPath = platform.getCurrentPath(metaData.branchName) - if (targetPath) expandTo(targetPath) - }, [metaData.branchName]) - - useOnLocationChange(goToCurrentItem) - useOnPJAXDone(goToCurrentItem) - - const { compactFileTree } = useConfigs().value - - return ( - visibleNodes?.nodes[index]?.path} - itemData={renderNodeContext} - itemCount={visibleNodes.nodes.length} - itemSize={compactFileTree ? 24 : 37} - height={height} - width={width} - > - {VirtualNode} - - ) -} diff --git a/src/components/DiffStatGraph.tsx b/src/components/FileExplorer/DiffStatGraph.tsx similarity index 97% rename from src/components/DiffStatGraph.tsx rename to src/components/FileExplorer/DiffStatGraph.tsx index 7aaa2dd..2a93095 100644 --- a/src/components/DiffStatGraph.tsx +++ b/src/components/FileExplorer/DiffStatGraph.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { resolveDiffGraphMeta } from 'utils/general' -import { Icon } from './Icon' +import { Icon } from '../Icon' export function DiffStatGraph({ diff: { status, changes, additions, deletions }, diff --git a/src/components/DiffStatText.tsx b/src/components/FileExplorer/DiffStatText.tsx similarity index 96% rename from src/components/DiffStatText.tsx rename to src/components/FileExplorer/DiffStatText.tsx index 2f8715c..338535d 100644 --- a/src/components/DiffStatText.tsx +++ b/src/components/FileExplorer/DiffStatText.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Icon } from './Icon' +import { Icon } from '../Icon' export function DiffStatText({ diff: { status, changes, additions, deletions }, diff --git a/src/components/FileExplorer/ListView.tsx b/src/components/FileExplorer/ListView.tsx new file mode 100644 index 0000000..ee2ed1b --- /dev/null +++ b/src/components/FileExplorer/ListView.tsx @@ -0,0 +1,65 @@ +import { useConfigs } from 'containers/ConfigsContext' +import { ConnectorState, Props } from 'driver/core/FileExplorer' +import { platform } from 'platforms' +import * as React from 'react' +import { Align as ReactWindowAlign, FixedSizeList } from 'react-window' +import { useOnLocationChange } from 'utils/hooks/useOnLocationChange' +import { useOnPJAXDone } from 'utils/hooks/usePJAX' +import { NodeRendererContext } from '.' +import { VirtualNode } from './VirtualNode' + +type ListViewProps = { + height: number + width: number + nodeRendererContext: NodeRendererContext + scrollMode: ReactWindowAlign +} & Pick & + Pick + +export function ListView({ + width, + height, + metaData, + expandTo, + nodeRendererContext, + scrollMode, +}: ListViewProps) { + const { visibleNodes } = nodeRendererContext + const { focusedNode, nodes } = visibleNodes + const listRef = React.useRef(null) + // the change of depths indicates switch into/from search state + React.useEffect(() => { + if (listRef.current && focusedNode?.path) { + const index = nodes.findIndex(node => node.path === focusedNode.path) + if (index !== -1) { + listRef.current.scrollToItem(index, scrollMode) + } + } + }, [focusedNode?.path, 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 :( + const goToCurrentItem = React.useCallback(() => { + const targetPath = platform.getCurrentPath(metaData.branchName) + if (targetPath) expandTo(targetPath) + }, [metaData.branchName]) + + useOnLocationChange(goToCurrentItem) + useOnPJAXDone(goToCurrentItem) + + const { compactFileTree } = useConfigs().value + + return ( + visibleNodes?.nodes[index]?.path} + itemData={nodeRendererContext} + itemCount={visibleNodes.nodes.length} + itemSize={compactFileTree ? 24 : 37} + height={height} + width={width} + > + {VirtualNode} + + ) +} diff --git a/src/components/Node.tsx b/src/components/FileExplorer/Node.tsx similarity index 98% rename from src/components/Node.tsx rename to src/components/FileExplorer/Node.tsx index 77117a4..51368f3 100644 --- a/src/components/Node.tsx +++ b/src/components/FileExplorer/Node.tsx @@ -3,7 +3,7 @@ import { platform } from 'platforms' import * as React from 'react' import { cx } from 'utils/cx' import { getFileIconURL, getFolderIconURL } from 'utils/parseIconMapCSV' -import { Icon } from './Icon' +import { Icon } from '../Icon' function getIconType(node: TreeNode) { switch (node.type) { diff --git a/src/components/FileExplorer/VirtualNode.tsx b/src/components/FileExplorer/VirtualNode.tsx new file mode 100644 index 0000000..8b7ff50 --- /dev/null +++ b/src/components/FileExplorer/VirtualNode.tsx @@ -0,0 +1,30 @@ +import { Node } from 'components/FileExplorer/Node' +import * as React from 'react' +import { ListChildComponentProps } from 'react-window' +import { NodeRendererContext } from '.' + +export const VirtualNode = React.memo(function VirtualNode({ + index, + style, + data: { onNodeClick, renderLabelText, renderActions, visibleNodes }, +}: Override) { + if (!visibleNodes) return null + + const { nodes, focusedNode, expandedNodes, loading, depths } = visibleNodes + const node = nodes[index] + + return ( + + ) +}) diff --git a/src/components/FileExplorer/index.tsx b/src/components/FileExplorer/index.tsx new file mode 100644 index 0000000..621ff78 --- /dev/null +++ b/src/components/FileExplorer/index.tsx @@ -0,0 +1,217 @@ +import { Label, Text } from '@primer/react' +import { LoadingIndicator } from 'components/LoadingIndicator' +import { SearchBar } from 'components/SearchBar' +import { useConfigs } from 'containers/ConfigsContext' +import { connect } from 'driver/connect' +import { FileExplorerCore } from 'driver/core' +import { ConnectorState, Props } from 'driver/core/FileExplorer' +import * as React from 'react' +import { Align as ReactWindowAlign } from 'react-window' +import { cx } from 'utils/cx' +import * as DOMHelper from 'utils/DOMHelper' +import { run } from 'utils/general' +import { useLoadedContext } from 'utils/hooks/useLoadedContext' +import { useSequentialEffect } from 'utils/hooks/useSequentialEffect' +import { useStateIO } from 'utils/hooks/useStateIO' +import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' +import { SideBarStateContext } from '../../containers/SideBarState' +import { SearchMode, searchModes } from '../searchModes' +import { SizeObserver } from '../SizeObserver' +import { ListView } from './ListView' +import { + NodeRenderer, + useNodeRenderers, + useRenderFileCommentAmounts, + useRenderFileStatus, + useRenderFindInFolderButton, + useRenderGoToButton +} from './useNodeRenderers' + +export type NodeRendererContext = { + onNodeClick: (event: React.MouseEvent, node: TreeNode) => void + renderLabelText: NodeRenderer + renderActions: NodeRenderer | undefined + visibleNodes: VisibleNodes +} + +function useSetupTree(setUpTree: ConnectorState['setUpTree'], metaData: MetaData) { + const stateContext = useLoadedContext(SideBarStateContext) + const { + value: { accessToken, compressSingletonFolder }, + } = useConfigs() + + useSequentialEffect( + checker => { + setUpTree( + { + metaData, + config: { + compressSingletonFolder, + accessToken, + }, + stateContext, + }, + checker, + ) + }, + [setUpTree, metaData, compressSingletonFolder, accessToken], + ) +} + +function useFocusFileExplorerOnFirstRender() { + React.useEffect(() => { + DOMHelper.focusFileExplorer() + }, []) +} + +function useReactWindowAlignMode(searched: boolean) { + const scrollMode = useStateIO('auto') + React.useEffect(() => { + // Use `auto` as default mode to prevent initial misalignment + // Switch to `smart` mode when start searching to make sure alignment is user-friendly when jump to files + if (scrollMode.value === 'auto' && searched) scrollMode.onChange('smart') + }, [searched]) + return scrollMode +} + +function useOnSearch( + updateSearchKey: (searchKey: string) => void, + visibleNodesGenerator: VisibleNodesGenerator | null, +) { + const { + value: { restoreExpandedFolders }, + } = useConfigs() + return React.useCallback( + (searchKey: string, searchMode: SearchMode) => { + updateSearchKey(searchKey) + if (visibleNodesGenerator) { + visibleNodesGenerator.search( + searchModes[searchMode].getSearchParams(searchKey), + restoreExpandedFolders, + ) + } + }, + [updateSearchKey, visibleNodesGenerator, restoreExpandedFolders], + ) +} + +function useRenderLabelText(searchKey: string) { + const { + value: { searchMode }, + } = useConfigs() + return React.useCallback( + (node: TreeNode) => searchModes[searchMode].renderNodeLabelText(node, searchKey), + [searchKey, searchMode], + ) +} + +const RawFileExplorer: React.FC = function RawFileExplorer({ + visibleNodes, + visibleNodesGenerator, + freeze, + onNodeClick, + searchKey, + updateSearchKey, + onFocusSearchBar, + goTo, + handleKeyDown, + metaData, + expandTo, + setUpTree, + defer, + searched, +}) { + useSetupTree(setUpTree, metaData) + useFocusFileExplorerOnFirstRender() + + const onSearch = useOnSearch(updateSearchKey, visibleNodesGenerator) + const renderActions = useNodeRenderers([ + useRenderGoToButton(searched, goTo), + useRenderFindInFolderButton(onSearch), + useRenderFileCommentAmounts(), + useRenderFileStatus(), + ]) + + const renderLabelText = useRenderLabelText(searchKey) + + const nodeRendererContext: NodeRendererContext | null = React.useMemo( + () => + visibleNodes && { + onNodeClick, + renderActions, + renderLabelText, + visibleNodes, + }, + [onNodeClick, renderActions, renderLabelText, visibleNodes], + ) + + const alignMode = useReactWindowAlignMode(searched) + const state = useLoadedContext(SideBarStateContext).value + + return ( +
+ {run(() => { + switch (state) { + case 'tree-loading': + return + case 'tree-rendering': + return + case 'tree-rendered': + return ( + visibleNodes && + nodeRendererContext && ( + <> + {defer && ( +
+ +
+ )} + + {searched && visibleNodes.nodes.length === 0 && ( + <> + + No results found. + + {defer && ( + + Search results are limited to loaded folders in Lazy Mode. + + )} + + )} + + {({ width = 0, height = 0 }) => ( +
+ +
+ )} +
+ + ) + ) + } + })} +
+ ) +} + +RawFileExplorer.defaultProps = { + freeze: false, + searchKey: '', + visibleNodes: null, +} + +export const FileExplorer = connect(FileExplorerCore)(RawFileExplorer) diff --git a/src/components/FileExplorer/useNodeRenderers.tsx b/src/components/FileExplorer/useNodeRenderers.tsx new file mode 100644 index 0000000..7a386e3 --- /dev/null +++ b/src/components/FileExplorer/useNodeRenderers.tsx @@ -0,0 +1,103 @@ +import { useConfigs } from 'containers/ConfigsContext' +import * as React from 'react' +import { isNotFalsy } from 'utils/general' +import { Icon } from '../Icon' +import { SearchMode } from '../searchModes' +import { DiffStatGraph } from './DiffStatGraph' +import { DiffStatText } from './DiffStatText' + +export type NodeRenderer = (node: TreeNode) => React.ReactNode + +export function useNodeRenderers(allRenderers: (NodeRenderer | null | undefined)[]) { + return React.useMemo(() => { + const renderers: NodeRenderer[] = allRenderers.filter(isNotFalsy) + return renderers.length + ? (node: TreeNode) => + renderers.map((render, i) => {render(node)}) + : undefined + }, allRenderers) +} + +export function useRenderFileStatus() { + function renderFileStatus({ diff }: TreeNode) { + return ( + diff && ( + + {showDiffInText ? : } + + ) + ) + } + const { + value: { showDiffInText }, + } = useConfigs() + return React.useMemo(() => renderFileStatus, []) +} + +export function useRenderFileCommentAmounts() { + function renderFileCommentAmounts(node: TreeNode) { + return node.comments?.active ? ( + + {node.comments.active > 9 ? '9+' : node.comments.active} + + ) : null + } + const { + value: { commentToggle }, + } = useConfigs() + return React.useMemo(() => (commentToggle ? renderFileCommentAmounts : null), []) +} + +export function useRenderFindInFolderButton( + onSearch: (searchKey: string, searchMode: SearchMode) => void, +) { + function renderFindInFolderButton(node: TreeNode) { + return node.type === 'tree' ? ( + + ) : null + } + const { + value: { searchMode }, + } = useConfigs() + return React.useMemo( + () => (searchMode === 'fuzzy' ? renderFindInFolderButton : null), + [searchMode], + ) +} + +export function useRenderGoToButton(searched: boolean, goTo: (path: string[]) => void) { + function renderGoToButton(node: TreeNode): React.ReactNode { + return ( + + ) + } + return React.useMemo(() => (searched ? renderGoToButton : null), [searched]) +} diff --git a/src/utils/general.ts b/src/utils/general.ts index 4af8aae..982ed86 100644 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -224,3 +224,7 @@ export function formatHash(hash?: string) { if (hash) return '#' + hash return '' } + +export function isNotFalsy(value: T | undefined | null): value is T { + return value !== undefined && value !== null +}