drone/web/src/components/DiffViewer/DiffViewer.tsx
2024-06-26 22:55:58 -07:00

625 lines
22 KiB
TypeScript

/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { noop } from 'lodash-es'
import { useGet, useMutate } from 'restful-react'
import {
Button,
Container,
FlexExpander,
ButtonVariation,
Layout,
Text,
ButtonSize,
Checkbox,
useIsMounted,
useToaster
} from '@harnessio/uicore'
import cx from 'classnames'
import * as Diff2Html from 'diff2html'
import { Render } from 'react-jsx-match'
import { Link } from 'react-router-dom'
import { useInView } from 'react-intersection-observer'
import { Diff2HtmlUI } from 'diff2html/lib-esm/ui/js/diff2html-ui'
import { Icon } from '@harnessio/icons'
import { Color } from '@harnessio/design-system'
import { useStrings } from 'framework/strings'
import { CodeIcon, GitInfoProps } from 'utils/GitUtils'
import type { DiffFileEntry } from 'utils/types'
import { useAppContext } from 'AppContext'
import type { GitFileDiff, TypesPullReq, TypesPullReqActivity } from 'services/code'
import { CopyButton } from 'components/CopyButton/CopyButton'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import type { UseGetPullRequestInfoResult } from 'pages/PullRequest/useGetPullRequestInfo'
import { useQueryParams } from 'hooks/useQueryParams'
import { useCustomEventListener, useEventListener } from 'hooks/useEventListener'
import { useShowRequestError } from 'hooks/useShowRequestError'
import { getErrorMessage, isInViewport } from 'utils/Utils'
import { createRequestAnimationFrameTaskPool } from 'utils/Task'
import { useResizeObserver } from 'hooks/useResizeObserver'
import { useFindGitBranch } from 'hooks/useFindGitBranch'
import Config from 'Config'
import {
DIFF2HTML_CONFIG,
DIFF_VIEWER_HEADER_HEIGHT,
ViewStyle,
getFileViewedState,
FileViewedState,
DiffCommentItem
} from './DiffViewerUtils'
import { usePullReqComments } from './usePullReqComments'
import Collapse from '../../icons/collapse.svg'
import Expand from '../../icons/expand.svg'
import css from './DiffViewer.module.scss'
interface DiffViewerProps extends Pick<GitInfoProps, 'repoMetadata'> {
diff: DiffFileEntry
viewStyle: ViewStyle
stickyTopPosition?: number
readOnly?: boolean
pullRequestMetadata?: TypesPullReq
targetRef?: string
sourceRef?: string
commitRange?: string[]
scrollElement: HTMLElement
commitSHA?: string
refetchActivities?: UseGetPullRequestInfoResult['refetchActivities']
memorizedState: Map<string, DiffViewerExchangeState>
fullDiffAPIPath: string
}
const DiffViewerInternal: React.FC<DiffViewerProps> = ({
diff,
viewStyle,
stickyTopPosition = 0,
readOnly,
repoMetadata,
pullRequestMetadata: pullReqMetadata,
targetRef,
sourceRef,
commitRange,
scrollElement,
commitSHA,
refetchActivities,
memorizedState,
fullDiffAPIPath
}) => {
const { routes } = useAppContext()
const { getString } = useStrings()
const { showError } = useToaster()
const viewedPath = useMemo(
() => `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullReqMetadata?.number}/file-views`,
[repoMetadata.path, pullReqMetadata?.number]
)
const { mutate: markViewed } = useMutate({ verb: 'PUT', path: viewedPath })
const { mutate: unmarkViewed } = useMutate({ verb: 'DELETE', path: ({ filePath }) => `${viewedPath}/${filePath}` })
const { path } = useQueryParams<{ path: string; commentId: string }>()
const shouldDiffBeShownByDefault = useMemo(() => path === diff.filePath, [path, diff.filePath])
const diffHasVeryLongLine = useMemo(
() => diff.blocks?.some(block => block.lines?.some(line => line.content?.length > Config.MAX_TEXT_LINE_SIZE_LIMIT)),
[diff]
)
// File viewed feature is only enabled if no commit range is provided (otherwise component is hidden, too)
const [viewed, setViewed] = useState(
commitRange?.length === 0 &&
getFileViewedState(diff.filePath, diff.checksumAfter, diff.fileViews) === FileViewedState.VIEWED &&
!shouldDiffBeShownByDefault
)
useEffect(() => {
if (commitRange?.length === 0) {
setViewed(getFileViewedState(diff.filePath, diff.checksumAfter, diff.fileViews) === FileViewedState.VIEWED)
}
}, [setViewed, diff.fileViews, diff.filePath, diff.checksumAfter, commitRange])
const showChangedSinceLastView = useMemo(
() =>
!readOnly &&
commitRange?.length === 0 &&
getFileViewedState(diff.filePath, diff.checksumAfter, diff.fileViews) === FileViewedState.CHANGED,
[readOnly, commitRange?.length, diff.filePath, diff.checksumAfter, diff.fileViews]
)
const [collapsed, setCollapsed] = useState(viewed || !!memorizedState.get(diff.filePath)?.collapsed)
const isBinary = useMemo(() => diff.isBinary, [diff.isBinary])
const fileUnchanged = useMemo(
() => diff.unchangedPercentage === 100 || (diff.addedLines === 0 && diff.deletedLines === 0),
[diff.addedLines, diff.deletedLines, diff.unchangedPercentage]
)
const fileDeleted = useMemo(() => diff.isDeleted, [diff.isDeleted])
const isDiffTooLarge = useMemo(
() => diff.addedLines + diff.deletedLines > Config.PULL_REQUEST_LARGE_DIFF_CHANGES_LIMIT,
[diff.addedLines, diff.deletedLines]
)
const [renderCustomContent, setRenderCustomContent] = useState(
!shouldDiffBeShownByDefault && (fileUnchanged || fileDeleted || isDiffTooLarge || isBinary || diffHasVeryLongLine)
)
const containerRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement>(null)
const diff2HtmlRef = useRef<{ renderer: Diff2HtmlUI; diff: DiffFileEntry }>()
const [dirty, setDirty] = useState(false)
const isMounted = useIsMounted()
const [useFullDiff, setUseFullDiff] = useState(!!memorizedState.get(diff.filePath)?.useFullDiff)
const { ref, inView } = useInView({
rootMargin: `500px 0px 500px 0px`,
initialInView: true
})
const setContainerRef = useCallback(
node => {
containerRef.current = node
ref(node)
},
[ref]
)
const contentHTML = useRef<string | null>(null)
useResizeObserver(
contentRef,
useCallback(
dom => {
if (isMounted.current && dom) {
dom.style.setProperty(BLOCK_HEIGHT, dom.clientHeight + 'px')
}
},
[isMounted]
)
)
//
// Handling custom events sent to DiffViewer from external components/features
// such as "jump to file", "jump to comment", etc...
//
useCustomEventListener<DiffViewerCustomEvent>(
diff.filePath,
useCallback(event => {
const { action, commentId } = event.detail
const containerDOM = document.getElementById(diff.containerId) as HTMLDivElement
function scrollToContainer() {
if (!isMounted.current) return
containerDOM.scrollIntoView({ block: 'start' })
if (!commentId) {
// Check to adjust scroll position to make sure content is not
// cut off due to current scroll position
const scrollGap = containerDOM.getBoundingClientRect().y - stickyTopPosition
if (scrollGap < 1) {
scrollElement.scroll({ top: (scrollElement.scrollTop || window.scrollY) + scrollGap })
}
} else {
const commentDOM = containerDOM.querySelector(`[data-comment-id="${commentId}"]`) as HTMLDivElement
// dom is the great grand parent of the comment DOM (CommentBox)
const dom = commentDOM?.parentElement?.parentElement?.parentElement?.parentElement
if (dom) dom.lastElementChild?.scrollIntoView({ block: 'center' })
}
}
switch (action) {
case DiffViewerEvent.SCROLL_INTO_VIEW: {
scrollToContainer()
break
}
}
}, []), // eslint-disable-line react-hooks/exhaustive-deps
() => !!diff.filePath
)
const commentsHook = usePullReqComments({
diff,
viewStyle,
stickyTopPosition,
readOnly,
repoMetadata,
pullReqMetadata,
targetRef,
sourceRef,
commitRange,
scrollElement,
collapsed,
containerRef,
contentRef,
refetchActivities,
setDirty,
memorizedState
})
useEffect(
function alwaysExpandDiffIfChangedSinceLastView() {
if (showChangedSinceLastView && collapsed && !viewed) {
setCollapsed(false)
}
},
[showChangedSinceLastView, viewed] // eslint-disable-line react-hooks/exhaustive-deps
)
const renderDiffAndComments = useCallback(() => {
if (!isMounted.current) return
const fullDiff = memorizedState.get(diff.filePath)?.fullDiff
const _diff = useFullDiff && fullDiff ? fullDiff : diff
// Create a new diff renderer if cached diff is different from current diff
// to ensure when new commit is selected, the diff is re-rendered correctly
if (diff2HtmlRef.current?.diff !== _diff) {
diff2HtmlRef.current = {
renderer: new Diff2HtmlUI(
contentRef.current as HTMLDivElement,
[_diff],
Object.assign({}, DIFF2HTML_CONFIG, { outputFormat: viewStyle })
),
diff: _diff
}
}
diff2HtmlRef.current.renderer.draw()
commentsHook.current.attachAllCommentThreads()
}, [commentsHook, diff, memorizedState, useFullDiff, viewStyle, isMounted])
useEffect(
function renderDiffAndCommentsIfInViewportOrSchedule() {
let taskId = 0
if (!renderCustomContent && !collapsed) {
if (isInViewport(containerRef.current as Element, 1000)) {
renderDiffAndComments()
} else {
taskId = scheduleTask(renderDiffAndComments)
}
}
memorizedState.set(diff.filePath, { ...memorizedState.get(diff.filePath), collapsed })
return () => cancelTask(taskId)
},
[collapsed, diff.filePath, memorizedState, isMounted, renderDiffAndComments, renderCustomContent]
)
const {
data: fullDiffData,
error: fullDiffError,
loading: fullDiffLoading,
refetch: getFullDiff,
cancel: cancelGetFullDiff
} = useGet<GitFileDiff[]>({
path: fullDiffAPIPath,
requestOptions: { headers: { Accept: 'application/json' } },
queryParams: { include_patch: true, path: diff.filePath, range: 1 },
lazy: !useFullDiff || !!memorizedState.get(diff.filePath)?.fullDiff
})
const branchInfo = useFindGitBranch(pullReqMetadata?.source_branch)
useEffect(
function serializeDeserializeContent() {
const dom = contentRef.current
if (inView) {
if (isMounted.current && dom && contentHTML.current) {
dom.innerHTML = contentHTML.current
contentHTML.current = null
// Remove all signs from the raw HTML that CommentBox was mounted so
// it can be mounted/re-rendered again freshly
dom.querySelectorAll('tr[data-source-line-number]').forEach(row => {
row.removeAttribute('data-source-line-number')
row.removeAttribute('data-comment-ids')
row.querySelector('button[data-toggle-comment="true"]')?.remove?.()
})
dom.querySelectorAll('tr[data-annotated-line],tr[data-place-holder-for-line]').forEach(row => {
row.remove?.()
})
// Attach comments again
commentsHook.current.attachAllCommentThreads()
}
} else {
if (isMounted.current && dom && !contentHTML.current) {
const { clientHeight, textContent, innerHTML } = dom
// Detach comments since they are no longer in sync in DOM as
// all DOMs are removed
commentsHook.current.detachAllCommentThreads()
// Save current innerHTML
contentHTML.current = innerHTML
const pre = document.createElement('pre')
pre.style.height = clientHeight + 'px'
pre.textContent = textContent
pre.classList.add(css.offscreenText)
dom.textContent = ''
dom.appendChild(pre)
// TODO: Might be good to clean textContent a bit to not include
// diff header info, line numbers, hunk headers, etc...
}
}
},
[inView, isMounted, commentsHook]
)
// Add click event listener from contentRef to handle click event on "Show Diff" button
// This can't be done from the button itself because it got serialized / deserialized from
// text during off-screen optimization (handler will be gone/destroyed)
useEventListener(
'click',
useCallback(function showDiff(event) {
if (((event.target as HTMLElement)?.closest('button') as HTMLElement)?.dataset?.action === ACTION_SHOW_DIFF) {
setRenderCustomContent(false)
}
}, []),
contentRef.current as HTMLDivElement
)
useShowRequestError(fullDiffError, 0)
useEffect(
function parseAndAssignFullDiff() {
if (fullDiffData) {
try {
memorizedState.set(diff.filePath, {
...memorizedState.get(diff.filePath),
fullDiff: Diff2Html.parse(
window.atob((fullDiffData[0].patch as unknown as string) || ''),
DIFF2HTML_CONFIG
).map(_diff => ({ ...diff, ..._diff }))[0],
useFullDiff: true
})
setUseFullDiff(true)
setRenderCustomContent(false)
if (memorizedState.get(diff.filePath)?.collapsed) {
setCollapsed(false)
memorizedState.set(diff.filePath, {
...memorizedState.get(diff.filePath),
collapsed: false
})
}
} catch (exception) {
showError(getErrorMessage(exception), 0)
}
}
},
[diff, diff.filePath, memorizedState, fullDiffData, showError]
)
useEffect(
function adjustScrollPositionWhenCollapsingFile() {
const containerDOM = containerRef.current as HTMLDivElement
if (
containerDOM &&
!useFullDiff &&
memorizedState.get(diff.filePath)?.useFullDiff === false &&
!isInViewport(containerDOM)
) {
if (stickyTopPosition && containerDOM.getBoundingClientRect().y - stickyTopPosition < 1) {
containerDOM.scrollIntoView()
scrollElement.scroll({ top: (scrollElement.scrollTop || window.scrollY) - stickyTopPosition })
}
}
},
[scrollElement, stickyTopPosition, useFullDiff, diff, memorizedState]
)
const toggleFullDiff = useCallback(() => {
// If full diff is not fetched, fetch it and set useFullDiff when data arrives
// Otherwise, toggle useFullDiff flag
if (!memorizedState.get(diff.filePath)?.fullDiff && !fullDiffLoading) {
cancelGetFullDiff()
getFullDiff()
} else {
memorizedState.set(diff.filePath, { ...memorizedState.get(diff.filePath), useFullDiff: !useFullDiff })
setUseFullDiff(!useFullDiff)
}
}, [useFullDiff, memorizedState, diff.filePath, cancelGetFullDiff, getFullDiff, fullDiffLoading])
const ToggleFullDiffIcon = useMemo(() => (useFullDiff ? Collapse : Expand), [useFullDiff])
return (
<Container
ref={setContainerRef}
id={diff.containerId}
className={cx(css.main, { [css.readOnly]: readOnly })}
data-diff-file-path={diff.filePath}
style={{ '--diff-viewer-sticky-top': `${stickyTopPosition}px` } as React.CSSProperties}>
<Layout.Vertical>
<Container className={css.diffHeader} height={DIFF_VIEWER_HEADER_HEIGHT}>
<Layout.Horizontal>
<Button
variation={ButtonVariation.ICON}
icon={collapsed ? 'main-chevron-right' : 'main-chevron-down'}
size={ButtonSize.SMALL}
onClick={() => setCollapsed(!collapsed)}
iconProps={{
size: 12,
style: {
color: '#383946',
flexGrow: 1,
justifyContent: 'center',
display: 'flex'
}
}}
className={css.chevron}
/>
<Button
variation={ButtonVariation.ICON}
className={css.expandCollapseDiffBtn}
onClick={toggleFullDiff}
title={getString(useFullDiff ? 'pr.collapseFullFile' : 'pr.expandFullFile')}>
<ToggleFullDiffIcon width="16" height="16" strokeWidth="2" />
</Button>
<Text
inline
className={css.fname}
lineClamp={1}
tooltipProps={{
portalClassName: css.popover,
className: css.fnamePopover
}}>
<Link
to={routes.toCODERepository({
repoPath: repoMetadata.path as string,
gitRef: pullReqMetadata?.source_branch
? branchInfo
? pullReqMetadata?.source_branch
: pullReqMetadata?.source_sha
: commitSHA || '',
resourcePath: diff.isRename ? diff.newName : diff.filePath
})}>
{diff.isRename ? `${diff.oldName} -> ${diff.newName}` : diff.filePath}
</Link>
<CopyButton content={diff.filePath} icon={CodeIcon.Copy} size={ButtonSize.SMALL} />
</Text>
<Container style={{ alignSelf: 'center' }} padding={{ left: 'small' }} margin={{ right: 'small' }}>
<Layout.Horizontal spacing="xsmall">
<Render when={diff.addedLines || diff.isNew}>
<Text tag="span" className={css.addedLines}>
+{diff.addedLines || 0}
</Text>
</Render>
<Render when={diff.deletedLines || diff.isDeleted}>
<Text tag="span" className={css.deletedLines}>
-{diff.deletedLines || 0}
</Text>
</Render>
</Layout.Horizontal>
</Container>
<FlexExpander />
<Render when={showChangedSinceLastView}>
<Container>
<Text className={css.fileChanged}>{getString('changedSinceLastView')}</Text>
</Container>
</Render>
<Render when={!readOnly}>
<Container>
<Layout.Horizontal spacing="xsmall" flex>
{fullDiffLoading && (
<Icon name="steps-spinner" color={Color.PRIMARY_7} margin={{ right: 'xsmall' }} />
)}
<Render when={commitRange?.length === 0}>
<label className={css.viewLabel}>
<Checkbox
checked={viewed}
onChange={async () => {
if (viewed) {
setViewed(false)
setCollapsed(false)
// update local data first
diff.fileViews?.delete(diff.filePath)
// best effort attempt to recflect on server (swallow exception - user still sees correct data locally)
await unmarkViewed(null, { pathParams: { filePath: diff.filePath } }).catch(noop)
} else {
setViewed(true)
setCollapsed(true)
// update local data first
// we could wait for server response for the guaranteed correct SHA, but this is non-crucial data so it's okay
diff.fileViews?.set(diff.filePath, diff.checksumAfter || 'unknown')
// best effort attempt to recflect on server (swallow exception - user still sees correct data locally)
await markViewed(
{
path: diff.filePath,
commit_sha: pullReqMetadata?.source_sha
},
{}
).catch(noop)
}
}}
/>
{getString('viewed')}
</label>
</Render>
</Layout.Horizontal>
</Container>
</Render>
</Layout.Horizontal>
</Container>
<Container id={diff.contentId} data-path={diff.filePath} className={css.diffContent} ref={contentRef}>
{/* Note: This parent container is needed to make sure "Show Diff" work correctly
with content converted between textContent and innerHTML */}
<Container>
<Render when={renderCustomContent && !collapsed}>
<Container height={200} flex={{ align: 'center-center' }}>
<Layout.Vertical padding="xlarge" style={{ alignItems: 'center' }}>
<Render when={fileDeleted || isDiffTooLarge || diffHasVeryLongLine}>
<Button variation={ButtonVariation.LINK} onClick={() => setRenderCustomContent(false)}>
{getString('pr.showDiff')}
</Button>
</Render>
<Text>
{getString(
fileDeleted
? 'pr.fileDeleted'
: isDiffTooLarge || diffHasVeryLongLine
? 'pr.diffTooLarge'
: isBinary
? 'pr.fileBinary'
: 'pr.fileUnchanged'
)}
</Text>
</Layout.Vertical>
</Container>
</Render>
</Container>
</Container>
</Layout.Vertical>
<NavigationCheck when={dirty} />
</Container>
)
}
const BLOCK_HEIGHT = '--block-height'
const ACTION_SHOW_DIFF = 'showDiff'
export enum DiffViewerEvent {
SCROLL_INTO_VIEW = 'scrollIntoView'
}
export interface DiffViewerCustomEvent {
action: DiffViewerEvent
commentId?: string
}
export interface DiffViewerExchangeState {
collapsed?: boolean
useFullDiff?: boolean
fullDiff?: DiffFileEntry
comments?: Map<number, CommentRestorationTrackingState>
commentsVisibilityAtLineNumber?: Map<number, boolean>
}
export interface CommentRestorationTrackingState extends DiffCommentItem<TypesPullReqActivity> {
uncommittedText?: string
showReplyPlaceHolder?: boolean
uncommittedEditComments?: Map<number, string>
}
const { scheduleTask, cancelTask } = createRequestAnimationFrameTaskPool()
export const DiffViewer = React.memo(DiffViewerInternal)