mirror of
https://github.com/EnixCoda/Gitako.git
synced 2026-03-11 08:54:44 +00:00
feat: self implemented virtual scroller
This commit is contained in:
parent
5a16682c7f
commit
31fc538a5c
25 changed files with 459 additions and 225 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
14
src/components/FileExplorer/useLatestValueRef.tsx
Normal file
14
src/components/FileExplorer/useLatestValueRef.tsx
Normal 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]);
|
||||
}
|
||||
125
src/components/FileExplorer/useVirtualScroll.tsx
Normal file
125
src/components/FileExplorer/useVirtualScroll.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
2
src/global.d.ts
vendored
|
|
@ -1,3 +1,5 @@
|
|||
type AnyArray = any[] // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
type MetaData = {
|
||||
userName: string
|
||||
repoName: string
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
32
src/utils/hooks/useElementSize.ts
Normal file
32
src/utils/hooks/useElementSize.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue