CODE-520: FE: optimize loading of file lists for large repos

This commit is contained in:
“tan-nhu” 2023-08-10 10:17:49 -07:00
parent 10ea3fa640
commit c1a23445dd
4 changed files with 131 additions and 26 deletions

4
.gitignore vendored
View File

@ -6,7 +6,9 @@ _research
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
web/node_modules web/node_modules
web/dist/files web/dist
web/coverage
yarn-error*
release release
.idea .idea
.vscode/settings.json .vscode/settings.json

6
web/.gitignore vendored
View File

@ -1,6 +0,0 @@
node_modules
dist
coverage
.env
yarn-error*
.DS_Store

View File

@ -1,15 +1,18 @@
import React, { useMemo } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Container, Color, TableV2 as Table, Text, Utils } from '@harness/uicore' import { Container, Color, TableV2 as Table, Text, Utils } from '@harness/uicore'
import type { CellProps, Column } from 'react-table' import type { CellProps, Column } from 'react-table'
import { Render } from 'react-jsx-match' import { Render } from 'react-jsx-match'
import { sortBy } from 'lodash-es' import { chunk, clone, sortBy, throttle } from 'lodash-es'
import { useMutate } from 'restful-react'
import { useHistory } from 'react-router-dom' import { useHistory } from 'react-router-dom'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import type { OpenapiContentInfo, OpenapiDirContent } from 'services/code' import type { OpenapiContentInfo, OpenapiDirContent, TypesCommit } from 'services/code'
import { formatDate } from 'utils/Utils' import { formatDate, LIST_FETCHING_LIMIT } from 'utils/Utils'
import { findReadmeInfo, CodeIcon, GitInfoProps, isFile } from 'utils/GitUtils' import { findReadmeInfo, CodeIcon, GitInfoProps, isFile } from 'utils/GitUtils'
import { LatestCommitForFolder } from 'components/LatestCommit/LatestCommit' import { LatestCommitForFolder } from 'components/LatestCommit/LatestCommit'
import { useEventListener } from 'hooks/useEventListener'
import { Readme } from './Readme' import { Readme } from './Readme'
import repositoryCSS from '../../Repository.module.scss'
import css from './FolderContent.module.scss' import css from './FolderContent.module.scss'
export function FolderContent({ export function FolderContent({
@ -18,7 +21,7 @@ export function FolderContent({
gitRef gitRef
}: Pick<GitInfoProps, 'repoMetadata' | 'resourceContent' | 'gitRef'>) { }: Pick<GitInfoProps, 'repoMetadata' | 'resourceContent' | 'gitRef'>) {
const history = useHistory() const history = useHistory()
const { routes } = useAppContext() const { routes, standalone } = useAppContext()
const columns: Column<OpenapiContentInfo>[] = useMemo( const columns: Column<OpenapiContentInfo>[] = useMemo(
() => [ () => [
{ {
@ -27,6 +30,7 @@ export function FolderContent({
Cell: ({ row }: CellProps<OpenapiContentInfo>) => { Cell: ({ row }: CellProps<OpenapiContentInfo>) => {
return ( return (
<Text <Text
data-resource-path={row.original.path}
lineClamp={1} lineClamp={1}
className={css.rowText} className={css.rowText}
color={Color.BLACK} color={Color.BLACK}
@ -79,6 +83,91 @@ export function FolderContent({
[] // eslint-disable-line react-hooks/exhaustive-deps [] // eslint-disable-line react-hooks/exhaustive-deps
) )
const readmeInfo = useMemo(() => findReadmeInfo(resourceContent), [resourceContent]) const readmeInfo = useMemo(() => findReadmeInfo(resourceContent), [resourceContent])
const scrollElement = useMemo(
() => (standalone ? document.querySelector(`.${repositoryCSS.main}`)?.parentElement : window) as HTMLElement,
[standalone]
)
const resourceEntries = useMemo(
() => sortBy((resourceContent.content as OpenapiDirContent)?.entries || [], ['type', 'name']),
[resourceContent.content]
)
const [pathsChunks, setPathsChunks] = useState<PathsChunks>([])
const { mutate: fetchLastCommitsForPaths } = useMutate<PathDetails>({
verb: 'POST',
path: `/api/v1/repos/${encodeURIComponent(repoMetadata.path as string)}/path-details`
})
const [lastCommitMapping, setLastCommitMapping] = useState<Record<string, TypesCommit>>({})
const mergedContentEntries = useMemo(
() => resourceEntries.map(entry => ({ ...entry, latest_commit: lastCommitMapping[entry.path as string] })),
[resourceEntries, lastCommitMapping]
)
// The idea is to fetch last commit details for chunks that has atleast one path which is
// rendered in the viewport
// eslint-disable-next-line react-hooks/exhaustive-deps
const scrollCallback = useCallback(
throttle(() => {
pathsChunks.forEach(pathsChunk => {
const { paths, loaded, loading } = pathsChunk
if (!loaded && !loading) {
for (let i = 0; i < paths.length; i++) {
const element = document.querySelector(`[data-resource-path="${paths[i]}"]`)
if (element && isInViewport(element)) {
pathsChunk.loading = true
setPathsChunks(pathsChunks.map(_chunk => (pathsChunk === _chunk ? pathsChunk : _chunk)))
fetchLastCommitsForPaths({ paths })
.then(response => {
const pathMapping: Record<string, TypesCommit> = clone(lastCommitMapping)
pathsChunk.loaded = true
setPathsChunks(pathsChunks.map(_chunk => (pathsChunk === _chunk ? pathsChunk : _chunk)))
response?.details?.forEach(({ path, last_commit }) => {
pathMapping[path] = last_commit
})
setLastCommitMapping(pathMapping)
})
.catch(error => {
pathsChunk.loaded = false
pathsChunk.loading = false
setPathsChunks(pathsChunks.map(_chunk => (pathsChunk === _chunk ? pathsChunk : _chunk)))
console.log('Failed to fetch path commit details', error) // eslint-disable-line no-console
})
break
}
}
}
})
}, 100),
[pathsChunks, lastCommitMapping]
)
// Group all resourceEntries paths into chunks, each has LIST_FETCHING_LIMIT paths
useEffect(() => {
setPathsChunks(
chunk(resourceEntries.map(entry => entry.path as string) || [], LIST_FETCHING_LIMIT).map(paths => ({
paths,
loaded: false,
loading: false
}))
)
}, [resourceEntries])
useEventListener('scroll', scrollCallback, scrollElement)
// Trigger scroll event callback on mount and cancel it on unmount
useEffect(() => {
scrollCallback()
return () => {
scrollCallback.cancel()
}
}, [scrollCallback])
return ( return (
<Container className={css.folderContent}> <Container className={css.folderContent}>
@ -88,13 +177,13 @@ export function FolderContent({
className={css.table} className={css.table}
hideHeaders hideHeaders
columns={columns} columns={columns}
data={sortBy((resourceContent.content as OpenapiDirContent)?.entries || [], ['type', 'name'])} data={mergedContentEntries}
onRowClick={data => { onRowClick={entry => {
history.push( history.push(
routes.toCODERepository({ routes.toCODERepository({
repoPath: repoMetadata.path as string, repoPath: repoMetadata.path as string,
gitRef, gitRef,
resourcePath: data.path resourcePath: entry.path
}) })
) )
}} }}
@ -107,3 +196,19 @@ export function FolderContent({
</Container> </Container>
) )
} }
function isInViewport(element: Element) {
const rect = element.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
)
}
interface PathDetails {
details: Array<{ path: string; last_commit: TypesCommit }>
}
type PathsChunks = Array<{ paths: string[]; loaded: boolean; loading: boolean }>

View File

@ -127,11 +127,13 @@ const LOCALE = Intl.NumberFormat().resolvedOptions?.().locale || 'en-US'
* @param timeStyle Optional DateTimeFormat's `timeStyle` option. * @param timeStyle Optional DateTimeFormat's `timeStyle` option.
*/ */
export function formatTime(timestamp: number | string, timeStyle = 'short'): string { export function formatTime(timestamp: number | string, timeStyle = 'short'): string {
return new Intl.DateTimeFormat(LOCALE, { return timestamp
? new Intl.DateTimeFormat(LOCALE, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: TS built-in type for DateTimeFormat is not correct // @ts-ignore: TS built-in type for DateTimeFormat is not correct
timeStyle timeStyle
}).format(new Date(timestamp)) }).format(new Date(timestamp))
: ''
} }
/** /**
@ -140,11 +142,13 @@ export function formatTime(timestamp: number | string, timeStyle = 'short'): str
* @param dateStyle Optional DateTimeFormat's `dateStyle` option. * @param dateStyle Optional DateTimeFormat's `dateStyle` option.
*/ */
export function formatDate(timestamp: number | string, dateStyle = 'medium'): string { export function formatDate(timestamp: number | string, dateStyle = 'medium'): string {
return new Intl.DateTimeFormat(LOCALE, { return timestamp
? new Intl.DateTimeFormat(LOCALE, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: TS built-in type for DateTimeFormat is not correct // @ts-ignore: TS built-in type for DateTimeFormat is not correct
dateStyle dateStyle
}).format(new Date(timestamp)) }).format(new Date(timestamp))
: ''
} }
/** /**
@ -153,7 +157,7 @@ export function formatDate(timestamp: number | string, dateStyle = 'medium'): st
* @returns Formatted string. * @returns Formatted string.
*/ */
export function formatNumber(num: number | bigint): string { export function formatNumber(num: number | bigint): string {
return new Intl.NumberFormat(LOCALE).format(num) return num ? new Intl.NumberFormat(LOCALE).format(num) : ''
} }
/** /**