feat: show file review status

This commit is contained in:
EnixCoda 2025-12-07 20:36:46 +08:00
parent 225a944685
commit 30668b16aa
15 changed files with 173 additions and 112 deletions

View file

@ -0,0 +1,21 @@
import {
DiffAddedIcon,
DiffIgnoredIcon,
DiffModifiedIcon,
DiffRemovedIcon,
DiffRenamedIcon,
} from '@primer/octicons-react'
import React from 'react'
import { Icon } from '../Icon'
const iconMap = {
added: DiffAddedIcon,
ignored: DiffIgnoredIcon,
modified: DiffModifiedIcon,
removed: DiffRemovedIcon,
renamed: DiffRenamedIcon,
}
export const DiffIcon: React.FC<{
diff: Required<TreeNode>['diff']
}> = ({ diff: { status } }) => <Icon className={status} IconComponent={iconMap[status]} />

View file

@ -1,27 +1,8 @@
import {
DiffAddedIcon,
DiffIgnoredIcon,
DiffModifiedIcon,
DiffRemovedIcon,
DiffRenamedIcon,
} from '@primer/octicons-react'
import React from 'react'
import { resolveDiffGraphMeta } from 'utils/general'
import { Icon } from '../Icon'
const iconMap = {
added: DiffAddedIcon,
ignored: DiffIgnoredIcon,
modified: DiffModifiedIcon,
removed: DiffRemovedIcon,
renamed: DiffRenamedIcon,
}
export function DiffStatGraph({
diff: { status, changes, additions, deletions },
}: {
diff: Required<TreeNode>['diff']
}) {
export function DiffStatGraph({ diff }: { diff: Required<TreeNode>['diff'] }) {
const { changes, additions, deletions } = diff
const { g, r, w } = resolveDiffGraphMeta(additions, deletions, changes)
const children: React.ReactNode[] = []
@ -32,10 +13,5 @@ export function DiffStatGraph({
for (let i = 0; i < w; i++)
children.push(<span key={`w-${i}`} className="diff-stat-graph-no-change" />)
return (
<span className={'diff-stat-graph'}>
<Icon className={status} IconComponent={iconMap[status]} />
{children}
</span>
)
return <span className={'diff-stat-graph'}>{children}</span>
}

View file

@ -1,22 +1,9 @@
import React from 'react'
import { Icon } from '../Icon'
const iconMap = {
added: 'diffAdded',
ignored: 'diffIgnored',
modified: 'diffModified',
removed: 'diffRemoved',
renamed: 'diffRenamed',
}
export function DiffStatText({
diff: { status, additions, deletions },
}: {
diff: Required<TreeNode>['diff']
}) {
export function DiffStatText({ diff }: { diff: Required<TreeNode>['diff'] }) {
const { additions, deletions } = diff
return (
<span className={'diff-stat-text'}>
<Icon className={status} type={iconMap[status]} />
{additions > 0 && <span className={'additions'}>{additions}</span>}
{additions > 0 && deletions > 0 && '/'}
{deletions > 0 && <span className={'deletions'}>{deletions}</span>}

View file

@ -1,4 +1,6 @@
import {
CheckCircleFillIcon,
CheckCircleIcon,
CheckIcon,
CommentIcon,
CrossReferenceIcon,
@ -15,6 +17,7 @@ import { cancelEvent, onEnterKeyDown } from 'utils/DOMHelper'
import { is } from 'utils/is'
import { Icon } from '../../Icon'
import { SearchMode } from '../../searchModes'
import { DiffIcon } from '../DiffIcon'
import { DiffStatText } from '../DiffStatText'
import { DiffStatGraph } from './../DiffStatGraph'
import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods'
@ -34,16 +37,27 @@ export function useNodeRenderers(allRenderers: (NodeRenderer | null | undefined)
export function useRenderFileStatus() {
const { showDiffInText } = useConfigs().value
return useCallback(
function renderFileStatus({ diff }: TreeNode) {
function renderFileStatus({ diff, reviewed }: TreeNode) {
return (
diff && (
<span
className={'node-item-diff'}
title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`}
>
{showDiffInText ? <DiffStatText diff={diff} /> : <DiffStatGraph diff={diff} />}
</span>
)
<>
{diff && (
<span
className={'node-item-diff'}
title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`}
>
<DiffIcon diff={diff} />
{showDiffInText ? <DiffStatText diff={diff} /> : <DiffStatGraph diff={diff} />}
</span>
)}
{reviewed === undefined ? null : (
<span
className={cx('node-item-reviewed', { reviewed })}
title={reviewed ? 'Reviewed' : 'Not reviewed'}
>
<Icon IconComponent={reviewed ? CheckCircleFillIcon : CheckCircleIcon} />
</span>
)}
</>
)
},
[showDiffInText],

View file

@ -6,6 +6,7 @@ import { useConfigs } from 'containers/ConfigsContext'
import { useInspector } from 'containers/Inspector'
import { PortalContext } from 'containers/PortalContext'
import { RepoContext } from 'containers/RepoContext'
import { platform } from 'platforms'
import React, {
useCallback,
useContext,
@ -59,6 +60,8 @@ export function FileExplorer() {
const visibleNodes = useVisibleNodes(visibleNodesGenerator)
const state = useLoadedContext(SideBarStateContext).value
platform.usePlatformFileTreeHooks?.({ visibleNodesGenerator })
return (
<>
{run(() => {

1
src/global.d.ts vendored
View file

@ -19,6 +19,7 @@ type TreeNode = {
rawLink?: string
sha?: string
accessDenied?: boolean
reviewed?: boolean
comments?: {
active: number
resolved: number

View file

@ -1,3 +1,4 @@
import { map } from 'utils/map'
import { sanitizedLocation } from 'utils/URLHelper'
import * as API from './API'
import { getPRDiffTotalStat, getPullRequestFilesCount, isInPullFilesPage } from './DOMHelper'
@ -38,7 +39,12 @@ export async function getPullRequestTreeData(
isInPullFilesPage() ? document : undefined,
)
const fileHashMap = resolveFileHashMap(docs)
const diffSummaryMap = resolveDiffSummaryMap(docs)
const fileHashMap =
diffSummaryMap.size > 0
? new Map(map(diffSummaryMap, ([, { path, pathDigest }]) => [path, `diff-${pathDigest}`]))
: resolveFileHashMap(docs)
const url = new URL(sanitizedLocation.href)
url.pathname = `/${userName}/${repoName}/pull/${pullId}/files`
@ -63,6 +69,7 @@ export async function getPullRequestTreeData(
permalink,
rawLink,
sha,
reviewed: diffSummaryMap.get(filename)?.markedAsViewed,
comments: commentsMap.get(filename),
diff: {
status,
@ -83,74 +90,63 @@ const GITHUB_API_RESPONSE_MAX_SIZE_PER_PAGE = 100
const MAX_PAGE = Math.ceil(GITHUB_API_RESPONSE_LENGTH_LIMIT / GITHUB_API_RESPONSE_MAX_SIZE_PER_PAGE)
function resolveFileHashMap(docs: Document[]) {
const mapFromEmbeddedJSON = docs
.map(
doc =>
doc.querySelector('script[data-target="react-app.embeddedData"]')?.textContent ??
JSON.stringify({}),
)
.map(element => {
try {
type PartialReactAppEmbeddedData = {
payload: {
pullRequestsFilesRoute: {
diffSummaries: [
{
changeType: 'MODIFIED'
highestAnnotationLevel: null
isCodeowner: null
isManifestFile: boolean
isSymlink: boolean
isVendored: boolean
linesAdded: number
linesChanged: number
linesDeleted: number
markedAsViewed: boolean
path: string
pathDigest: string
},
]
}
}
}
return JSON.parse(element) as PartialReactAppEmbeddedData
} catch (error) {
return null
}
})
.map(json => {
try {
return json?.payload.pullRequestsFilesRoute.diffSummaries
} catch (error) {
return null
}
})
.reduce((map, curr) => {
curr?.forEach(({ path, pathDigest }) => {
map.set(path, 'diff-' + pathDigest)
})
return map
}, new Map<string, string>())
if (mapFromEmbeddedJSON.size > 0) {
return mapFromEmbeddedJSON
}
// query all elements at once to make getFileElementHash run faster
const elementsHavePath = docs.map(doc => doc.querySelectorAll(`[data-path]`))
const map = new Map<string, string>()
const fileHashMap = new Map<string, string>()
for (const group of elementsHavePath) {
for (let i = 0; i < group.length; i++) {
const element = group[i]
const id = element.parentElement?.id
if (id) {
const path = element.getAttribute('data-path')
if (path) map.set(path, id)
if (path) fileHashMap.set(path, id)
}
}
}
return map
return fileHashMap
}
function resolveDiffSummaryMap(docs: Document[]) {
type DiffSummary = {
changeType: 'MODIFIED'
highestAnnotationLevel: null
isCodeowner: null
isManifestFile: boolean
isSymlink: boolean
isVendored: boolean
linesAdded: number
linesChanged: number
linesDeleted: number
markedAsViewed: boolean
path: string
pathDigest: string
}
type PartialReactAppEmbeddedData = {
payload: {
pullRequestsFilesRoute: {
diffSummaries: [DiffSummary]
}
}
}
return docs
.map(doc => doc.querySelector('script[data-target="react-app.embeddedData"]')?.textContent)
.map(element => {
try {
const json = element ? (JSON.parse(element) as PartialReactAppEmbeddedData) : null
return json?.payload.pullRequestsFilesRoute.diffSummaries
} catch (error) {
return null
}
})
.reduce((map, curr) => {
curr?.forEach(record => {
map.set(record.path, record)
})
return map
}, new Map<DiffSummary['path'], DiffSummary>())
}
async function safeGetPullRequestTreeData(

View file

@ -0,0 +1,30 @@
import React from 'react'
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
export function useGitHubReviewStatus(visibleNodesGenerator: VisibleNodesGenerator | null) {
React.useEffect(() => {
if (!visibleNodesGenerator) return
const clickHandler = (event: MouseEvent) => {
const target = event.target as HTMLElement
const markReviewedButton = target.closest('[class*="MarkAsViewedButton-"]')
const filePath = markReviewedButton
?.closest('[id^="diff-"]')
?.querySelector('a[href^="#diff-"]')
?.textContent?.trim()
// remove Unicode control characters that GitHub adds for RTL support
.replace(/[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '')
if (!markReviewedButton) return
if (!filePath) return
visibleNodesGenerator.updateNode(filePath, node => {
node.reviewed = markReviewedButton.getAttribute('aria-label') === 'Viewed'
})
}
window.addEventListener('click', clickHandler)
return () => {
window.removeEventListener('click', clickHandler)
}
}, [visibleNodesGenerator])
}

View file

@ -1,6 +1,7 @@
import { useConfigs } from 'containers/ConfigsContext'
import { GITHUB_OAUTH } from 'env'
import { Base64 } from 'js-base64'
import { Platform } from 'platforms/platform'
import { $ } from 'utils/$'
import { configRef } from 'utils/config/helper'
import { resolveGitModules } from 'utils/gitSubmodule'
@ -13,6 +14,7 @@ import { getPullRequestTreeData } from './getPullRequestTreeData'
import { useEnterpriseStatBarStyleFix } from './hooks/useEnterpriseStatBarStyleFix'
import { useGitHubAttachCopySnippetButton } from './hooks/useGitHubAttachCopySnippetButton'
import { useGitHubCodeFold } from './hooks/useGitHubCodeFold'
import { useGitHubReviewStatus } from './hooks/useGitHubReviewStatus'
export function processTree(tree: TreeNode[]): TreeNode {
// nodes are created from items and put onto tree
@ -207,6 +209,9 @@ export const GitHub: Platform = {
useGitHubCodeFold(codeFolding)
useEnterpriseStatBarStyleFix()
},
usePlatformFileTreeHooks({ visibleNodesGenerator = null }) {
useGitHubReviewStatus(visibleNodesGenerator)
},
delegateFastRedirectAnchorProps() {
if (configRef.pjaxMode !== 'native') return

View file

@ -1,4 +1,5 @@
import { Base64 } from 'js-base64'
import { Platform } from 'platforms/platform'
import { resolveGitModules } from 'utils/gitSubmodule'
import { useProgressBar } from 'utils/hooks/useProgressBar'
import { sortFoldersToFront } from 'utils/treeParser'

View file

@ -1,6 +1,7 @@
import { GITEE_OAUTH } from 'env'
import { Base64 } from 'js-base64'
import { errors, platform } from 'platforms'
import { Platform } from 'platforms/platform'
import { useCallback, useEffect } from 'react'
import { resolveGitModules } from 'utils/gitSubmodule'
import { useAfterRedirect } from 'utils/hooks/useFastRedirect'

View file

@ -1,3 +1,5 @@
import { Platform } from './platform'
export const dummyPlatformForTypeSafety: Platform = {
isEnterprise() {
return false

View file

@ -1,4 +1,6 @@
type Platform = {
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
export type Platform = {
shouldActivate?(): boolean
isEnterprise(): boolean
// branch name might not be available when resolving from DOM and URL
@ -29,5 +31,8 @@ type Platform = {
| void
loadWithFastRedirect?(url: string, element: HTMLElement): boolean | void
usePlatformHooks?(): void
usePlatformFileTreeHooks?(fileTree: {
visibleNodesGenerator?: VisibleNodesGenerator | null
}): void
mapErrorMessage?: (error: Error) => string | void
}

View file

@ -2,11 +2,11 @@ import { EventHub } from '../EventHub'
import { findNode } from '../general'
import { Options } from './index'
function mergeNodes(target: TreeNode, source: TreeNode) {
function inPlaceMergeNodes(target: TreeNode, source: TreeNode) {
for (const node of source.contents || []) {
const dup = target.contents?.find($node => $node.path === node.path)
if (dup) {
mergeNodes(dup, node)
inPlaceMergeNodes(dup, node)
} else {
if (!target.contents) target.contents = []
target.contents.push(node)
@ -31,6 +31,14 @@ export class BaseLayer {
this.defer = defer
}
updateNode = async (path: string, updateNode: (node: TreeNode) => void) => {
const node = await findNode(this.baseRoot, path)
if (node) {
updateNode(node)
this.baseHub.emit('emit', this.baseRoot)
}
}
loadTreeData = async (path: string) => {
const node = await findNode(this.baseRoot, path)
if (node && node.type !== 'tree') return node
@ -39,7 +47,7 @@ export class BaseLayer {
this.loading.add(path)
this.baseHub.emit('loadingChange', this.loading)
mergeNodes(this.baseRoot, await this.getTreeData(path))
inPlaceMergeNodes(this.baseRoot, await this.getTreeData(path))
this.loading.delete(path)
this.baseHub.emit('loadingChange', this.loading)
this.baseHub.emit('emit', this.baseRoot)

11
src/utils/map.ts Normal file
View file

@ -0,0 +1,11 @@
export const map = <K, R>(
iterable: Iterable<K>,
callback: (item: K, index: number, iterable: Iterable<K>) => R,
): R[] => {
const result: R[] = []
let index = 0
for (const item of iterable) {
result.push(callback(item, index++, iterable))
}
return result
}