feat: self implemented virtual scroller

This commit is contained in:
EnixCoda 2022-06-16 01:07:58 +08:00
parent 5a16682c7f
commit 31fc538a5c
25 changed files with 459 additions and 225 deletions

View file

@ -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<HTMLDivElement>({
totalAmount,
rowHeight,
viewportHeight: height,
overScan: 10,
})
const $mode = useStateIO<AlignMode>('top')
const enableScroll = width * height > 0 // these can be 0 on first render
const listRef = React.useRef<FixedSizeList<NodeRendererContext>>(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<Align>('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 (
<FixedSizeList<NodeRendererContext>
ref={listRef}
itemKey={(index, { visibleNodes }) => visibleNodes.nodes[index]?.path}
itemData={nodeRendererContext}
itemCount={visibleNodes.nodes.length}
itemSize={compactFileTree ? 24 : 37}
height={height}
width={'100%'}
<div
style={{
height,
width: '100%',
overflow: 'auto',
}}
ref={ref}
onScroll={onScroll}
>
{VirtualNode}
</FixedSizeList>
<div style={containerStyle}>
{visibleRows.map(({ row, style }) => {
const node = nodes[row]
return (
<Node
key={node.path}
node={node}
style={style}
depth={depths.get(node) || 0}
focused={focusedNode?.path === node.path}
loading={loading.has(node.path)}
expanded={expandedNodes.has(node.path)}
onClick={onNodeClick}
onFocus={onNodeFocus}
renderLabelText={renderLabelText}
renderActions={renderActions}
/>
)
})}
</div>
</div>
)
}

View file

@ -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 && <div className={'actions'}>{renderActions(node)}</div>}
</a>
)
}
})
const NodeItemIcon = React.memo(function NodeItemIcon({
node,

View file

@ -6,8 +6,9 @@ import { NodeRendererContext } from '.'
export const VirtualNode = React.memo(function VirtualNode({
index,
style,
data: { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes },
data,
}: Override<ListChildComponentProps, { data: NodeRendererContext }>) {
const { onNodeClick, onNodeFocus, renderLabelText, renderActions, visibleNodes } = data
if (!visibleNodes) return null
const { nodes, focusedNode, expandedNodes, loading, depths } = visibleNodes

View file

@ -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)
},

View file

@ -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],
)
}

View file

@ -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<React.SetStateAction<string>>,
expandTo: (currentPath: string[]) => Promise<void>,
expandTo: ReturnType<typeof useExpandTo>,
) {
return React.useCallback(
(path: string[]) => {
if (!visibleNodesGenerator) return
updateSearchKey('')
visibleNodesGenerator.search(null)
visibleNodesGenerator.onNextUpdate(() => expandTo(path))

View file

@ -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<HTMLElement>) => {
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],
)
}

View file

@ -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<typeof useHandleNodeClick>,
onNodeFocus: ReturnType<typeof useHandleNodeFocus>,
renderActions: ReturnType<typeof useNodeRenderers>,
renderLabelText: ReturnType<typeof useRenderLabelText>,
): NodeRendererContext | null {
return React.useMemo(
() =>
visibleNodes && {
visibleNodes,
onNodeClick,
onNodeFocus,
renderActions,
renderLabelText,
},
[visibleNodes, onNodeClick, onNodeFocus, renderActions, renderLabelText],
)
}

View file

@ -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<HTMLElement, 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],
)
}

View file

@ -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],
)

View file

@ -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)

View file

@ -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<VisibleNodesGenerator | null>(
null,
)
@ -21,6 +21,7 @@ export function useVisibleNodesGenerator(metaData: MetaData) {
useCallback(
shouldAbort => {
catchNetworkErrors(async () => {
if (!metaData) return
if (shouldAbort()) return
setStateContext('tree-loading')

View file

@ -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<React.SetStateAction<string>>,
) {
@ -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,

View file

@ -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 <LoadingIndicator text={'Fetching File List...'} />
case 'tree-rendering':
return <LoadingIndicator text={'Rendering File List...'} />
case 'tree-rendered':
return (
metaData &&
visibleNodesGenerator &&
visibleNodes && (
<LoadedFileExplorer
metaData={metaData}
visibleNodesGenerator={visibleNodesGenerator}
visibleNodes={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<HTMLDivElement>()
const { compactFileTree } = useConfigs().value
const {
ref: scrollElementRef,
onScroll,
visibleRows,
containerStyle,
scrollToItem,
} = useVirtualScroll<HTMLDivElement>({
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<AlignMode>('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 (
<div className={`file-explorer`} tabIndex={-1} onKeyDown={handleKeyDown}>
{run(() => {
switch (state) {
case 'tree-loading':
return <LoadingIndicator text={'Fetching File List...'} />
case 'tree-rendering':
return <LoadingIndicator text={'Rendering File List...'} />
case 'tree-rendered':
return (
visibleNodes &&
nodeRendererContext && (
<>
{visibleNodesGenerator?.defer && (
<div className={'status'}>
<Label
title="This repository is large. Gitako has switched to Lazy Mode to improve performance. Folders will be loaded on demand."
className={'lazy-mode'}
variant="attention"
>
Lazy Mode is ON
</Label>
</div>
)}
<SearchBar value={searchKey} onSearch={onSearch} onFocus={handleFocusSearchBar} />
{searched && visibleNodes.nodes.length === 0 && (
<>
<Text marginTop={6} textAlign="center" color="text.gray">
No results found.
</Text>
{visibleNodesGenerator?.defer && (
<Text textAlign="center" color="gray.4" fontSize="12px">
Search results are limited to loaded folders in Lazy Mode.
</Text>
)}
</>
)}
{visibleNodes.nodes.length > 0 && (
<SizeObserver<HTMLDivElement>>
{({ width = 0, height = 0 }, ref) => (
<div className={'files'} ref={ref}>
<ListView
height={height}
width={width}
nodeRendererContext={nodeRendererContext}
/>
</div>
)}
</SizeObserver>
)}
</>
{visibleNodesGenerator?.defer && (
<div className={'status'}>
<Label
title="This repository is large. Gitako has switched to Lazy Mode to improve performance. Folders will be loaded on demand."
className={'lazy-mode'}
variant="attention"
>
Lazy Mode is ON
</Label>
</div>
)}
<SearchBar value={searchKey} onSearch={onSearch} onFocus={handleFocusSearchBar} />
{searched && visibleNodes.nodes.length === 0 && (
<>
<Text marginTop={6} textAlign="center" color="text.gray">
No results found.
</Text>
{visibleNodesGenerator?.defer && (
<Text textAlign="center" color="gray.4" fontSize="12px">
Search results are limited to loaded folders in Lazy Mode.
</Text>
)}
</>
)}
<div
className={cx('files', {
// instead of unmounting, hide the element when not needed, so that the ref can be preserved after search result matches nothing
hidden: visibleNodes.nodes.length === 0,
})}
ref={filesRef}
>
<div
style={{
height,
width: '100%',
overflow: 'auto',
}}
ref={scrollElementRef}
onScroll={onScroll}
>
<div style={containerStyle}>
{visibleRows.map(({ row, style }) => {
const node = nodes[row]
return (
<Node
key={node.path}
node={node}
style={style}
depth={depths.get(node) || 0}
focused={focusedNode?.path === node.path}
loading={loading.has(node.path)}
expanded={expandedNodes.has(node.path)}
onClick={handleNodeClick}
onFocus={handleNodeFocus}
renderLabelText={renderLabelText}
renderActions={renderActions}
/>
)
)
}
})}
})}
</div>
</div>
</div>
</div>
)
}

View file

@ -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<HTMLElement, Element>, node: TreeNode) => focusNode(node),
[focusNode],
(event: React.FocusEvent<HTMLElement, Element>, node: TreeNode) => {
setAlignMode('lazy')
focusNode(node)
},
[focusNode, setAlignMode],
)
}

View file

@ -0,0 +1,14 @@
import * as React from 'react';
function useLatestValueRef<T>(value: T) {
const ref = React.useRef(value);
React.useEffect(() => {
ref.current = value;
});
return ref;
}
export function useCallbackRef<Args extends AnyArray, R>(
callback: (...args: Args) => R): (...args: Args) => R {
const ref = useLatestValueRef(callback);
return React.useCallback((...args: Args) => ref.current(...args), [ref]);
}

View file

@ -0,0 +1,125 @@
import * as React from 'react'
import { useCallbackRef } from './useLatestValueRef'
function memoize<Args extends AnyArray, R>(
fn: (...args: Args) => R,
serializeArguments: (...args: Args) => string | number,
): (...args: Args) => R {
const memory = new Map<string | number, R>()
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<E extends HTMLElement>({
totalAmount,
viewportHeight,
rowHeight,
overScan = 0,
}: {
totalAmount: number
overScan?: number
viewportHeight: number
rowHeight: number
}) {
const totalHeight = totalAmount * rowHeight
const ref = React.useRef<E | null>(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<E, 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,
}
}

View file

@ -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 (

View file

@ -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() {
}
/>
</div>
{metaData && <MetaBar metaData={metaData} />}
<MetaBar />
</div>
{run(() => {
switch (state) {
@ -189,8 +187,11 @@ export function SideBar() {
return <LoadingIndicator text={'Fetching repo meta...'} />
case 'error-due-to-auth':
return <AccessDeniedDescription />
default:
return metaData && <FileExplorer metaData={metaData} />
case 'meta-loaded':
case 'tree-loading':
case 'tree-rendering':
case 'tree-rendered':
return <FileExplorer />
}
})}
</div>

View file

@ -119,7 +119,7 @@ export function SideBarBodyWrapper({
style={{ height: heightForSafari }}
onMouseLeave={onMouseLeave}
>
<div className={'gitako-side-bar-body-wrapper-content'}>{children}</div>
{children}
{features.resize && (
<ResizeHandler
onResize={onResize}

View file

@ -1,42 +0,0 @@
import * as React from 'react'
import * as features from 'utils/features'
type Size = {
width: number
height: number
}
type Props<R extends Element> = {
children(size: Partial<Size>, ref: React.MutableRefObject<R | null>): React.ReactNode
}
export function SizeObserver<R extends Element>({ children }: Props<R>) {
const ref = React.useRef<R | null>(null)
const [size, setSize] = React.useState<Partial<Size>>({
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)}</>
}

2
src/global.d.ts vendored
View file

@ -1,3 +1,5 @@
type AnyArray = any[] // eslint-disable-line @typescript-eslint/no-explicit-any
type MetaData = {
userName: string
repoName: string

View file

@ -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,

View file

@ -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()
})
}

View file

@ -0,0 +1,32 @@
import * as React from 'react'
import * as features from 'utils/features'
import { Size2D } from '../../components/SideBarBodyWrapper'
export function useElementSize<E extends HTMLElement>() {
const ref = React.useRef<E | null>(null)
const [size, setSize] = React.useState<Size2D>([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,
}
}