import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useMutate } from 'restful-react' import ReactDOM from 'react-dom' import { useInView } from 'react-intersection-observer' import { Button, Color, Container, FlexExpander, ButtonVariation, Layout, Text, ButtonSize, useToaster } from '@harness/uicore' import cx from 'classnames' import { Render } from 'react-jsx-match' import { Diff2HtmlUI } from 'diff2html/lib-esm/ui/js/diff2html-ui' import { useStrings } from 'framework/strings' import { CodeIcon, GitInfoProps } from 'utils/GitUtils' import { useEventListener } from 'hooks/useEventListener' import type { DiffFileEntry } from 'utils/types' import { useConfirmAct } from 'hooks/useConfirmAction' import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator' import { useAppContext } from 'AppContext' import type { TypesPullReq, TypesPullReqActivity } from 'services/code' import { getErrorMessage } from 'utils/Utils' import { activitiesToDiffCommentItems, activityToCommentItem, CommentType, DIFF2HTML_CONFIG, DiffCommentItem, DIFF_VIEWER_HEADER_HEIGHT, getCommentLineInfo, getDiffHTMLSnapshotFromRow, getRawTextInRange, PR_CODE_COMMENT_PAYLOAD_VERSION, PullRequestCodeCommentPayload, renderCommentOppositePlaceHolder, ViewStyle } from './DiffViewerUtils' import { CommentAction, CommentBox, CommentItem } from '../CommentBox/CommentBox' import css from './DiffViewer.module.scss' interface DiffViewerProps extends Pick { diff: DiffFileEntry viewStyle: ViewStyle stickyTopPosition?: number readOnly?: boolean pullRequestMetadata?: TypesPullReq onCommentUpdate: () => void } // // Note: Lots of direct DOM manipulations are used to boost performance. // Avoid React re-rendering at all cost as it might cause unresponsive UI // when diff content is big, or when a PR has a lot of changed files. // export const DiffViewer: React.FC = ({ diff, viewStyle, stickyTopPosition = 0, readOnly, repoMetadata, pullRequestMetadata, onCommentUpdate }) => { const { getString } = useStrings() const [viewed, setViewed] = useState(false) const [collapsed, setCollapsed] = useState(false) const [fileUnchanged] = useState(diff.unchangedPercentage === 100) const [fileDeleted] = useState(diff.isDeleted) const [renderCustomContent, setRenderCustomContent] = useState(fileUnchanged || fileDeleted) const [heightWithoutComments, setHeightWithoutComents] = useState('auto') const [diffRenderer, setDiffRenderer] = useState() const { ref: inViewRef, inView } = useInView({ rootMargin: '100px 0px' }) const containerRef = useRef(null) const { currentUser } = useAppContext() const { showError } = useToaster() const confirmAct = useConfirmAct() const path = useMemo( () => `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata?.number}/comments`, [repoMetadata.path, pullRequestMetadata?.number] ) const { mutate: saveComment } = useMutate({ verb: 'POST', path }) const { mutate: updateComment } = useMutate({ verb: 'PATCH', path: ({ id }) => `${path}/${id}` }) const { mutate: deleteComment } = useMutate({ verb: 'DELETE', path: ({ id }) => `${path}/${id}` }) const [comments, setComments] = useState[]>(activitiesToDiffCommentItems(diff)) const commentsRef = useRef[]>(comments) const setContainerRef = useCallback( node => { containerRef.current = node inViewRef(node) }, [inViewRef] ) const contentRef = useRef(null) const setupViewerInitialStates = useCallback(() => { setDiffRenderer( new Diff2HtmlUI( document.getElementById(diff.contentId) as HTMLElement, [diff], Object.assign({}, DIFF2HTML_CONFIG, { outputFormat: viewStyle }) ) ) }, [diff, viewStyle]) const renderDiffAndUpdateContainerHeightIfNeeded = useCallback( (enforced = false) => { const contentDOM = contentRef.current as HTMLDivElement const containerDOM = containerRef.current as HTMLDivElement if (!contentDOM.dataset.rendered || enforced) { if (!renderCustomContent || enforced) { containerDOM.style.height = 'auto' diffRenderer?.draw() } contentDOM.dataset.rendered = 'true' setHeightWithoutComents(containerDOM.clientHeight) } }, [diffRenderer, renderCustomContent] ) useEffect( function createDiffRenderer() { if (inView && !diffRenderer) { setupViewerInitialStates() } }, [inView, diffRenderer, setupViewerInitialStates] ) useEffect( function renderInitialContent() { if (diffRenderer && inView) { renderDiffAndUpdateContainerHeightIfNeeded() } }, [inView, diffRenderer, renderDiffAndUpdateContainerHeightIfNeeded] ) useEffect( function handleCollapsedState() { const containerDOM = containerRef.current as HTMLDivElement & { scrollIntoViewIfNeeded: () => void } const { classList: containerClassList, style: containerStyle } = containerDOM if (collapsed) { containerClassList.add(css.collapsed) // Fix scrolling position messes up with sticky header: When content of the diff content // is above the diff header, we need to scroll it back to below the header, adjust window // scrolling position to avoid the next diff scroll jump const { y } = containerDOM.getBoundingClientRect() if (y - stickyTopPosition < 1) { containerDOM.scrollIntoView() if (stickyTopPosition) { window.scroll({ top: window.scrollY - stickyTopPosition }) } } if (parseInt(containerStyle.height) != DIFF_VIEWER_HEADER_HEIGHT) { containerStyle.height = `${DIFF_VIEWER_HEADER_HEIGHT}px` } } else { containerClassList.remove(css.collapsed) const commentsHeight = comments.reduce((total, comment) => total + comment.height, 0) || 0 const newHeight = Number(heightWithoutComments) + commentsHeight if (parseInt(containerStyle.height) != newHeight) { containerStyle.height = `${newHeight}px` } } }, [collapsed, heightWithoutComments, stickyTopPosition, comments] ) useEventListener( 'click', useCallback( function clickToAddAnnotation(event: MouseEvent) { if (readOnly) { return } const target = event.target as HTMLDivElement const targetButton = target?.closest('[data-annotation-for-line]') as HTMLDivElement const annotatedLineRow = targetButton?.closest('tr') as HTMLTableRowElement const commentItem: DiffCommentItem = { left: false, right: false, lineNumber: 0, height: 0, commentItems: [] } if (targetButton && annotatedLineRow) { if (viewStyle === ViewStyle.SIDE_BY_SIDE) { const leftParent = targetButton.closest('.d2h-file-side-diff.left') commentItem.left = !!leftParent commentItem.right = !leftParent commentItem.lineNumber = Number(targetButton.dataset.annotationForLine) } else { const lineInfoTD = targetButton.closest('td')?.previousElementSibling const lineNum1 = lineInfoTD?.querySelector('.line-num1') const lineNum2 = lineInfoTD?.querySelector('.line-num2') // Right has priority commentItem.right = !!lineNum2?.textContent commentItem.left = !commentItem.right commentItem.lineNumber = Number(lineNum2?.textContent || lineNum1?.textContent) } setComments([...comments, commentItem]) } }, [viewStyle, comments, readOnly] ), containerRef.current as HTMLDivElement ) useEffect( function renderAnnotatations() { if (readOnly) { return } const isSideBySide = viewStyle === ViewStyle.SIDE_BY_SIDE // Update latest commentsRef to use it inside CommentBox callbacks commentsRef.current = comments comments.forEach(comment => { const lineInfo = getCommentLineInfo(contentRef.current, comment, viewStyle) if (lineInfo.rowElement) { const { rowElement } = lineInfo if (lineInfo.hasCommentsRendered) { if (isSideBySide) { const filesDiff = rowElement?.closest('.d2h-files-diff') as HTMLElement const sideDiff = filesDiff?.querySelector(`div.${comment.left ? 'right' : 'left'}`) as HTMLElement const oppositeRowPlaceHolder = sideDiff?.querySelector( `tr[data-place-holder-for-line="${comment.lineNumber}"]` ) const first = oppositeRowPlaceHolder?.firstElementChild as HTMLTableCellElement const last = oppositeRowPlaceHolder?.lastElementChild as HTMLTableCellElement if (first && last) { first.style.height = `${comment.height}px` last.style.height = `${comment.height}px` } } } else { // Mark row that it has comment/annotation rowElement.dataset.annotated = 'true' // Create a new row below it and render CommentBox inside const commentRowElement = document.createElement('tr') commentRowElement.dataset.annotatedLine = String(comment.lineNumber) commentRowElement.innerHTML = `` rowElement.after(commentRowElement) const element = commentRowElement.firstElementChild as HTMLTableCellElement // Note: CommentBox is rendered as an independent React component // everything passed to it must be either values, or refs. If you // pass callbacks or states, they won't be updated and might // . cause unexpected bugs ReactDOM.unmountComponentAtNode(element as HTMLDivElement) ReactDOM.render( { if (comment.height !== boxHeight) { comment.height = boxHeight setTimeout(() => setComments([...commentsRef.current]), 0) } }} onCancel={() => { // Clean up CommentBox rendering and reset states bound to lineInfo ReactDOM.unmountComponentAtNode(element as HTMLDivElement) commentRowElement.parentElement?.removeChild(commentRowElement) lineInfo.oppositeRowElement?.parentElement?.removeChild( lineInfo.oppositeRowElement?.nextElementSibling as Element ) delete lineInfo.rowElement.dataset.annotated setTimeout(() => setComments(commentsRef.current.filter(item => item !== comment)), 0) }} currentUserName={currentUser.display_name} handleAction={async (action, value, commentItem) => { let result = true let updatedItem: CommentItem | undefined = undefined const id = (commentItem as CommentItem)?.payload?.id switch (action) { case CommentAction.NEW: { // lineNumberRange can be used to allow multiple-line selection when commenting in the future const lineNumberRange = [comment.lineNumber] const payload: PullRequestCodeCommentPayload = { type: CommentType.CODE_COMMENT, version: PR_CODE_COMMENT_PAYLOAD_VERSION, file_id: diff.fileId, file_title: diff.fileTitle, language: diff.language || '', is_on_left: comment.left, at_line_number: comment.lineNumber, line_number_range: lineNumberRange, range_text_content: getRawTextInRange(diff, lineNumberRange), diff_html_snapshot: getDiffHTMLSnapshotFromRow(rowElement) } await saveComment({ type: CommentType.CODE_COMMENT, text: value, payload }) .then((newComment: TypesPullReqActivity) => { updatedItem = activityToCommentItem(newComment) }) .catch(exception => { result = false showError(getErrorMessage(exception), 0) }) break } case CommentAction.REPLY: { const parentComment = diff.fileActivities?.find( activity => (activity.payload as PullRequestCodeCommentPayload).file_id === diff.fileId ) if (parentComment) { await saveComment({ type: CommentType.CODE_COMMENT, text: value, parent_id: Number(parentComment.id as number) }) .then(newComment => { updatedItem = activityToCommentItem(newComment) }) .catch(exception => { result = false showError(getErrorMessage(exception), 0) }) } break } case CommentAction.DELETE: { result = false await confirmAct({ message: getString('deleteCommentConfirm'), action: async () => { await deleteComment({}, { pathParams: { id } }) .then(() => { result = true }) .catch(exception => { result = false showError(getErrorMessage(exception), 0, getString('pr.failedToDeleteComment')) }) } }) break } case CommentAction.UPDATE: { await updateComment({ text: value }, { pathParams: { id } }) .then(newComment => { updatedItem = activityToCommentItem(newComment) }) .catch(exception => { result = false showError(getErrorMessage(exception), 0) }) break } } if (result) { onCommentUpdate() } return [result, updatedItem] }} />, element ) // Split view: Calculate, inject, and adjust an empty place-holder row in the opposite pane if (isSideBySide && lineInfo.oppositeRowElement) { renderCommentOppositePlaceHolder(comment, lineInfo.oppositeRowElement) } } } // Comment no longer has UI relevant anchors to be rendered // else { // console.info('Comment is discarded due to no UI relevant anchors', { comment, lineInfo }) // } }) }, [ comments, viewStyle, getString, currentUser, readOnly, diff, saveComment, showError, updateComment, deleteComment, confirmAct ] ) useEffect(function cleanUpCommentBoxRendering() { const contentDOM = contentRef.current return () => { contentDOM ?.querySelectorAll('[data-annotated-line]') .forEach(element => ReactDOM.unmountComponentAtNode(element.firstElementChild as HTMLTableCellElement)) } }, []) return ( {getString(fileDeleted ? 'pr.fileDeleted' : 'pr.fileUnchanged')} ) } function getInitialCommentContentFromSelection(_diff: DiffFileEntry) { return '' }