mirror of
https://github.com/harness/drone.git
synced 2025-05-13 23:50:47 +08:00
325 lines
9.7 KiB
TypeScript
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>
|
|
)
|
|
}
|