drone/web/src/components/DiffViewer/DiffViewer.tsx

795 lines
29 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 { useMutate } from 'restful-react'
import ReactDOM from 'react-dom'
import { useInView } from 'react-intersection-observer'
import {
Button,
Container,
FlexExpander,
ButtonVariation,
Layout,
Text,
ButtonSize,
useToaster,
ButtonProps,
Checkbox,
useIsMounted
} from '@harnessio/uicore'
import cx from 'classnames'
import { Render } from 'react-jsx-match'
import { Link } from 'react-router-dom'
import { Diff2HtmlUI } from 'diff2html/lib-esm/ui/js/diff2html-ui'
import { max, random } from 'lodash-es'
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 { useAppContext } from 'AppContext'
import type { OpenapiCommentCreatePullReqRequest, TypesPullReq, TypesPullReqActivity } from 'services/code'
import { getErrorMessage, waitUntil } from 'utils/Utils'
import { CopyButton } from 'components/CopyButton/CopyButton'
import { AppWrapper } from 'App'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import { CodeCommentStatusButton } from 'components/CodeCommentStatusButton/CodeCommentStatusButton'
import { CodeCommentSecondarySaveButton } from 'components/CodeCommentSecondarySaveButton/CodeCommentSecondarySaveButton'
import { CodeCommentStatusSelect } from 'components/CodeCommentStatusSelect/CodeCommentStatusSelect'
import { useQueryParams } from 'hooks/useQueryParams'
import {
activitiesToDiffCommentItems,
activityToCommentItem,
CommentType,
DIFF2HTML_CONFIG,
DiffCommentItem,
DIFF_VIEWER_HEADER_HEIGHT,
getCommentLineInfo,
createCommentOppositePlaceHolder,
ViewStyle,
contentDOMHasData
} from './DiffViewerUtils'
import {
CommentAction,
CommentBox,
CommentBoxOutletPosition,
CommentItem,
SingleConsumerEventStream
} from '../CommentBox/CommentBox'
import css from './DiffViewer.module.scss'
interface DiffViewerProps extends Pick<GitInfoProps, 'repoMetadata'> {
diff: DiffFileEntry
viewStyle: ViewStyle
stickyTopPosition?: number
readOnly?: boolean
pullRequestMetadata?: TypesPullReq
onCommentUpdate: () => void
targetRef?: string
sourceRef?: string
commitRange?: string[]
scrollElement: HTMLElement
}
//
// 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<DiffViewerProps> = ({
diff,
viewStyle,
stickyTopPosition = 0,
readOnly,
repoMetadata,
pullRequestMetadata,
onCommentUpdate,
targetRef,
sourceRef,
commitRange,
scrollElement
}) => {
const { routes } = useAppContext()
const { getString } = useStrings()
const viewedPath = useMemo(
() => `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata?.number}/file-views`,
[repoMetadata.path, pullRequestMetadata?.number]
)
const { mutate: markViewed } = useMutate({ verb: 'PUT', path: viewedPath })
const { mutate: unmarkViewed } = useMutate({ verb: 'DELETE', path: ({ filePath }) => `${viewedPath}/${filePath}` })
// file viewed feature is only enabled if no commit range is provided (otherwise component is hidden, too)
const [viewed, setViewed] = useState(
commitRange?.length === 0 && diff.fileViews?.get(diff.filePath) === diff.checksumAfter
)
useEffect(() => {
if (commitRange?.length === 0) {
setViewed(diff.fileViews?.get(diff.filePath) === diff.checksumAfter)
}
}, [diff.fileViews, diff.filePath, diff.checksumAfter, commitRange])
const [collapsed, setCollapsed] = useState(viewed)
useEffect(() => {
setCollapsed(viewed)
}, [viewed])
const [fileUnchanged] = useState(diff.unchangedPercentage === 100)
const [fileDeleted] = useState(diff.isDeleted)
const [renderCustomContent, setRenderCustomContent] = useState(fileUnchanged || fileDeleted)
const [diffRenderer, setDiffRenderer] = useState<Diff2HtmlUI>()
const { ref: inViewRef, inView } = useInView({ rootMargin: '100px 0px' })
const containerRef = useRef<HTMLDivElement | null>(null)
const { currentUser, standalone } = useAppContext()
const { showError } = useToaster()
const confirmAct = useConfirmAct()
const commentPath = useMemo(
() => `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata?.number}/comments`,
[repoMetadata.path, pullRequestMetadata?.number]
)
const { mutate: saveComment } = useMutate({ verb: 'POST', path: commentPath })
const { mutate: updateComment } = useMutate({ verb: 'PATCH', path: ({ id }) => `${commentPath}/${id}` })
const { mutate: deleteComment } = useMutate({ verb: 'DELETE', path: ({ id }) => `${commentPath}/${id}` })
const [comments] = useState(new Map<number, DiffCommentItem<TypesPullReqActivity>>())
const [dirty, setDirty] = useState(false)
const setContainerRef = useCallback(
node => {
containerRef.current = node
inViewRef(node)
},
[inViewRef]
)
const contentRef = useRef<HTMLDivElement>(null)
const setupViewerInitialStates = useCallback(() => {
setDiffRenderer(
new Diff2HtmlUI(
document.getElementById(diff.contentId) as HTMLElement,
[diff],
Object.assign({}, DIFF2HTML_CONFIG, { outputFormat: viewStyle })
)
)
}, [diff, viewStyle])
// renderCodeComment renders a single comment (both newly created ones and existing ones frm the db!)
const renderCodeComment = useCallback(
(id: number) => {
const comment = comments.get(id)
if (!comment || comment.destroy) {
return
}
// early exit if there's nothing to render on
if (!contentRef.current || !contentDOMHasData(contentRef.current)) {
return
}
const isSideBySide = viewStyle === ViewStyle.SIDE_BY_SIDE
const lineInfo = getCommentLineInfo(contentRef.current, comment, viewStyle)
// TODO: add support for live updating changes and replies to comment!
if (!lineInfo.rowElement || lineInfo.hasCommentsRendered) {
return
}
const { rowElement } = lineInfo
// Annotate row to indicated the row is taken (we only support one per line as of now)
rowElement.dataset.annotated = 'true'
// always create placeholder (in memory)
const oppositeRowPlaceHolder = createCommentOppositePlaceHolder(comment.lineNumber)
// in split view, actually attach the placeholder
if (isSideBySide && lineInfo.oppositeRowElement != null) {
lineInfo.oppositeRowElement.after(oppositeRowPlaceHolder)
}
// Create a new row below it and render CommentBox inside
const commentRowElement = document.createElement('tr')
commentRowElement.dataset.annotatedLine = String(comment.lineNumber)
commentRowElement.innerHTML = `<td colspan="2"></td>`
rowElement.after(commentRowElement)
const element = commentRowElement.firstElementChild as HTMLTableCellElement
comment.destroy = () => {
// Clean up CommentBox rendering and reset states bound to lineInfo
ReactDOM.unmountComponentAtNode(element as HTMLDivElement)
commentRowElement.parentElement?.removeChild(commentRowElement)
lineInfo.oppositeRowElement?.parentElement?.removeChild(oppositeRowPlaceHolder as Element)
delete lineInfo.rowElement.dataset.annotated
comment.destroy = undefined
comments.delete(id)
}
comment.eventStream = new SingleConsumerEventStream<CommentItem<TypesPullReqActivity>[]>()
// 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(
<AppWrapper>
<CommentBox
commentItems={comment.commentItems}
eventStream={comment.eventStream}
initialContent={''}
width={isSideBySide ? 'calc(50vw - 200px)' : undefined}
onHeightChange={boxHeight => {
const first = oppositeRowPlaceHolder?.firstElementChild as HTMLTableCellElement
const last = oppositeRowPlaceHolder?.lastElementChild as HTMLTableCellElement
if (first && last) {
first.style.height = `${boxHeight}px`
last.style.height = `${boxHeight}px`
}
}}
autoFocusAndPosition={true}
enableReplyPlaceHolder={comment.commentItems?.length > 0}
onCancel={comment.destroy}
setDirty={setDirty}
currentUserName={currentUser.display_name}
handleAction={async (action, value, commentItem) => {
let result = true
let updatedItem: CommentItem<TypesPullReqActivity> | undefined = undefined
const existingDBID = (commentItem as CommentItem<TypesPullReqActivity>)?.id
switch (action) {
case CommentAction.NEW: {
const payload: OpenapiCommentCreatePullReqRequest = {
line_start: comment.lineNumber,
line_end: comment.lineNumber,
line_start_new: !comment.left,
line_end_new: !comment.left,
path: diff.filePath,
source_commit_sha: sourceRef,
target_commit_sha: targetRef,
text: value
}
await saveComment(payload)
.then((createdActivity: TypesPullReqActivity) => {
if (!createdActivity.id) {
comment.destroy?.()
return
}
updatedItem = activityToCommentItem(createdActivity)
// recreate comment for now to tie any loose ends
comment.destroy?.()
comments.delete(id)
comments.set(createdActivity.id, comment)
// persist comment in activities
diff.activities?.push(createdActivity)
})
.catch(exception => {
result = false
showError(getErrorMessage(exception), 0)
})
break
}
case CommentAction.REPLY: {
await saveComment({
type: CommentType.CODE_COMMENT,
text: value,
parent_id: Number(existingDBID)
})
.then(createdActivity => {
if (!createdActivity.id) {
comment.destroy?.()
return
}
updatedItem = activityToCommentItem(createdActivity)
// persist comment in activities - will update parent comment with latest replies
diff.activities?.push(createdActivity)
})
.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: existingDBID } })
.then(() => {
result = true
})
.catch(exception => {
result = false
showError(getErrorMessage(exception), 0, getString('pr.failedToDeleteComment'))
})
}
})
break
}
case CommentAction.UPDATE: {
await updateComment({ text: value }, { pathParams: { id: existingDBID } })
.then(newComment => {
updatedItem = activityToCommentItem(newComment)
})
.catch(exception => {
result = false
showError(getErrorMessage(exception), 0)
})
break
}
}
if (result) {
onCommentUpdate()
}
return [result, updatedItem]
}}
outlets={{
[CommentBoxOutletPosition.LEFT_OF_OPTIONS_MENU]: (
<CodeCommentStatusSelect
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata as TypesPullReq}
onCommentUpdate={onCommentUpdate}
commentItems={comment.commentItems}
/>
),
[CommentBoxOutletPosition.LEFT_OF_REPLY_PLACEHOLDER]: (
<CodeCommentStatusButton
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata as TypesPullReq}
onCommentUpdate={onCommentUpdate}
commentItems={comment.commentItems}
/>
),
[CommentBoxOutletPosition.BETWEEN_SAVE_AND_CANCEL_BUTTONS]: (props: ButtonProps) => (
<CodeCommentSecondarySaveButton
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata as TypesPullReq}
commentItems={comment.commentItems}
{...props}
/>
)
}}
/>
</AppWrapper>,
element
)
},
[
// eslint-disable-line react-hooks/exhaustive-deps
comments,
currentUser.display_name,
deleteComment,
diff.activities,
diff.filePath,
pullRequestMetadata,
repoMetadata,
saveComment,
showError,
sourceRef,
targetRef,
updateComment,
viewStyle
// The following three cause the whole thing to re-render and run unnecessary all the time.
// disable for now as no issues were found in testing.
// getString,
// onCommentUpdate,
// confirmAct,
]
)
// reRenderCodeComments is required to trigger comment rendering once all data is the renderer completed drawing..
const reRenderCodeComments = useCallback(() => {
if (readOnly) {
return
}
// early exit if there's nothing to render on
if (!contentRef.current || !contentDOMHasData(contentRef.current)) {
return
}
comments.forEach(item => renderCodeComment(item.inner.id || 0))
}, [readOnly, comments, renderCodeComment])
useEffect(function cleanUpCommentBoxRendering() {
const contentDOM = contentRef.current
return () => {
contentDOM
?.querySelectorAll('[data-annotated-line]')
.forEach(element => ReactDOM.unmountComponentAtNode(element.firstElementChild as HTMLTableCellElement))
}
}, [])
useEffect(
function handleActivityChanges() {
// no activities or commit range view? no comments!
if (!diff?.activities || (commitRange?.length || 0) > 0) {
return
}
const latestComments = activitiesToDiffCommentItems(diff.filePath, diff.activities)
latestComments.forEach(latestComment => {
const id = latestComment.inner.id
if (!id) {
return
}
const existingComment = comments.get(id)
const latestDeleted = latestComment.commentItems.map(x => !!x.deleted).reduce((a, b) => a && b, true)
// is this a to us new, but already delete comment? perfect, nothing to do
if (!existingComment && latestDeleted) {
return
}
// is this a new comment or we failed to render it before? add to our internal cache and render!
if (!existingComment || !existingComment.destroy) {
comments.set(id, latestComment)
renderCodeComment(id)
return
}
// heuristic: whoever has the latest update timestamp has the latest data
const mostRecentExisting = existingComment.commentItems
.map(x => max([x.updated as number, x.edited as number, x.deleted as number]) || 0)
.reduce(
(x, y) => max([x, y]) || 0,
max([
existingComment.inner.updated,
existingComment.inner.edited,
existingComment.inner.deleted as number,
existingComment.inner.resolved
]) || 0
)
const mostRecentLatest = latestComment.commentItems
.map(x => max([x.updated as number, x.edited as number, x.deleted as number]) || 0)
.reduce(
(x, y) => max([x, y]) || 0,
max([
latestComment.inner.updated,
latestComment.inner.edited,
latestComment.inner.deleted as number,
latestComment.inner.resolved
]) || 0
)
if (mostRecentExisting >= mostRecentLatest) {
return
}
// comment changed -- update everything that can change
existingComment.inner = latestComment.inner
existingComment.commentItems = [...latestComment.commentItems]
// push event to subscriber
existingComment.eventStream?.publish(existingComment.commentItems)
// NOTE: no need to render comment as we update in place :)
})
},
[diff.filePath, diff?.activities, diff?.activities?.length, commitRange, comments, renderCodeComment]
)
useEffect(
function createDiffRenderer() {
if (inView && !diffRenderer) {
setupViewerInitialStates()
}
},
[inView, diffRenderer, setupViewerInitialStates]
)
useEffect(
function handleCollapsedState() {
const containerDOM = containerRef.current as HTMLDivElement & { scrollIntoViewIfNeeded: () => void }
const { classList: containerClassList, style: containerStyle } = containerDOM
if (collapsed) {
containerClassList.add(css.collapsed)
if (parseInt(containerStyle.height) != DIFF_VIEWER_HEADER_HEIGHT) {
containerStyle.height = `${DIFF_VIEWER_HEADER_HEIGHT}px`
}
// 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) {
scrollElement.scroll({ top: scrollElement.scrollTop - stickyTopPosition })
}
}
} else {
containerClassList.remove(css.collapsed)
const newHeight = Number(containerDOM.scrollHeight)
if (parseInt(containerStyle.height) != newHeight) {
containerStyle.height = `${newHeight}px`
}
}
},
[collapsed, stickyTopPosition, scrollElement]
)
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
// use random negative numbers as temporary IDs - never collides with persisted db entries :P
const randID = -random(1000000000, false)
const commentItem: DiffCommentItem<TypesPullReqActivity> = {
inner: { id: randID } as TypesPullReqActivity,
left: false,
right: false,
lineNumber: 0,
commentItems: [],
filePath: '',
destroy: undefined,
eventStream: undefined
}
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)
}
comments.set(randID, commentItem)
renderCodeComment(randID)
}
},
[viewStyle, readOnly, comments, renderCodeComment]
),
containerRef.current as HTMLDivElement
)
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()
reRenderCodeComments()
}
contentDOM.dataset.rendered = 'true'
}
},
[diffRenderer, renderCustomContent, reRenderCodeComments]
)
useEffect(
function renderInitialContent() {
if (diffRenderer && inView) {
renderDiffAndUpdateContainerHeightIfNeeded()
}
},
[inView, diffRenderer, renderDiffAndUpdateContainerHeightIfNeeded]
)
const isMounted = useIsMounted()
const { path, commentId } = useQueryParams<{ path: string; commentId: string }>()
useEffect(
function scrollToComment() {
if (path && commentId && path === diff.filePath) {
containerRef.current?.scrollIntoView({ block: 'start' })
waitUntil(
() => !!containerRef.current?.querySelector(`[data-comment-id="${commentId}"]`),
() => {
const dom = containerRef.current?.querySelector(`[data-comment-id="${commentId}"]`)?.parentElement
?.parentElement?.parentElement?.parentElement
if (dom) {
window.requestAnimationFrame(() => {
setTimeout(() => {
if (isMounted.current) {
dom?.scrollIntoView({ block: 'center' })
}
}, 500)
})
}
}
)
}
},
[path, commentId]
)
const [isSidebarExpanded, setIsSidebarExpanded] = useState(true)
const sideBarExpandedHandler = useCallback((event: Event) => {
setIsSidebarExpanded(_ => !!(event as CustomEvent).detail)
}, [])
useEffect(() => {
window.addEventListener(SIDE_NAV_EXPANDED_EVENT, sideBarExpandedHandler)
return () => window.removeEventListener(SIDE_NAV_EXPANDED_EVENT, sideBarExpandedHandler)
}, [sideBarExpandedHandler])
return (
<Container
ref={setContainerRef}
id={diff.containerId}
className={cx(css.main, { [css.readOnly]: readOnly })}
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: 10,
style: {
color: '#383946',
flexGrow: 1,
justifyContent: 'center',
display: 'flex'
}
}}
className={css.chevron}
/>
<Text inline className={css.fname}>
<Link
to={routes.toCODERepository({
repoPath: repoMetadata.path as string,
gitRef: pullRequestMetadata?.source_branch,
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' }}>
<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={
!readOnly &&
commitRange?.length === 0 &&
diff.fileViews?.get(diff.filePath) !== undefined &&
diff.fileViews?.get(diff.filePath) !== diff.checksumAfter
}>
<Container>
<Text className={css.fileChanged}>{getString('changedSinceLastView')}</Text>
</Container>
</Render>
<Render when={!readOnly && commitRange?.length === 0}>
<Container>
<label className={css.viewLabel}>
<Checkbox
checked={viewed}
onChange={async () => {
if (viewed) {
setViewed(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(() => undefined)
} else {
setViewed(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: pullRequestMetadata?.source_sha
},
{}
).catch(() => undefined)
}
}}
/>
{getString('viewed')}
</label>
</Container>
</Render>
</Layout.Horizontal>
</Container>
<Container
id={diff.contentId}
data-path={diff.filePath}
className={cx(css.diffContent, {
[css.standalone]: standalone,
[css.navV2]: !!document.querySelector('[data-code-nav-version="2"]'),
[css.sidebarCollapsed]: !isSidebarExpanded
})}
ref={contentRef}>
<Render when={renderCustomContent}>
<Container>
<Layout.Vertical padding="xlarge" style={{ alignItems: 'center' }}>
<Render when={fileDeleted}>
<Button
variation={ButtonVariation.LINK}
onClick={() => {
setRenderCustomContent(false)
setTimeout(() => renderDiffAndUpdateContainerHeightIfNeeded(true), 0)
}}>
{getString('pr.showDiff')}
</Button>
</Render>
<Text>{getString(fileDeleted ? 'pr.fileDeleted' : 'pr.fileUnchanged')}</Text>
</Layout.Vertical>
</Container>
</Render>
</Container>
</Layout.Vertical>
<NavigationCheck when={dirty} />
</Container>
)
}
const SIDE_NAV_EXPANDED_EVENT = 'SIDE_NAV_EXPANDED_EVENT'