diff --git a/src/components/FileExplorer/ListView.tsx b/src/components/FileExplorer/ListView.tsx index b53fcde..6dcc8f4 100644 --- a/src/components/FileExplorer/ListView.tsx +++ b/src/components/FileExplorer/ListView.tsx @@ -1,9 +1,9 @@ import { useConfigs } from 'containers/ConfigsContext' import * as React from 'react' -import { Align, FixedSizeList } from 'react-window' import { useStateIO } from 'utils/hooks/useStateIO' import { NodeRendererContext } from '.' -import { VirtualNode } from './VirtualNode' +import { Node } from './Node' +import { AlignMode, useVirtualScroll } from './useVirtualScroll' type ListViewProps = { height: number @@ -12,10 +12,25 @@ type ListViewProps = { } export function ListView({ width, height, nodeRendererContext }: ListViewProps) { - const { visibleNodes } = nodeRendererContext - const { focusedNode, nodes } = visibleNodes + const { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes } = + nodeRendererContext + const { focusedNode, nodes, expandedNodes, depths, loading } = visibleNodes + + const { compactFileTree } = useConfigs().value + + const rowHeight = compactFileTree ? 24 : 37 + const totalAmount = visibleNodes.nodes.length + const { onScroll, visibleRows, containerStyle, scrollToItem, ref } = + useVirtualScroll({ + totalAmount, + rowHeight, + viewportHeight: height, + overScan: 10, + }) + + const $mode = useStateIO('top') + const enableScroll = width * height > 0 // these can be 0 on first render - const listRef = React.useRef>(null) const index = React.useMemo( () => width && height && focusedNode?.path @@ -24,43 +39,57 @@ export function ListView({ width, height, nodeRendererContext }: ListViewProps) [focusedNode?.path, nodes, width, height], ) - const $mode = useStateIO('start') - const enableScroll = width * height > 0 // these can be 0 on first render - React.useEffect(() => { // - init loading - // - "start" + // - "top" // - NO immediate call // - jump to file - // - "start" + // - "top" // - NO immediate call // - click file/folder // - not invoke // - navigate with keyboard - // - "smart" + // - "lazy" // - immediate call - if (enableScroll && listRef.current && index !== -1) { - listRef.current.scrollToItem(index, $mode.value) + if (enableScroll && index !== -1) { + scrollToItem?.(index, $mode.value) } - }, [enableScroll, $mode.value, index]) + }, [enableScroll, $mode.value, index, scrollToItem]) React.useEffect(() => { - if (enableScroll && $mode.value === 'start') $mode.onChange('smart') + if (enableScroll && $mode.value === 'top') $mode.onChange('lazy') }, [enableScroll, $mode.value]) // eslint-disable-line react-hooks/exhaustive-deps - const { compactFileTree } = useConfigs().value - return ( - - ref={listRef} - itemKey={(index, { visibleNodes }) => visibleNodes.nodes[index]?.path} - itemData={nodeRendererContext} - itemCount={visibleNodes.nodes.length} - itemSize={compactFileTree ? 24 : 37} - height={height} - width={'100%'} +
- {VirtualNode} - +
+ {visibleRows.map(({ row, style }) => { + const node = nodes[row] + return ( + + ) + })} +
+
) } diff --git a/src/components/FileExplorer/Node.tsx b/src/components/FileExplorer/Node.tsx index 4244d75..5d07670 100644 --- a/src/components/FileExplorer/Node.tsx +++ b/src/components/FileExplorer/Node.tsx @@ -28,7 +28,8 @@ type Props = { renderLabelText(node: TreeNode): React.ReactNode style?: React.CSSProperties } -export function Node({ + +export const Node = React.memo(function Node({ node, depth, expanded, @@ -60,7 +61,7 @@ export function Node({ {renderActions &&
{renderActions(node)}
} ) -} +}) const NodeItemIcon = React.memo(function NodeItemIcon({ node, diff --git a/src/components/FileExplorer/VirtualNode.tsx b/src/components/FileExplorer/VirtualNode.tsx index 8d464a8..f5cbebb 100644 --- a/src/components/FileExplorer/VirtualNode.tsx +++ b/src/components/FileExplorer/VirtualNode.tsx @@ -6,8 +6,9 @@ import { NodeRendererContext } from '.' export const VirtualNode = React.memo(function VirtualNode({ index, style, - data: { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes }, + data, }: Override) { + const { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes } = data if (!visibleNodes) return null const { nodes, focusedNode, expandedNodes, loading, depths } = visibleNodes diff --git a/src/components/FileExplorer/hooks/useExpandTo.tsx b/src/components/FileExplorer/hooks/useExpandTo.tsx index 43e378a..4f9a74c 100644 --- a/src/components/FileExplorer/hooks/useExpandTo.tsx +++ b/src/components/FileExplorer/hooks/useExpandTo.tsx @@ -1,11 +1,9 @@ import * as React from 'react' import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' -export function useExpandTo(visibleNodesGenerator: VisibleNodesGenerator | null) { +export function useExpandTo(visibleNodesGenerator: VisibleNodesGenerator) { return React.useCallback( async (currentPath: string[]) => { - if (!visibleNodesGenerator) return - const nodeExpandedTo = await visibleNodesGenerator.expandTo(currentPath.join('/')) if (nodeExpandedTo) visibleNodesGenerator.focusNode(nodeExpandedTo) }, diff --git a/src/components/FileExplorer/hooks/useFocusNode.tsx b/src/components/FileExplorer/hooks/useFocusNode.tsx index e988068..4d8d9e4 100644 --- a/src/components/FileExplorer/hooks/useFocusNode.tsx +++ b/src/components/FileExplorer/hooks/useFocusNode.tsx @@ -1,9 +1,9 @@ import * as React from 'react' import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' -export function useFocusNode(visibleNodesGenerator: VisibleNodesGenerator | null) { +export function useFocusNode(visibleNodesGenerator: VisibleNodesGenerator) { return React.useCallback( - (node: TreeNode | null) => visibleNodesGenerator?.focusNode(node), + (node: TreeNode | null) => visibleNodesGenerator.focusNode(node), [visibleNodesGenerator], ) } diff --git a/src/components/FileExplorer/hooks/useGoTo.tsx b/src/components/FileExplorer/hooks/useGoTo.tsx index 10c5e9b..f17918d 100644 --- a/src/components/FileExplorer/hooks/useGoTo.tsx +++ b/src/components/FileExplorer/hooks/useGoTo.tsx @@ -1,15 +1,14 @@ import * as React from 'react' import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' +import { useExpandTo } from './useExpandTo' export function useGoTo( - visibleNodesGenerator: VisibleNodesGenerator | null, + visibleNodesGenerator: VisibleNodesGenerator, updateSearchKey: React.Dispatch>, - expandTo: (currentPath: string[]) => Promise, + expandTo: ReturnType, ) { return React.useCallback( (path: string[]) => { - if (!visibleNodesGenerator) return - updateSearchKey('') visibleNodesGenerator.search(null) visibleNodesGenerator.onNextUpdate(() => expandTo(path)) diff --git a/src/components/FileExplorer/hooks/useHandleKeyDown.tsx b/src/components/FileExplorer/hooks/useHandleKeyDown.tsx index d0abee5..7532e52 100644 --- a/src/components/FileExplorer/hooks/useHandleKeyDown.tsx +++ b/src/components/FileExplorer/hooks/useHandleKeyDown.tsx @@ -3,7 +3,8 @@ import * as DOMHelper from 'utils/DOMHelper' import { OperatingSystems, os } from 'utils/general' import { loadWithPJAX } from 'utils/hooks/usePJAX' import { VisibleNodes } from 'utils/VisibleNodesGenerator' -import { VisibleNodesGeneratorMethods } from './useOnVisibleNodesGeneratorReady' +import { AlignMode } from '../useVirtualScroll' +import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods' function wouldBlockHistoryNavigation(event: React.KeyboardEvent) { // Cmd + left/right on macOS @@ -25,17 +26,19 @@ function getVisibleParentNode(nodes: TreeNode[], focusedNode: TreeNode) { } export function useHandleKeyDown( - visibleNodes: VisibleNodes | null, + visibleNodes: VisibleNodes, { focusNode, toggleExpansion, goTo }: VisibleNodesGeneratorMethods, searched: boolean, + setAlignMode: (mode: AlignMode) => void, ) { return React.useCallback( (event: React.KeyboardEvent) => { - if (!visibleNodes) return const { nodes, focusedNode, expandedNodes } = visibleNodes - function handleVerticalMove(index: number) { + + const handleVerticalMove = (index: number) => { if (0 <= index && index < nodes.length) { DOMHelper.focusFileExplorer() + setAlignMode('lazy') focusNode(nodes[index]) } else { DOMHelper.focusSearchInput() @@ -66,11 +69,13 @@ export function useHandleKeyDown( } if (expandedNodes.has(focusedNode.path)) { toggleExpansion(focusedNode, { recursive: event.altKey }) + setAlignMode('lazy') } else { // go forward to the start of the list, find the closest node with lower depth const parentNode = getVisibleParentNode(nodes, focusedNode) if (parentNode) { focusNode(parentNode) + setAlignMode('lazy') } } break @@ -87,6 +92,7 @@ export function useHandleKeyDown( const nextNode = nodes[focusedNodeIndex + 1] if (focusedNode.contents?.includes(nextNode)) { focusNode(nextNode) + setAlignMode('lazy') } } else { toggleExpansion(focusedNode, { recursive: event.altKey }) @@ -103,6 +109,7 @@ export function useHandleKeyDown( // expand node or redirect to file page if (searched) { goTo(focusedNode.path.split('/')) + setAlignMode('top') } else { if (focusedNode.type === 'tree') { toggleExpansion(focusedNode, { recursive: event.altKey }) @@ -145,6 +152,6 @@ export function useHandleKeyDown( } } }, - [visibleNodes, searched, goTo, focusNode, toggleExpansion], + [visibleNodes, searched, goTo, focusNode, toggleExpansion, setAlignMode], ) } diff --git a/src/components/FileExplorer/hooks/useNodeRenderContext.tsx b/src/components/FileExplorer/hooks/useNodeRenderContext.tsx deleted file mode 100644 index a739c51..0000000 --- a/src/components/FileExplorer/hooks/useNodeRenderContext.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react' -import { VisibleNodes } from 'utils/VisibleNodesGenerator' -import { NodeRendererContext } from '../index' -import { useHandleNodeFocus } from '../useHandleNodeFocus' -import { useNodeRenderers } from './useNodeRenderers' -import { useHandleNodeClick } from './useOnNodeClick' -import { useRenderLabelText } from './useRenderLabelText' - -export function useNodeRenderContext( - visibleNodes: VisibleNodes | null, - onNodeClick: ReturnType, - onNodeFocus: ReturnType, - renderActions: ReturnType, - renderLabelText: ReturnType, -): NodeRendererContext | null { - return React.useMemo( - () => - visibleNodes && { - visibleNodes, - onNodeClick, - onNodeFocus, - renderActions, - renderLabelText, - }, - [visibleNodes, onNodeClick, onNodeFocus, renderActions, renderLabelText], - ) -} diff --git a/src/components/FileExplorer/hooks/useOnNodeClick.tsx b/src/components/FileExplorer/hooks/useOnNodeClick.tsx index 6e0403b..84a8996 100644 --- a/src/components/FileExplorer/hooks/useOnNodeClick.tsx +++ b/src/components/FileExplorer/hooks/useOnNodeClick.tsx @@ -2,12 +2,17 @@ import { useConfigs } from 'containers/ConfigsContext' import * as React from 'react' import { isOpenInNewWindowClick } from 'utils/general' import { loadWithPJAX } from 'utils/hooks/usePJAX' -import { VisibleNodesGeneratorMethods } from './useOnVisibleNodesGeneratorReady' +import { AlignMode } from '../useVirtualScroll' +import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods' -export function useHandleNodeClick({ toggleExpansion, focusNode }: VisibleNodesGeneratorMethods) { +export function useHandleNodeClick( + { toggleExpansion, focusNode }: VisibleNodesGeneratorMethods, + setAlignMode: (mode: AlignMode) => void, +) { const { recursiveToggleFolder } = useConfigs().value return React.useCallback( (event: React.MouseEvent, node: TreeNode) => { + setAlignMode('lazy') switch (node.type) { case 'tree': { const recursive = @@ -40,6 +45,6 @@ export function useHandleNodeClick({ toggleExpansion, focusNode }: VisibleNodesG } } }, - [toggleExpansion, recursiveToggleFolder, focusNode], + [toggleExpansion, recursiveToggleFolder, focusNode, setAlignMode], ) } diff --git a/src/components/FileExplorer/hooks/useOnSearch.tsx b/src/components/FileExplorer/hooks/useOnSearch.tsx index f9b9fe8..2b88353 100644 --- a/src/components/FileExplorer/hooks/useOnSearch.tsx +++ b/src/components/FileExplorer/hooks/useOnSearch.tsx @@ -5,18 +5,16 @@ import { SearchMode, searchModes } from '../../searchModes' export function useOnSearch( updateSearchKey: (searchKey: string) => void, - visibleNodesGenerator: VisibleNodesGenerator | null, + visibleNodesGenerator: VisibleNodesGenerator, ) { const { restoreExpandedFolders } = useConfigs().value return React.useCallback( (searchKey: string, searchMode: SearchMode) => { updateSearchKey(searchKey) - if (visibleNodesGenerator) { - visibleNodesGenerator.search( - searchModes[searchMode].getSearchParams(searchKey), - restoreExpandedFolders, - ) - } + visibleNodesGenerator.search( + searchModes[searchMode].getSearchParams(searchKey), + restoreExpandedFolders, + ) }, [updateSearchKey, visibleNodesGenerator, restoreExpandedFolders], ) diff --git a/src/components/FileExplorer/hooks/useToggleExpansion.tsx b/src/components/FileExplorer/hooks/useToggleExpansion.tsx index 8cbf253..db06042 100644 --- a/src/components/FileExplorer/hooks/useToggleExpansion.tsx +++ b/src/components/FileExplorer/hooks/useToggleExpansion.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' -export function useToggleExpansion(visibleNodesGenerator: VisibleNodesGenerator | null) { +export function useToggleExpansion(visibleNodesGenerator: VisibleNodesGenerator) { return React.useCallback( async ( node: TreeNode, @@ -11,8 +11,6 @@ export function useToggleExpansion(visibleNodesGenerator: VisibleNodesGenerator recursive?: boolean }, ) => { - if (!visibleNodesGenerator) return - if (node.type === 'tree') { visibleNodesGenerator.focusNode(node) await visibleNodesGenerator.toggleExpand(node, recursive) diff --git a/src/components/FileExplorer/hooks/useVisibleNodesGenerator.tsx b/src/components/FileExplorer/hooks/useVisibleNodesGenerator.tsx index 9ddf107..35610f5 100644 --- a/src/components/FileExplorer/hooks/useVisibleNodesGenerator.tsx +++ b/src/components/FileExplorer/hooks/useVisibleNodesGenerator.tsx @@ -7,7 +7,7 @@ import { useSequentialEffect } from 'utils/hooks/useSequentialEffect' import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' import { SideBarStateContext } from '../../../containers/SideBarState' -export function useVisibleNodesGenerator(metaData: MetaData) { +export function useVisibleNodesGenerator(metaData: MetaData | null) { const [visibleNodesGenerator, setVisibleNodesGenerator] = useState( null, ) @@ -21,6 +21,7 @@ export function useVisibleNodesGenerator(metaData: MetaData) { useCallback( shouldAbort => { catchNetworkErrors(async () => { + if (!metaData) return if (shouldAbort()) return setStateContext('tree-loading') diff --git a/src/components/FileExplorer/hooks/useOnVisibleNodesGeneratorReady.tsx b/src/components/FileExplorer/hooks/useVisibleNodesGeneratorMethods.tsx similarity index 86% rename from src/components/FileExplorer/hooks/useOnVisibleNodesGeneratorReady.tsx rename to src/components/FileExplorer/hooks/useVisibleNodesGeneratorMethods.tsx index 81e858c..6363869 100644 --- a/src/components/FileExplorer/hooks/useOnVisibleNodesGeneratorReady.tsx +++ b/src/components/FileExplorer/hooks/useVisibleNodesGeneratorMethods.tsx @@ -7,7 +7,7 @@ import { useGoTo } from './useGoTo' import { useToggleExpansion } from './useToggleExpansion' export function useVisibleNodesGeneratorMethods( - visibleNodesGenerator: VisibleNodesGenerator | null, + visibleNodesGenerator: VisibleNodesGenerator, getCurrentPath: () => string[] | null, updateSearchKey: React.Dispatch>, ) { @@ -17,9 +17,8 @@ export function useVisibleNodesGeneratorMethods( const focusNode = useFocusNode(visibleNodesGenerator) // Only run when visibleNodesGenerator changes + // Confirmed: other items in deps array also only update when that changes useEffect(() => { - if (!visibleNodesGenerator) return - if (platform.shouldExpandAll?.()) { visibleNodesGenerator.onNextUpdate(visibleNodes => visibleNodes.nodes.forEach(node => toggleExpansion(node, { recursive: true })), @@ -28,7 +27,7 @@ export function useVisibleNodesGeneratorMethods( const targetPath = getCurrentPath() if (targetPath) goTo(targetPath) } - }, [visibleNodesGenerator]) // eslint-disable-line react-hooks/exhaustive-deps + }, [visibleNodesGenerator, getCurrentPath, goTo, toggleExpansion]) return { expandTo, diff --git a/src/components/FileExplorer/index.tsx b/src/components/FileExplorer/index.tsx index e9c7926..0640126 100644 --- a/src/components/FileExplorer/index.tsx +++ b/src/components/FileExplorer/index.tsx @@ -1,19 +1,22 @@ import { Label, Text } from '@primer/react' import { LoadingIndicator } from 'components/LoadingIndicator' import { SearchBar } from 'components/SearchBar' +import { useConfigs } from 'containers/ConfigsContext' +import { RepoContext } from 'containers/RepoContext' import { platform } from 'platforms' import * as React from 'react' +import { usePrevious } from 'react-use' +import { cx } from 'utils/cx' import { run } from 'utils/general' +import { useElementSize } from 'utils/hooks/useElementSize' import { useLoadedContext } from 'utils/hooks/useLoadedContext' import { useOnLocationChange } from 'utils/hooks/useOnLocationChange' import { useOnPJAXDone } from 'utils/hooks/usePJAX' -import { VisibleNodes } from 'utils/VisibleNodesGenerator' +import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' import { SideBarStateContext } from '../../containers/SideBarState' -import { SizeObserver } from '../SizeObserver' import { useFocusFileExplorerOnFirstRender } from './hooks/useFocusFileExplorerOnFirstRender' import { useGetCurrentPath } from './hooks/useGetCurrentPath' import { useHandleKeyDown } from './hooks/useHandleKeyDown' -import { useNodeRenderContext } from './hooks/useNodeRenderContext' import { NodeRenderer, useNodeRenderers, @@ -24,11 +27,12 @@ import { } from './hooks/useNodeRenderers' import { useHandleNodeClick } from './hooks/useOnNodeClick' import { useOnSearch } from './hooks/useOnSearch' -import { useVisibleNodesGeneratorMethods } from './hooks/useOnVisibleNodesGeneratorReady' import { useRenderLabelText } from './hooks/useRenderLabelText' import { useVisibleNodesGenerator } from './hooks/useVisibleNodesGenerator' -import { ListView } from './ListView' +import { useVisibleNodesGeneratorMethods } from './hooks/useVisibleNodesGeneratorMethods' +import { Node } from './Node' import { useHandleNodeFocus } from './useHandleNodeFocus' +import { AlignMode, useVirtualScroll } from './useVirtualScroll' import { useVisibleNodes } from './useVisibleNodes' export type NodeRendererContext = { @@ -39,17 +43,95 @@ export type NodeRendererContext = { visibleNodes: VisibleNodes } -type Props = { - metaData: MetaData -} - -export function FileExplorer({ metaData }: Props) { +export function FileExplorer() { + const metaData = React.useContext(RepoContext) const visibleNodesGenerator = useVisibleNodesGenerator(metaData) const visibleNodes = useVisibleNodes(visibleNodesGenerator) + const state = useLoadedContext(SideBarStateContext).value + return ( + <> + {run(() => { + switch (state) { + case 'tree-loading': + return + case 'tree-rendering': + return + case 'tree-rendered': + return ( + metaData && + visibleNodesGenerator && + visibleNodes && ( + + ) + ) + } + })} + + ) +} + +function LoadedFileExplorer({ + metaData, + visibleNodesGenerator, + visibleNodes, +}: { + metaData: MetaData + visibleNodesGenerator: VisibleNodesGenerator + visibleNodes: VisibleNodes +}) { const [searchKey, updateSearchKey] = React.useState('') const searched = !!searchKey const onSearch = useOnSearch(updateSearchKey, visibleNodesGenerator) + const { focusedNode, nodes, expandedNodes, depths, loading } = visibleNodes + + const { + ref: filesRef, + size: [, height], + } = useElementSize() + const { compactFileTree } = useConfigs().value + const { + ref: scrollElementRef, + onScroll, + visibleRows, + containerStyle, + scrollToItem, + } = useVirtualScroll({ + totalAmount: visibleNodes.nodes.length, + rowHeight: compactFileTree ? 24 : 37, + viewportHeight: height, + overScan: 10, + }) + + // - init loading + // - "top" + // - jump to file + // - "top" + // - tab to file + // - "lazy" + // - click file/folder + // - "lazy" + // - navigate with keyboard + // - "lazy" + const [alignMode, setAlignMode] = React.useState('top') + + const index = React.useMemo( + () => (focusedNode?.path ? nodes.findIndex(node => node.path === focusedNode.path) : -1), + [focusedNode?.path, nodes], + ) + + React.useLayoutEffect(() => { + if (index !== -1) scrollToItem(index, alignMode) + }, [index, scrollToItem, alignMode]) + const prevSearchKey = usePrevious(searchKey) + React.useEffect(() => { + // when start searching or stop searching + if (!prevSearchKey !== !searchKey) scrollToItem(0, alignMode) + }, [prevSearchKey, searchKey, scrollToItem, alignMode]) const getCurrentPath = useGetCurrentPath(metaData) const methods = useVisibleNodesGeneratorMethods( @@ -58,9 +140,9 @@ export function FileExplorer({ metaData }: Props) { updateSearchKey, ) const { expandTo, goTo, focusNode } = methods - const handleNodeClick = useHandleNodeClick(methods) - const handleNodeFocus = useHandleNodeFocus(methods) - const handleKeyDown = useHandleKeyDown(visibleNodes, methods, searched) + const handleNodeFocus = useHandleNodeFocus(methods, setAlignMode) + const handleNodeClick = useHandleNodeClick(methods, setAlignMode) + const handleKeyDown = useHandleKeyDown(visibleNodes, methods, searched, setAlignMode) const handleFocusSearchBar = () => focusNode(null) const renderActions = useNodeRenderers([ @@ -71,15 +153,6 @@ export function FileExplorer({ metaData }: Props) { ]) const renderLabelText = useRenderLabelText(searchKey) - const nodeRendererContext = useNodeRenderContext( - visibleNodes, - handleNodeClick, - handleNodeFocus, - renderActions, - renderLabelText, - ) - - const state = useLoadedContext(SideBarStateContext).value useFocusFileExplorerOnFirstRender() const goToCurrentItem = React.useCallback(() => { @@ -92,59 +165,68 @@ export function FileExplorer({ metaData }: Props) { return (
- {run(() => { - switch (state) { - case 'tree-loading': - return - case 'tree-rendering': - return - case 'tree-rendered': - return ( - visibleNodes && - nodeRendererContext && ( - <> - {visibleNodesGenerator?.defer && ( -
- -
- )} - - {searched && visibleNodes.nodes.length === 0 && ( - <> - - No results found. - - {visibleNodesGenerator?.defer && ( - - Search results are limited to loaded folders in Lazy Mode. - - )} - - )} - {visibleNodes.nodes.length > 0 && ( - > - {({ width = 0, height = 0 }, ref) => ( -
- -
- )} - - )} - + {visibleNodesGenerator?.defer && ( +
+ +
+ )} + + {searched && visibleNodes.nodes.length === 0 && ( + <> + + No results found. + + {visibleNodesGenerator?.defer && ( + + Search results are limited to loaded folders in Lazy Mode. + + )} + + )} +
+
+
+ {visibleRows.map(({ row, style }) => { + const node = nodes[row] + return ( + ) - ) - } - })} + })} +
+
+
) } diff --git a/src/components/FileExplorer/useHandleNodeFocus.tsx b/src/components/FileExplorer/useHandleNodeFocus.tsx index dcaf1f8..50fddfe 100644 --- a/src/components/FileExplorer/useHandleNodeFocus.tsx +++ b/src/components/FileExplorer/useHandleNodeFocus.tsx @@ -1,9 +1,16 @@ import * as React from 'react' -import { VisibleNodesGeneratorMethods } from './hooks/useOnVisibleNodesGeneratorReady' +import { VisibleNodesGeneratorMethods } from './hooks/useVisibleNodesGeneratorMethods' +import { AlignMode } from './useVirtualScroll' -export function useHandleNodeFocus({ focusNode }: VisibleNodesGeneratorMethods) { +export function useHandleNodeFocus( + { focusNode }: VisibleNodesGeneratorMethods, + setAlignMode: (mode: AlignMode) => void, +) { return React.useCallback( - (event: React.FocusEvent, node: TreeNode) => focusNode(node), - [focusNode], + (event: React.FocusEvent, node: TreeNode) => { + setAlignMode('lazy') + focusNode(node) + }, + [focusNode, setAlignMode], ) } diff --git a/src/components/FileExplorer/useLatestValueRef.tsx b/src/components/FileExplorer/useLatestValueRef.tsx new file mode 100644 index 0000000..dd3738f --- /dev/null +++ b/src/components/FileExplorer/useLatestValueRef.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; + +function useLatestValueRef(value: T) { + const ref = React.useRef(value); + React.useEffect(() => { + ref.current = value; + }); + return ref; +} +export function useCallbackRef( + callback: (...args: Args) => R): (...args: Args) => R { + const ref = useLatestValueRef(callback); + return React.useCallback((...args: Args) => ref.current(...args), [ref]); +} diff --git a/src/components/FileExplorer/useVirtualScroll.tsx b/src/components/FileExplorer/useVirtualScroll.tsx new file mode 100644 index 0000000..1883ab3 --- /dev/null +++ b/src/components/FileExplorer/useVirtualScroll.tsx @@ -0,0 +1,125 @@ +import * as React from 'react' +import { useCallbackRef } from './useLatestValueRef' + +function memoize( + fn: (...args: Args) => R, + serializeArguments: (...args: Args) => string | number, +): (...args: Args) => R { + const memory = new Map() + return (...args) => { + const key = serializeArguments(...args) + let r = memory.get(key) + if (!r) memory.set(key, (r = fn(...args))) + return r + } +} + +export type AlignMode = 'top' | 'end' | 'lazy' + +export function useVirtualScroll({ + totalAmount, + viewportHeight, + rowHeight, + overScan = 0, +}: { + totalAmount: number + overScan?: number + viewportHeight: number + rowHeight: number +}) { + const totalHeight = totalAmount * rowHeight + + const ref = React.useRef(null) // TODO: compare DOM native event listener + const [scrollTop, setScrollTop] = React.useState(0) + const [controlledScrollTop, setControlledScrollTop] = React.useState(0) + + const onScroll = React.useCallback((e: React.UIEvent) => { + setScrollTop(e.currentTarget.scrollTop) + }, []) + + const [startRenderIndex, endRenderIndex] = React.useMemo(() => { + const viewportLastItemOverflow = viewportHeight % rowHeight + const visibleRowCount = (viewportHeight - viewportLastItemOverflow) / rowHeight + const inViewIndexFirst = (Math.min(scrollTop, totalHeight - viewportHeight) / rowHeight) >> 0 + const inViewIndexLast = inViewIndexFirst + visibleRowCount + const renderIndexFirst = Math.max(0, inViewIndexFirst - overScan) + const renderIndexLast = Math.min(totalAmount, inViewIndexLast + overScan) + return [renderIndexFirst, renderIndexLast] + }, [scrollTop, viewportHeight, overScan, rowHeight, totalAmount, totalHeight]) + + const indexes = React.useMemo(() => { + const indexes: number[] = [] + let i = startRenderIndex + while (i < endRenderIndex) indexes.push(i++) + return indexes + }, [startRenderIndex, endRenderIndex]) + + const mapStyles = React.useCallback( + (row: number): React.CSSProperties => ({ + position: 'absolute', + top: 0, + transform: `translateY(${row * rowHeight}px)`, + width: '100%', + height: rowHeight, + }), + [rowHeight], + ) + const memoizedStyler = React.useMemo(() => memoize(mapStyles, row => row), [mapStyles]) + + const visibleRows: { row: number; style: React.CSSProperties }[] = React.useMemo( + () => + indexes.map(row => ({ + row, + style: memoizedStyler(row), + })), + [indexes, memoizedStyler], + ) + + React.useLayoutEffect(() => { + if (ref.current) { + ref.current.scrollTop = controlledScrollTop + } + }, [controlledScrollTop]) + + const containerStyle: React.CSSProperties = React.useMemo( + () => ({ + height: totalHeight, + position: 'relative', + }), + [totalHeight], + ) + + const scrollToItem = useCallbackRef((row: number, mode: AlignMode) => { + const getOffsetEnd = () => row * rowHeight + rowHeight - viewportHeight + const getOffsetTop = () => row * rowHeight + + const updateScrollPosition = (scrollTop: number) => { + setScrollTop(scrollTop) + setControlledScrollTop(scrollTop) + } + + switch (mode) { + case 'top': + return updateScrollPosition(getOffsetTop()) + case 'end': + return updateScrollPosition(getOffsetEnd()) + case 'lazy': { + const isAbove = row * rowHeight < scrollTop + const isBelow = row * rowHeight + rowHeight > scrollTop + viewportHeight + if (isBelow) { + updateScrollPosition(getOffsetEnd()) + } else if (isAbove) { + updateScrollPosition(getOffsetTop()) + } + } + } + }) + + return { + ref, + visibleRows, + onScroll, + containerStyle, + scrollToItem, + } +} diff --git a/src/components/MetaBar.tsx b/src/components/MetaBar.tsx index 0500067..ae31309 100644 --- a/src/components/MetaBar.tsx +++ b/src/components/MetaBar.tsx @@ -1,14 +1,14 @@ import { GitBranchIcon } from '@primer/octicons-react' import { Box, BranchName, Breadcrumbs, Text } from '@primer/react' +import { RepoContext } from 'containers/RepoContext' import { platform } from 'platforms' import * as React from 'react' import { createAnchorClickHandler } from 'utils/createAnchorClickHandler' -type Props = { - metaData: MetaData -} +export function MetaBar() { + const metaData = React.useContext(RepoContext) + if (!metaData) return null -export function MetaBar({ metaData }: Props) { const { userName, repoName, branchName } = metaData const { repoUrl, userUrl, branchUrl } = platform.resolveUrlFromMetaData(metaData) return ( diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 4b63ed9..aca0575 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -16,7 +16,6 @@ import { useLoadedContext } from 'utils/hooks/useLoadedContext' import { useOnPJAXDone, usePJAX } from 'utils/hooks/usePJAX' import { useStateIO } from 'utils/hooks/useStateIO' import { SideBarErrorContext } from '../containers/ErrorContext' -import { RepoContext } from '../containers/RepoContext' import { SideBarStateContext } from '../containers/SideBarState' import { Theme } from '../containers/Theme' import { useToggleSideBarWithKeyboard } from '../utils/hooks/useToggleSideBarWithKeyboard' @@ -25,7 +24,6 @@ import { RoundIconButton } from './RoundIconButton' import { SettingsBarContent } from './settings/SettingsBar' export function SideBar() { - const metaData = React.useContext(RepoContext) const state = useLoadedContext(SideBarStateContext).value const configContext = useConfigs() @@ -176,7 +174,7 @@ export function SideBar() { } /> - {metaData && } + {run(() => { switch (state) { @@ -189,8 +187,11 @@ export function SideBar() { return case 'error-due-to-auth': return - default: - return metaData && + case 'meta-loaded': + case 'tree-loading': + case 'tree-rendering': + case 'tree-rendered': + return } })} diff --git a/src/components/SideBarBodyWrapper.tsx b/src/components/SideBarBodyWrapper.tsx index 92bb720..96e0104 100644 --- a/src/components/SideBarBodyWrapper.tsx +++ b/src/components/SideBarBodyWrapper.tsx @@ -119,7 +119,7 @@ export function SideBarBodyWrapper({ style={{ height: heightForSafari }} onMouseLeave={onMouseLeave} > -
{children}
+ {children} {features.resize && ( = { - children(size: Partial, ref: React.MutableRefObject): React.ReactNode -} - -export function SizeObserver({ children }: Props) { - const ref = React.useRef(null) - - const [size, setSize] = React.useState>({ - width: undefined, - height: undefined, - }) - - React.useEffect(() => { - if (ref.current) { - if (features.resize) { - const observer = new window.ResizeObserver(entries => { - const entry = entries[0] - if (!entry) return - const rect = entry.contentRect - setSize(rect) - }) - observer.observe(ref.current) - return () => observer.disconnect() - } else { - if ('getBoundingClientRect' in ref.current) { - const rect = ref.current.getBoundingClientRect() - setSize(rect) - } - } - } - }, []) - - return <>{children(size, ref)} -} diff --git a/src/global.d.ts b/src/global.d.ts index 7c00502..a47db99 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,3 +1,5 @@ +type AnyArray = any[] // eslint-disable-line @typescript-eslint/no-explicit-any + type MetaData = { userName: string repoName: string diff --git a/src/styles/index.scss b/src/styles/index.scss index af8cfef..7878191 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -497,12 +497,11 @@ $minimal-z-index: max( } .file-explorer { - width: 100%; - height: 100%; outline: none; - display: flex; + display: inline-flex; + flex: 1; flex-direction: column; - flex-wrap: nowrap; + overflow: hidden; // essential for shrinking `.files` when viewport height reduce .search-input { padding-left: 0; @@ -534,6 +533,10 @@ $minimal-z-index: max( flex: 1; overflow: hidden; + &.hidden { + display: none; + } + // Put this inside files will help files to get proper size derived from parents, regardless of its content size .magic-size-container { position: absolute; @@ -607,7 +610,7 @@ $minimal-z-index: max( .octicon.ChevronRight { // smooth rotation animation - transition: transform .3s ease; + transition: transform 0.3s ease; } & + .octicon-wrapper, diff --git a/src/utils/DOMHelper.ts b/src/utils/DOMHelper.ts index 1881f5f..2884e70 100644 --- a/src/utils/DOMHelper.ts +++ b/src/utils/DOMHelper.ts @@ -132,7 +132,8 @@ export function copyElementContent(element: Element, trimLeadingSpace?: boolean) export function focusFileExplorer() { const sideBarContentSelector = '.gitako-side-bar .file-explorer' $(sideBarContentSelector, sideBarElement => { - if (sideBarElement instanceof HTMLElement) sideBarElement.focus() + if (document.activeElement !== sideBarElement && sideBarElement instanceof HTMLElement) + sideBarElement.focus() }) } diff --git a/src/utils/hooks/useElementSize.ts b/src/utils/hooks/useElementSize.ts new file mode 100644 index 0000000..ef87f22 --- /dev/null +++ b/src/utils/hooks/useElementSize.ts @@ -0,0 +1,32 @@ +import * as React from 'react' +import * as features from 'utils/features' +import { Size2D } from '../../components/SideBarBodyWrapper' + +export function useElementSize() { + const ref = React.useRef(null) + + const [size, setSize] = React.useState([0, 0]) + + React.useEffect(() => { + if (ref.current) { + if (features.resize) { + const observer = new window.ResizeObserver(entries => { + const entry = entries[0] + if (!entry) return + const { width, height } = entry.contentRect + setSize([width, height]) + }) + observer.observe(ref.current) + return () => observer.disconnect() + } else if ('getBoundingClientRect' in ref.current) { + const { width, height } = ref.current.getBoundingClientRect() + setSize([width, height]) + } + } + }, []) + + return { + ref, + size, + } +}