From 30668b16aad9971b6c0df11fbd412bdb8191b23b Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 7 Dec 2025 20:36:46 +0800 Subject: [PATCH] feat: show file review status --- src/components/FileExplorer/DiffIcon.tsx | 21 ++++ src/components/FileExplorer/DiffStatGraph.tsx | 30 +---- src/components/FileExplorer/DiffStatText.tsx | 17 +-- .../FileExplorer/hooks/useNodeRenderers.tsx | 32 +++-- src/components/FileExplorer/index.tsx | 3 + src/global.d.ts | 1 + .../GitHub/getPullRequestTreeData.ts | 110 +++++++++--------- .../GitHub/hooks/useGitHubReviewStatus.ts | 30 +++++ src/platforms/GitHub/index.ts | 5 + src/platforms/Gitea/index.ts | 1 + src/platforms/Gitee/index.ts | 1 + src/platforms/dummyPlatformForTypeSafety.ts | 2 + src/platforms/{platform.d.ts => platform.ts} | 7 +- src/utils/VisibleNodesGenerator/BaseLayer.ts | 14 ++- src/utils/map.ts | 11 ++ 15 files changed, 173 insertions(+), 112 deletions(-) create mode 100644 src/components/FileExplorer/DiffIcon.tsx create mode 100644 src/platforms/GitHub/hooks/useGitHubReviewStatus.ts rename src/platforms/{platform.d.ts => platform.ts} (85%) create mode 100644 src/utils/map.ts diff --git a/src/components/FileExplorer/DiffIcon.tsx b/src/components/FileExplorer/DiffIcon.tsx new file mode 100644 index 0000000..44d1b70 --- /dev/null +++ b/src/components/FileExplorer/DiffIcon.tsx @@ -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['diff'] +}> = ({ diff: { status } }) => diff --git a/src/components/FileExplorer/DiffStatGraph.tsx b/src/components/FileExplorer/DiffStatGraph.tsx index 45329cc..e1f8e79 100644 --- a/src/components/FileExplorer/DiffStatGraph.tsx +++ b/src/components/FileExplorer/DiffStatGraph.tsx @@ -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['diff'] -}) { +export function DiffStatGraph({ diff }: { diff: Required['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() - return ( - - - {children} - - ) + return {children} } diff --git a/src/components/FileExplorer/DiffStatText.tsx b/src/components/FileExplorer/DiffStatText.tsx index 2763fc0..61732c2 100644 --- a/src/components/FileExplorer/DiffStatText.tsx +++ b/src/components/FileExplorer/DiffStatText.tsx @@ -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['diff'] -}) { +export function DiffStatText({ diff }: { diff: Required['diff'] }) { + const { additions, deletions } = diff return ( - {additions > 0 && {additions}} {additions > 0 && deletions > 0 && '/'} {deletions > 0 && {deletions}} diff --git a/src/components/FileExplorer/hooks/useNodeRenderers.tsx b/src/components/FileExplorer/hooks/useNodeRenderers.tsx index a875e80..47c5c07 100644 --- a/src/components/FileExplorer/hooks/useNodeRenderers.tsx +++ b/src/components/FileExplorer/hooks/useNodeRenderers.tsx @@ -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 && ( - - {showDiffInText ? : } - - ) + <> + {diff && ( + + + {showDiffInText ? : } + + )} + {reviewed === undefined ? null : ( + + + + )} + ) }, [showDiffInText], diff --git a/src/components/FileExplorer/index.tsx b/src/components/FileExplorer/index.tsx index 69ccba4..3e4d533 100644 --- a/src/components/FileExplorer/index.tsx +++ b/src/components/FileExplorer/index.tsx @@ -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(() => { diff --git a/src/global.d.ts b/src/global.d.ts index c907441..55232f0 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -19,6 +19,7 @@ type TreeNode = { rawLink?: string sha?: string accessDenied?: boolean + reviewed?: boolean comments?: { active: number resolved: number diff --git a/src/platforms/GitHub/getPullRequestTreeData.ts b/src/platforms/GitHub/getPullRequestTreeData.ts index 920a797..be4a1fb 100644 --- a/src/platforms/GitHub/getPullRequestTreeData.ts +++ b/src/platforms/GitHub/getPullRequestTreeData.ts @@ -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()) - - 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() + const fileHashMap = new Map() 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()) } async function safeGetPullRequestTreeData( diff --git a/src/platforms/GitHub/hooks/useGitHubReviewStatus.ts b/src/platforms/GitHub/hooks/useGitHubReviewStatus.ts new file mode 100644 index 0000000..a304bea --- /dev/null +++ b/src/platforms/GitHub/hooks/useGitHubReviewStatus.ts @@ -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]) +} diff --git a/src/platforms/GitHub/index.ts b/src/platforms/GitHub/index.ts index 83229ad..66c0f26 100644 --- a/src/platforms/GitHub/index.ts +++ b/src/platforms/GitHub/index.ts @@ -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 diff --git a/src/platforms/Gitea/index.ts b/src/platforms/Gitea/index.ts index ff336d2..2995eb1 100644 --- a/src/platforms/Gitea/index.ts +++ b/src/platforms/Gitea/index.ts @@ -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' diff --git a/src/platforms/Gitee/index.ts b/src/platforms/Gitee/index.ts index ec5d704..a778be0 100644 --- a/src/platforms/Gitee/index.ts +++ b/src/platforms/Gitee/index.ts @@ -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' diff --git a/src/platforms/dummyPlatformForTypeSafety.ts b/src/platforms/dummyPlatformForTypeSafety.ts index b02ea46..d4efdc2 100644 --- a/src/platforms/dummyPlatformForTypeSafety.ts +++ b/src/platforms/dummyPlatformForTypeSafety.ts @@ -1,3 +1,5 @@ +import { Platform } from './platform' + export const dummyPlatformForTypeSafety: Platform = { isEnterprise() { return false diff --git a/src/platforms/platform.d.ts b/src/platforms/platform.ts similarity index 85% rename from src/platforms/platform.d.ts rename to src/platforms/platform.ts index c5356e9..a028f02 100644 --- a/src/platforms/platform.d.ts +++ b/src/platforms/platform.ts @@ -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 } diff --git a/src/utils/VisibleNodesGenerator/BaseLayer.ts b/src/utils/VisibleNodesGenerator/BaseLayer.ts index d4db586..04d2301 100644 --- a/src/utils/VisibleNodesGenerator/BaseLayer.ts +++ b/src/utils/VisibleNodesGenerator/BaseLayer.ts @@ -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) diff --git a/src/utils/map.ts b/src/utils/map.ts new file mode 100644 index 0000000..0cec5e7 --- /dev/null +++ b/src/utils/map.ts @@ -0,0 +1,11 @@ +export const map = ( + iterable: Iterable, + callback: (item: K, index: number, iterable: Iterable) => R, +): R[] => { + const result: R[] = [] + let index = 0 + for (const item of iterable) { + result.push(callback(item, index++, iterable)) + } + return result +}