drone/web/src/pages/Repository/RepositoryContent/FileContent/GitBlame.tsx
2023-04-06 15:48:58 -07:00

325 lines
9.7 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Avatar, Container, FontVariation, Layout, StringSubstitute, Text } from '@harness/uicore'
import type { ViewUpdate } from '@codemirror/view'
import { EditorView, gutter, GutterMarker, WidgetType } from '@codemirror/view'
import { Compartment } from '@codemirror/state'
import ReactTimeago from 'react-timeago'
import { useGet } from 'restful-react'
import { Render } from 'react-jsx-match'
import { noop } from 'lodash-es'
import type { GitrpcBlamePart } from 'services/code'
import type { GitInfoProps } from 'utils/GitUtils'
import { useStrings } from 'framework/strings'
import { getErrorMessage } from 'utils/Utils'
import { Editor } from 'components/Editor/Editor'
import { lineWidget, LineWidgetPosition, LineWidgetSpec } from './lineWidget'
import css from './GitBlame.module.scss'
interface BlameBlock {
fromLineNumber: number
toLineNumber: number
topPosition: number
heights: Record<number, number>
commitInfo: GitrpcBlamePart['commit']
lines: GitrpcBlamePart['lines']
numberOfLines: number
}
type BlameBlockRecord = Record<number, BlameBlock>
const INITIAL_TOP_POSITION = -1
export const GitBlame: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'resourcePath'>> = ({
repoMetadata,
resourcePath
}) => {
const { getString } = useStrings()
const [blameBlocks, setBlameBlocks] = useState<BlameBlockRecord>({})
const { data, error, loading } = useGet<GitrpcBlamePart[]>({
path: `/api/v1/repos/${repoMetadata?.path}/+/blame/${resourcePath}`,
lazy: !repoMetadata || !resourcePath
})
useEffect(() => {
if (data) {
let fromLineNumber = 1
data.forEach(({ commit, lines }) => {
const toLineNumber = fromLineNumber + (lines?.length || 0) - 1
blameBlocks[fromLineNumber] = {
fromLineNumber,
toLineNumber,
topPosition: INITIAL_TOP_POSITION,
heights: {},
commitInfo: commit,
lines: lines,
numberOfLines: lines?.length || 0
}
fromLineNumber = toLineNumber + 1
})
setBlameBlocks({ ...blameBlocks })
}
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
const findBlockForLineNumber = useCallback(
lineNumber => {
let startLine = lineNumber
while (!blameBlocks[startLine] && startLine > 0) {
startLine--
}
return blameBlocks[startLine]
},
[blameBlocks]
)
// eslint-disable-next-line react-hooks/exhaustive-deps
const onViewUpdate = useCallback(
({ view, geometryChanged }: ViewUpdate) => {
if (geometryChanged) {
view.viewportLineBlocks.forEach(lineBlock => {
const { from, top, height } = lineBlock
const lineNumber = view.state.doc.lineAt(from).number
const blockAtLineNumber = findBlockForLineNumber(lineNumber)
if (!blockAtLineNumber) {
// eslint-disable-next-line no-console
console.error('Bad math! Cannot find a blame block for line', lineNumber)
} else {
if (blockAtLineNumber.topPosition === INITIAL_TOP_POSITION) {
blockAtLineNumber.topPosition = top
}
// CodeMirror reports top position of a block incorrectly sometimes, so we need to normalize it
// using dimensions of the previous block.
if (lineNumber > 1) {
const previousBlock = findBlockForLineNumber(lineNumber - 1)
if (previousBlock.fromLineNumber !== blockAtLineNumber.fromLineNumber) {
blockAtLineNumber.topPosition = previousBlock.topPosition + computeHeight(previousBlock.heights)
}
}
blockAtLineNumber.heights[lineNumber] = height
const blockDOM = document.querySelector(
`.${css.blameBox}[data-block-from-line="${blockAtLineNumber.fromLineNumber}"]`
) as HTMLDivElement
if (blockDOM) {
const _height = `${computeHeight(blockAtLineNumber.heights)}px`
const _top = `${blockAtLineNumber.topPosition}px`
if (blockDOM.style.height !== _height || blockDOM.style.top !== _top) {
blockDOM.style.height = _height
blockDOM.style.top = _top
if (blockAtLineNumber.topPosition !== INITIAL_TOP_POSITION) {
blockDOM.removeAttribute('data-block-top')
}
}
}
}
})
}
},
[] // eslint-disable-line react-hooks/exhaustive-deps
)
// TODO: Normalize loading and error rendering when implementing new Design layout
// that have Blame in a separate tab.
if (loading) {
return <Container padding="xlarge">{getString('loading')}</Container>
}
if (error) {
return <Container padding="xlarge">{getErrorMessage(error)}</Container>
}
return (
<Container className={css.gitBlame}>
<Layout.Horizontal className={css.layout}>
<Container className={css.blameColumn}>
{Object.values(blameBlocks).map(blameInfo => (
<GitBlameMetaInfo key={blameInfo.fromLineNumber} {...blameInfo} />
))}
</Container>
<Render when={Object.values(blameBlocks).length}>
<GitBlameRenderer
source={data?.map(({ lines }) => (lines as string[]).join('\n')).join('\n') || ''}
filename={resourcePath}
onViewUpdate={onViewUpdate}
blameBlocks={blameBlocks}
/>
</Render>
</Layout.Horizontal>
</Container>
)
}
class CustomLineNumber extends GutterMarker {
lineNumber: number
constructor(lineNumber: number) {
super()
this.lineNumber = lineNumber
}
toDOM() {
const element = document.createElement('div')
element.textContent = this.lineNumber.toString()
element.classList.add(css.lineNo)
return element
}
}
interface GitBlameRendererProps {
filename: string
source: string
onViewUpdate?: (update: ViewUpdate) => void
blameBlocks: BlameBlockRecord
}
interface EditorLinePaddingWidgetSpec extends LineWidgetSpec {
blockLines: number
}
const GitBlameRenderer = React.memo(function GitBlameSourceViewer({
source,
filename,
onViewUpdate = noop,
blameBlocks
}: GitBlameRendererProps) {
const extensions = useMemo(() => new Compartment(), [])
const viewRef = useRef<EditorView>()
useEffect(() => {
const customLineNumberGutter = gutter({
lineMarker(_view, line) {
const lineNumber: number = _view.state.doc.lineAt(line.from).number
return new CustomLineNumber(lineNumber)
}
})
const lineWidgetSpec: EditorLinePaddingWidgetSpec[] = []
Object.values(blameBlocks).forEach(block => {
const blockLines = block.numberOfLines
lineWidgetSpec.push({
lineNumber: block.fromLineNumber,
position: LineWidgetPosition.TOP,
blockLines
})
lineWidgetSpec.push({
lineNumber: block.toLineNumber,
position: LineWidgetPosition.BOTTOM,
blockLines
})
})
viewRef.current?.dispatch({
effects: extensions.reconfigure([
customLineNumberGutter,
lineWidget({
spec: lineWidgetSpec,
widgetFor: spec => new EditorLinePaddingWidget(spec)
})
])
})
}, [extensions, blameBlocks])
return (
<Editor
viewRef={viewRef}
filename={filename}
source={source}
readonly={true}
className={css.main}
onViewUpdate={onViewUpdate}
extensions={extensions.of([])}
/>
)
})
class EditorLinePaddingWidget extends WidgetType {
constructor(readonly spec: EditorLinePaddingWidgetSpec) {
super()
}
toDOM() {
const { blockLines, position, lineNumber } = this.spec
let height = 8
if (position === LineWidgetPosition.BOTTOM && blockLines <= 4) {
height += (5 - blockLines) * 15
}
const div = document.createElement('div')
div.setAttribute('aria-hidden', 'true')
div.setAttribute('data-line-number', String(lineNumber))
div.setAttribute('data-position', position)
div.style.height = `${height}px`
return div
}
eq() {
return false
}
ignoreEvent() {
return false
}
}
function computeHeight(heights: Record<number, number>) {
return Object.values(heights).reduce((a, b) => a + b, 0)
}
function GitBlameMetaInfo({ fromLineNumber, toLineNumber, topPosition, heights, commitInfo }: BlameBlock) {
const height = computeHeight(heights)
const { getString } = useStrings()
return (
<Container
className={css.blameBox}
data-block-from-line={`${fromLineNumber}`}
data-block-to-line={`${toLineNumber}`}
data-block-top={`${topPosition}`}
key={`${fromLineNumber}-${height}`}
height={height}
style={{ top: topPosition }}>
<Layout.Horizontal spacing="small" className={css.blameBoxLayout}>
<Container>
<Avatar name={commitInfo?.author?.identity?.name} size="normal" hoverCard={false} />
</Container>
<Container style={{ flexGrow: 1 }}>
<Layout.Vertical spacing="xsmall">
<Text
font={{ variation: FontVariation.BODY }}
lineClamp={2}
tooltipProps={{
portalClassName: css.blameCommitPortalClass
}}>
{commitInfo?.title}
</Text>
<Text font={{ variation: FontVariation.BODY }} lineClamp={1}>
<StringSubstitute
str={getString('blameCommitLine')}
vars={{
author: <strong>{commitInfo?.author?.identity?.name as string}</strong>,
timestamp: <ReactTimeago date={commitInfo?.author?.when as string} />
}}
/>
</Text>
</Layout.Vertical>
</Container>
</Layout.Horizontal>
</Container>
)
}