Add code to restore new and edited comments

This commit is contained in:
“tan-nhu” 2024-06-26 22:55:58 -07:00
parent e6dc4c4fc9
commit 58073b0146
3 changed files with 224 additions and 36 deletions

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { EditorView } from '@codemirror/view'
import { Render, Match, Truthy, Falsy, Else } from 'react-jsx-match'
import { Container, Layout, Avatar, TextInput, Text, FlexExpander, Button, useIsMounted } from '@harnessio/uicore'
@ -34,6 +34,7 @@ import { ButtonRoleProps, CodeCommentState } from 'utils/Utils'
import { useResizeObserver } from 'hooks/useResizeObserver'
import { useCustomEventListener } from 'hooks/useEventListener'
import type { SuggestionBlock } from 'components/SuggestionBlock/SuggestionBlock'
import type { CommentRestorationTrackingState, DiffViewerExchangeState } from 'components/DiffViewer/DiffViewer'
import commentActiveIconUrl from './comment.svg?url'
import commentResolvedIconUrl from './comment-resolved.svg?url'
import css from './CommentBox.module.scss'
@ -83,6 +84,7 @@ interface CommentBoxProps<T> {
resetOnSave?: boolean
hideCancel?: boolean
currentUserName: string
commentThreadId?: number
commentItems: CommentItem<T>[]
handleAction: (
action: CommentAction,
@ -99,6 +101,8 @@ interface CommentBoxProps<T> {
routingId: string
copyLinkToComment: (commentId: number, commentItem: CommentItem<T>) => void
suggestionBlock?: SuggestionBlock
memorizedState?: CommentRestorationTrackingState
commentsVisibilityAtLineNumber?: DiffViewerExchangeState['commentsVisibilityAtLineNumber']
}
const CommentBoxInternal = <T = unknown,>({
@ -109,6 +113,7 @@ const CommentBoxInternal = <T = unknown,>({
initialContent = '',
width,
fluid,
commentThreadId,
commentItems = [],
currentUserName,
handleAction,
@ -123,7 +128,9 @@ const CommentBoxInternal = <T = unknown,>({
standalone,
routingId,
copyLinkToComment,
suggestionBlock
suggestionBlock,
memorizedState,
commentsVisibilityAtLineNumber
}: CommentBoxProps<T>) => {
const { getString } = useStrings()
const [comments, setComments] = useState<CommentItem<T>[]>(commentItems)
@ -134,6 +141,13 @@ const CommentBoxInternal = <T = unknown,>({
const containerRef = useRef<HTMLDivElement>(null)
const isMounted = useIsMounted()
const clearMemorizedState = useCallback(() => {
if (memorizedState) {
delete memorizedState.showReplyPlaceHolder
delete memorizedState.uncommittedText
}
}, [memorizedState])
useResizeObserver(
containerRef,
useCallback(
@ -156,10 +170,13 @@ const CommentBoxInternal = <T = unknown,>({
const _onCancel = useCallback(() => {
setMarkdown('')
setShowReplyPlaceHolder(true)
clearMemorizedState()
if (onCancel && !comments.length) {
onCancel()
}
}, [setShowReplyPlaceHolder, onCancel, comments.length])
}, [setShowReplyPlaceHolder, onCancel, comments.length, clearMemorizedState])
const hidePlaceHolder = useCallback(() => setShowReplyPlaceHolder(false), [setShowReplyPlaceHolder])
const onQuote = useCallback((content: string) => {
const replyContent = content
@ -179,13 +196,73 @@ const CommentBoxInternal = <T = unknown,>({
})
}, [dirties, setDirty])
useEffect(
// This function restores CommentBox internal states from memorizedState
// after it got destroyed during HTML/textContent serialization/deserialization
// This approach is not optimized, we probably have to think about a shared
// store per diff or something else to make the flow nicer
function serializeNewCommentInfo() {
if (!commentThreadId || !memorizedState) return
if (commentThreadId < 0) {
if (!comments?.[0]?.id) {
if (!markdown && memorizedState.uncommittedText) {
setMarkdown(memorizedState.uncommittedText)
viewRef.current?.dispatch({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: memorizedState.uncommittedText
}
})
viewRef.current?.contentDOM?.blur()
} else {
memorizedState.uncommittedText = markdown
memorizedState.showReplyPlaceHolder = showReplyPlaceHolder
}
} else {
clearMemorizedState()
}
} else if (commentThreadId > 0) {
if (!showReplyPlaceHolder) {
if (markdown) {
memorizedState.uncommittedText = markdown
memorizedState.showReplyPlaceHolder = false
}
} else {
if (!markdown && memorizedState.showReplyPlaceHolder === false) {
setShowReplyPlaceHolder(false)
const { uncommittedText = '' } = memorizedState
setTimeout(() => {
setMarkdown(uncommittedText)
viewRef.current?.dispatch({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: uncommittedText
}
})
viewRef.current?.contentDOM?.blur()
}, 0)
}
delete memorizedState.showReplyPlaceHolder
delete memorizedState.uncommittedText
}
}
},
[markdown, commentThreadId, comments, memorizedState, clearMemorizedState, showReplyPlaceHolder]
)
return (
<Container
className={cx(css.main, { [css.fluid]: fluid }, outerClassName)}
padding={!fluid ? 'medium' : undefined}
width={width}
ref={containerRef}
data-comment-thread-id={comments?.[0]?.id || ''}>
data-comment-thread-id={comments?.[0]?.id || commentThreadId || ''}>
{outlets[CommentBoxOutletPosition.TOP]}
<Container className={cx(boxClassName, css.box)}>
<Layout.Vertical>
@ -210,6 +287,8 @@ const CommentBoxInternal = <T = unknown,>({
outlets={outlets}
copyLinkToComment={copyLinkToComment}
suggestionBlock={suggestionBlock}
memorizedState={memorizedState}
commentsVisibilityAtLineNumber={commentsVisibilityAtLineNumber}
/>
<Match expr={showReplyPlaceHolder && enableReplyPlaceHolderRef.current}>
<Truthy>
@ -250,6 +329,8 @@ const CommentBoxInternal = <T = unknown,>({
value={markdown}
onChange={setMarkdown}
onSave={async (value: string) => {
clearMemorizedState()
if (handleAction) {
const [result, updatedItem] = await handleAction(
comments.length ? CommentAction.REPLY : CommentAction.NEW,
@ -306,7 +387,13 @@ const CommentBoxInternal = <T = unknown,>({
interface CommentsThreadProps<T>
extends Pick<
CommentBoxProps<T>,
'commentItems' | 'handleAction' | 'outlets' | 'copyLinkToComment' | 'suggestionBlock'
| 'commentItems'
| 'handleAction'
| 'outlets'
| 'copyLinkToComment'
| 'suggestionBlock'
| 'memorizedState'
| 'commentsVisibilityAtLineNumber'
> {
onQuote: (content: string) => void
setDirty: (index: number, dirty: boolean) => void
@ -321,21 +408,26 @@ const CommentsThread = <T = unknown,>({
outlets = {},
repoMetadata,
copyLinkToComment,
suggestionBlock
suggestionBlock,
memorizedState,
commentsVisibilityAtLineNumber
}: CommentsThreadProps<T>) => {
const { getString } = useStrings()
const { standalone, routingId } = useAppContext()
const [editIndexes, setEditIndexes] = useState<Record<number, boolean>>({})
const resetStateAtIndex = useCallback(
(index: number) => {
(index: number, commentItem: CommentItem<T>) => {
delete editIndexes[index]
setEditIndexes({ ...editIndexes })
if (memorizedState?.uncommittedEditComments && commentItem?.id) {
memorizedState.uncommittedEditComments.delete(commentItem.id)
}
},
[editIndexes]
[editIndexes, memorizedState]
)
const isCommentThreadResolved = useMemo(() => !!get(commentItems[0], 'payload.resolved'), [commentItems])
const domRef = useRef<HTMLElement>()
const show = useRef(isCommentThreadResolved ? false : true)
const internalFlags = useRef({ initialized: false })
useEffect(
@ -353,10 +445,12 @@ const CommentsThread = <T = unknown,>({
const lineNumColDOM = annotatedRow.firstElementChild as HTMLElement
const sourceLineNumber = annotatedRow.dataset.sourceLineNumber
const button: HTMLButtonElement = lineNumColDOM?.querySelector('button') || document.createElement('button')
const showFromMemory = commentsVisibilityAtLineNumber?.get(Number(sourceLineNumber))
let show = showFromMemory !== undefined ? showFromMemory : isCommentThreadResolved ? false : true
if (!button.onclick) {
const toggleHidden = (dom: Element) => {
if (show.current) dom.setAttribute('hidden', '')
if (show) dom.setAttribute('hidden', '')
else dom.removeAttribute('hidden')
}
const toggleComments = (e: KeyboardEvent | MouseEvent) => {
@ -377,9 +471,13 @@ const CommentsThread = <T = unknown,>({
toggleHidden(commentRow)
commentRow = commentRow.nextElementSibling as HTMLElement
}
show.current = !show.current
show = !show
if (!show.current) button.dataset.threadsCount = String(activeThreads + resolvedThreads)
if (memorizedState) {
commentsVisibilityAtLineNumber?.set(Number(sourceLineNumber), show)
}
if (!show) button.dataset.threadsCount = String(activeThreads + resolvedThreads)
else delete button.dataset.threadsCount
e.stopPropagation()
@ -404,7 +502,9 @@ const CommentsThread = <T = unknown,>({
while (commentRow?.dataset?.annotatedLine) {
if (commentRow.dataset.commentThreadStatus == CodeCommentState.RESOLVED) {
resolvedThreads++
if (!internalFlags.current.initialized) show.current = false
if (!internalFlags.current.initialized && !showFromMemory) {
show = false
}
} else activeThreads++
commentRow = commentRow.nextElementSibling as HTMLElement
@ -415,19 +515,44 @@ const CommentsThread = <T = unknown,>({
if (!internalFlags.current.initialized) {
internalFlags.current.initialized = true
if (!show.current && resolvedThreads) button.dataset.threadsCount = String(resolvedThreads)
if (!show && resolvedThreads) button.dataset.threadsCount = String(resolvedThreads)
else delete button.dataset.threadsCount
}
}
},
[isCommentThreadResolved, getString]
[isCommentThreadResolved, getString, commentsVisibilityAtLineNumber, memorizedState]
)
const viewRefs = useRef(
Object.fromEntries(
commentItems.map(commentItem => [commentItem.id, createRef() as React.MutableRefObject<EditorView | undefined>])
)
)
const contentRestoredRefs = useRef<Record<number, boolean>>({})
return (
<Render when={commentItems.length}>
<Container className={css.viewer} padding="xlarge" ref={domRef}>
{commentItems.map((commentItem, index) => {
const isLastItem = index === commentItems.length - 1
const contentFromMemorizedState = memorizedState?.uncommittedEditComments?.get(commentItem.id)
const viewRef = viewRefs.current[commentItem.id]
if (viewRef && contentFromMemorizedState !== undefined && !contentRestoredRefs.current[commentItem.id]) {
editIndexes[index] = true
contentRestoredRefs.current[commentItem.id] = true
setTimeout(() => {
if (contentFromMemorizedState !== commentItem.content) {
viewRef.current?.dispatch({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: contentFromMemorizedState
}
})
}
}, 0)
}
return (
<ThreadSection
@ -487,7 +612,14 @@ const CommentsThread = <T = unknown,>({
className: cx(css.optionMenuIcon, css.edit),
iconName: 'Edit',
text: getString('edit'),
onClick: () => setEditIndexes({ ...editIndexes, ...{ [index]: true } })
onClick: () => {
setEditIndexes({ ...editIndexes, ...{ [index]: true } })
if (memorizedState) {
memorizedState.uncommittedEditComments =
memorizedState.uncommittedEditComments || new Map()
memorizedState.uncommittedEditComments.set(commentItem.id, commentItem.content)
}
}
},
{
hasIcon: true,
@ -512,7 +644,7 @@ const CommentsThread = <T = unknown,>({
text: getString('delete'),
onClick: async () => {
if (await handleAction(CommentAction.DELETE, '', commentItem)) {
resetStateAtIndex(index)
resetStateAtIndex(index, commentItem)
}
}
}
@ -538,13 +670,20 @@ const CommentsThread = <T = unknown,>({
standalone={standalone}
repoMetadata={repoMetadata}
value={commentItem?.content}
viewRef={viewRefs.current[commentItem.id]}
onSave={async value => {
if (await handleAction(CommentAction.UPDATE, value, commentItem)) {
commentItem.content = value
resetStateAtIndex(index)
resetStateAtIndex(index, commentItem)
}
}}
onCancel={() => resetStateAtIndex(index)}
onChange={value => {
if (memorizedState) {
memorizedState.uncommittedEditComments = memorizedState.uncommittedEditComments || new Map()
memorizedState.uncommittedEditComments.set(commentItem.id, value)
}
}}
onCancel={() => resetStateAtIndex(index, commentItem)}
setDirty={_dirty => {
setDirty(index, _dirty)
}}
@ -555,7 +694,7 @@ const CommentsThread = <T = unknown,>({
save: getString('save'),
cancel: getString('cancel')
}}
autoFocusAndPosition
autoFocusAndPosition={contentFromMemorizedState ? false : true}
suggestionBlock={suggestionBlock}
/>
</Container>

View File

@ -41,7 +41,7 @@ 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 } from 'services/code'
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'
@ -58,7 +58,8 @@ import {
DIFF_VIEWER_HEADER_HEIGHT,
ViewStyle,
getFileViewedState,
FileViewedState
FileViewedState,
DiffCommentItem
} from './DiffViewerUtils'
import { usePullReqComments } from './usePullReqComments'
import Collapse from '../../icons/collapse.svg'
@ -234,7 +235,8 @@ const DiffViewerInternal: React.FC<DiffViewerProps> = ({
containerRef,
contentRef,
refetchActivities,
setDirty
setDirty,
memorizedState
})
useEffect(
@ -337,14 +339,16 @@ const DiffViewerInternal: React.FC<DiffViewerProps> = ({
// 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...
// Set innerHTML to a pre tag with the same height to avoid reflow
// The pre textContent allows Cmd/Ctrl-F to work
dom.innerHTML = `<pre style="height: ${clientHeight + 'px'}" class="${
css.offscreenText
}">${textContent}</pre>`
}
}
},
@ -384,7 +388,10 @@ const DiffViewerInternal: React.FC<DiffViewerProps> = ({
if (memorizedState.get(diff.filePath)?.collapsed) {
setCollapsed(false)
memorizedState.set(diff.filePath, { ...memorizedState.get(diff.filePath), collapsed: false })
memorizedState.set(diff.filePath, {
...memorizedState.get(diff.filePath),
collapsed: false
})
}
} catch (exception) {
showError(getErrorMessage(exception), 0)
@ -602,6 +609,14 @@ 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()

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { useMutate } from 'restful-react'
import Selecto from 'selecto'
import ReactDOM from 'react-dom'
@ -35,6 +35,7 @@ import { CodeCommentStatusSelect } from 'components/CodeCommentStatusSelect/Code
import { dispatchCustomEvent } from 'hooks/useEventListener'
import { UseGetPullRequestInfoResult, usePullReqActivities } from 'pages/PullRequest/useGetPullRequestInfo'
import { CommentThreadTopDecoration } from 'components/CommentThreadTopDecoration/CommentThreadTopDecoration'
import type { DiffViewerExchangeState } from './DiffViewer'
import {
activitiesToDiffCommentItems,
activityToCommentItem,
@ -68,6 +69,7 @@ interface UsePullReqCommentsProps extends Pick<GitInfoProps, 'repoMetadata'> {
contentRef: React.RefObject<HTMLDivElement | null>
refetchActivities?: UseGetPullRequestInfoResult['refetchActivities']
setDirty?: React.Dispatch<React.SetStateAction<boolean>>
memorizedState: Map<string, DiffViewerExchangeState>
}
export function usePullReqComments({
@ -85,7 +87,8 @@ export function usePullReqComments({
containerRef,
contentRef,
refetchActivities,
setDirty
setDirty,
memorizedState
}: UsePullReqCommentsProps) {
const activities = usePullReqActivities()
const { getString } = useStrings()
@ -98,7 +101,18 @@ export function usePullReqComments({
)
const { save, update, remove } = useCommentAPI(commentPath)
const location = useLocation()
const [comments] = useState(new Map<number, DiffCommentItem<TypesPullReqActivity>>())
const comments = useMemo(() => {
let _comments = memorizedState.get(diff.filePath)?.comments
if (!_comments) {
_comments = new Map<number, DiffCommentItem<TypesPullReqActivity>>()
memorizedState.set(diff.filePath, { ...memorizedState.get(diff.filePath), comments: _comments })
}
return _comments
}, [diff.filePath, memorizedState])
const copyLinkToComment = useCallback(
(id, commentItem) => {
const path = `${routes.toCODEPullRequest({
@ -252,8 +266,18 @@ export function usePullReqComments({
commentRowElement.innerHTML = `<td colspan="2"></td>`
lineInfo.rowElement.after(commentRowElement)
if (!memorizedState.get(diff.filePath)?.commentsVisibilityAtLineNumber) {
memorizedState.set(diff.filePath, {
...memorizedState.get(diff.filePath),
commentsVisibilityAtLineNumber: new Map()
})
}
// Get show commments from memorizedState
const showComments = memorizedState.get(diff.filePath)?.commentsVisibilityAtLineNumber?.get(comment.lineNumberEnd)
// Set both place-holder and comment box hidden when comment thread is resolved
if (isCommentThreadResolved) {
if ((showComments === undefined && isCommentThreadResolved) || showComments === false) {
oppositeRowPlaceHolder.setAttribute('hidden', '')
commentRowElement.setAttribute('hidden', '')
}
@ -300,6 +324,12 @@ export function usePullReqComments({
lang: filenameToLanguage(diff.filePath.split('/').pop())
}
// Vars to verified if a CommentBox is restored from innerHTML/textContent optimization
const _memorizedState = memorizedState.get(diff.filePath)?.comments?.get(commentThreadId)
const isCommentBoxRestored =
(commentThreadId && commentThreadId < 0 && _memorizedState?.uncommittedText !== undefined) ||
_memorizedState?.showReplyPlaceHolder === false
// 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
@ -311,6 +341,7 @@ export function usePullReqComments({
routingId={routingId}
standalone={standalone}
repoMetadata={repoMetadata}
commentThreadId={commentThreadId}
commentItems={comment._commentItems as CommentItem<TypesPullReqActivity>[]}
initialContent={''}
width={getCommentBoxWidth(isSideBySide)}
@ -323,7 +354,7 @@ export function usePullReqComments({
last.style.height = `${boxHeight}px`
}
}}
autoFocusAndPosition={true}
autoFocusAndPosition={isCommentBoxRestored ? false : true}
enableReplyPlaceHolder={(comment._commentItems as CommentItem<TypesPullReqActivity>[])?.length > 0}
onCancel={comment.destroy}
setDirty={setDirty || noop}
@ -470,6 +501,8 @@ export function usePullReqComments({
/>
)
}}
memorizedState={_memorizedState}
commentsVisibilityAtLineNumber={memorizedState.get(diff.filePath)?.commentsVisibilityAtLineNumber}
/>
</AppWrapper>,
element
@ -498,7 +531,8 @@ export function usePullReqComments({
refetchActivities,
copyLinkToComment,
markSelectedLines,
updateDataCommentIds
updateDataCommentIds,
memorizedState
]
)