Files
deepseek-harness/packages/experimental/client-ui-forge/src/client/DiffFile.tsx
T
Yichen Jiang 6f088b13e7 perf(forge-panel): highlight only after scrolling settles
Highlighting waits until the page is idle and the scrollport has been still
for 200 ms, so a slow grammar's first tokenization never interrupts a scroll.
Adds the Agent Note recording the changes view decisions.
2026-11-07 16:42:40 +08:00

592 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* One file of the changes view: a sticky header with the path, badges, line
* counts, and file actions, then its hunks unified or side by side with the
* unchanged lines between them collapsed into rows that expand them. A section
* draws its lines once it comes within reach of the scrollport and highlights
* them when the page is next idle; it redraws only when its own props change.
*/
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import clsx from 'clsx'
import { flushSync } from 'react-dom'
import {
FileTypeIcon, IconChevronDownOutlineRegular, IconChevronsUpDownOutlineRegular, IconEllipsisOutlineRegular, IconPlusOutlineRegular,
Tag, Tooltip,
languageForPath, Menu, MenuItemButton, useCodeHighlighter, type CodeHighlighter, type HighlightSpan,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ForgeDiffFile } from '../types.ts'
import { drawnLines, segmentsOf, splitRows, type DiffRow, type DiffSegment, type SplitCell, type SplitRow } from './diff-rows.ts'
import { codeSideAt, readCodeSelection } from './code-selection.ts'
import { reviewMatches, selectReviewLines, type ReviewSelection } from './review.ts'
import { ReviewThread, useFileReview } from './ReviewThread.tsx'
import type { FileReviewState } from './review-queue.ts'
import { ReviewComposer, type ReviewActions } from './ReviewComposer.tsx'
import reviewCss from './ReviewComposer.module.css'
import { FileLabel } from './FileLabel.tsx'
import type { ForgePanelKey, NS } from './locales.ts'
import type { ViewedState } from './viewed-files.ts'
import { SectionReachContext, ViewVisibleContext, whenIdle } from './section-reach.ts'
import css from './ChangesTab.module.css'
type Translate = PropsLocale<typeof NS>['t']
/** One action of a file's menu. */
export interface DiffFileAction {
/** Dictionary key of the row label. */
readonly label: ForgePanelKey
/** Runs the action. */
readonly run: () => void
}
/** New side of a file: its lines once read, `loading` while a read runs, or undefined before any. */
export type DiffFileText = readonly string[] | 'loading' | undefined
/**
* What every file section of one changes view calls. The view passes the same
* object on every render, so a section redraws only when its own values change.
*/
export interface DiffFileHandlers {
/** Expand or collapse a file. */
readonly toggle: (file: ForgeDiffFile) => void
/** Show a large file anyway. */
readonly reveal: (path: string) => void
/** Expand one gap of a file. */
readonly expand: (file: ForgeDiffFile, index: number) => void
/** Mark a file viewed, which also collapses it, or clear the mark. */
readonly view: (file: ForgeDiffFile, viewed: boolean) => void
/** Rows of a file's menu, read whenever the menu draws. */
readonly actions: (file: ForgeDiffFile) => readonly DiffFileAction[]
/** Receive a file's section element, which scrolling to the file targets, or null once it unmounts. */
readonly section: (path: string, element: HTMLElement | null) => void
}
/** Props of one file section. */
export interface DiffFileProps {
/** Comment destinations and the source being reviewed. */
readonly review?: ReviewActions | undefined
/** The file and its hunks. */
readonly file: ForgeDiffFile
/** Whether the section shows its lines. */
readonly expanded: boolean
/** Whether the file shows the large-file notice instead of its lines until revealed. */
readonly large: boolean
/** Whether the user revealed a large file. */
readonly revealed: boolean
/** The file's new side, which supplies collapsed unchanged lines. */
readonly text: DiffFileText
/** Indexes of the expanded gaps. */
readonly open: ReadonlySet<number>
/** Whether deletions and additions show in two columns; a file with only additions or only deletions keeps one. */
readonly split: boolean
/** Whether long lines wrap. */
readonly wrap: boolean
/** Badge text after the path, or empty for none. */
readonly badge: string
/** Hover text of the path: both paths of a renamed file, else the path. */
readonly tooltip: string
/** Whether the reader marked this change viewed, or marked an earlier change of the file. */
readonly viewed: ViewedState
/** Actions shared by every section of the view. */
readonly handlers: DiffFileHandlers
/** Forge panel copy. */
readonly t: Translate
}
/**
* Render added and deleted line counts, or the binary label for binary content.
* @param props - the counts and copy.
* @returns the counts.
*/
export function Counts({ file, t }: {
readonly file: Pick<ForgeDiffFile, 'additions' | 'deletions' | 'binary'>
readonly t: Translate
}): ReactNode {
if (file.binary) return <span>{t('diff.binaryLabel')}</span>
return (
<span className={css.counts}>
<span className={css.added}>{`+${file.additions.toLocaleString('en-US')}`}</span>
<span className={css.deleted}>{`−${file.deletions.toLocaleString('en-US')}`}</span>
</span>
)
}
const SIGN: Record<DiffRow['kind'], string> = { add: '+', del: '-', context: '' }
/** Highlighted tokens of one side's lines by line number. */
type SideSpans = Map<number, readonly HighlightSpan[]>
/** Highlighted tokens of a file's drawn lines on each side; a line without an entry draws as plain text. */
interface FileSpans {
readonly old: SideSpans
readonly new: SideSpans
}
/** Lines highlighted as one fragment; a longer run splits here, where a token spanning the split restarts. */
const HIGHLIGHT_CHUNK_LINES = 100
/** Numbered lines of one side that are highlighted together. */
interface HighlightChunk {
readonly side: 'old' | 'new'
readonly lines: readonly { readonly no: number; readonly text: string }[]
}
/** Split each run of rows of each side into fragments of at most {@link HIGHLIGHT_CHUNK_LINES} lines. */
function highlightChunks(segments: readonly DiffSegment[]): HighlightChunk[] {
const chunks: HighlightChunk[] = []
for (const segment of segments) {
if (segment.kind === 'gap') continue
for (const side of ['old', 'new'] as const) {
const lines: { readonly no: number; readonly text: string }[] = []
for (const row of segment.rows) {
const no = row[side]
if (no !== null) lines.push({ no, text: row.text })
}
for (let at = 0; at < lines.length; at += HIGHLIGHT_CHUNK_LINES) {
chunks.push({ side, lines: lines.slice(at, at + HIGHLIGHT_CHUNK_LINES) })
}
}
}
return chunks
}
/** Highlight one fragment, so tokens spanning its lines keep their state, into `spans`. */
function addChunkSpans(spans: FileSpans, chunk: HighlightChunk, highlighter: CodeHighlighter): void {
const highlighted = highlighter(chunk.lines.map(line => line.text).join('\n'))
/* v8 ignore next -- a grammar that reported ready stays registered, so its fragments always tokenize */
if (highlighted === undefined) return
// Shiki omits a final empty token row, so a final empty line gets no tokens.
for (const [index, line] of chunk.lines.entries()) spans[chunk.side].set(line.no, highlighted[index] ?? [])
}
/**
* Highlight a file's drawn lines in idle time while the scrollport is still, once its language's grammar is ready:
* each idle period highlights fragments while it lasts, at least one, and the lines take their tokens when the last
* fragment is done. Until then, the lines keep the tokens of the previous pass, by line number, and new lines draw as
* plain text. A grammar that loads for another language starts no pass.
*/
function useFileSpans(path: string, segments: readonly DiffSegment[]): FileSpans | undefined {
const reach = useContext(SectionReachContext)
const highlighter = useCodeHighlighter(languageForPath(path))
// The highlighter changes whenever any grammar loads; the empty fragment tells whether this language's is ready.
const ready = highlighter('') !== undefined
const current = useRef(highlighter)
current.current = highlighter
const [spans, setSpans] = useState<FileSpans | undefined>(undefined)
useEffect(() => {
if (!ready) return
const chunks = highlightChunks(segments)
const pass: FileSpans = { old: new Map(), new: new Map() }
let next = 0
const step = (deadline: IdleDeadline | undefined): void => {
for (;;) {
const chunk = chunks[next]
if (chunk === undefined) {
setSpans(pass)
return
}
addChunkSpans(pass, chunk, current.current)
next++
if ((deadline?.timeRemaining() ?? 0) <= 0) break
}
cancel = later(step)
}
// Outside a changes view nothing scrolls the sections, so idle time alone gates the pass.
const later = reach === null ? whenIdle : (task: (deadline: IdleDeadline | undefined) => void) => reach.whenSettled(task)
let cancel = later(step)
return () => { cancel() }
}, [segments, ready, reach])
return spans
}
function Code({ text, spans, old, next }: {
readonly text: string
readonly spans: readonly HighlightSpan[] | undefined
readonly old: number | null
readonly next: number | null
}): ReactNode {
return (
<span className={css.text} data-review-code="" data-old-line={old ?? undefined} data-new-line={next ?? undefined} data-diff-code={spans === undefined ? undefined : ''}>
{spans === undefined ? text : spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)}
</span>
)
}
/** Whether a side-by-side row repeats an unchanged line or shows a change. */
function pairKind(pair: SplitRow): 'context' | 'change' {
return pair.left !== null && pair.left.kind === 'context' ? 'context' : 'change'
}
/** The composer of a new comment, shown with its menus only while the changes tab is visible. */
function VisibleComposer(props: Omit<Parameters<typeof ReviewComposer>[0], 'visible'>): ReactNode {
return <ReviewComposer {...props} visible={useContext(ViewVisibleContext)} />
}
/** The hunks of one file with their collapsed and expanded unchanged lines. */
function Lines({ file, text, open, handlers, split, wrap, review, comments, t }: Pick<DiffFileProps,
'file' | 'text' | 'open' | 'handlers' | 'split' | 'wrap' | 'review' | 't'> & { readonly comments: FileReviewState }): ReactNode {
const lines = typeof text === 'object' ? text : undefined
const columns = split && file.additions > 0 && file.deletions > 0
const singleSide = file.status === 'added' ? 'R' : file.status === 'deleted' ? 'L' : null
let maximum = Math.max(1, lines?.length ?? 0, (lines?.length ?? 0) + file.deletions - file.additions)
for (const hunk of file.hunks) maximum = Math.max(maximum, hunk.oldStart + hunk.oldLines - 1, hunk.newStart + hunk.newLines - 1)
const style: CSSProperties & { '--forge-number-width': string } = { '--forge-number-width': `calc(${Math.max(2, String(maximum).length)}ch + 12px)` }
const [selection, setSelection] = useState<ReviewSelection | null>(null)
const root = useRef<HTMLDivElement>(null)
const origin = useRef<{ side: 'L' | 'R'; line: number } | null>(null)
// `add` is a gutter drag that began on a row's hover plus and opens the editor when the pointer is released.
const dragging = useRef<'gutter' | 'add' | 'text' | null>(null)
const [selecting, setSelecting] = useState(false)
const [editing, setEditing] = useState(false)
const [nativeSide, setNativeSide] = useState<'L' | 'R' | null>(null)
const nativeSelection = useCallback((): void => {
if (editing || nativeSide === null || root.current === null || review === undefined) return
const selected = readCodeSelection(root.current, nativeSide)
if (selected === undefined) {
setSelection(null)
setNativeSide(null)
return
}
const comment = selectReviewLines(file, selected.side, selected.from, selected.to)
setSelection(previous => comment === undefined ? null
: previous?.comment.side === comment.side && previous.comment.startLine === comment.startLine
&& previous.comment.endLine === comment.endLine
? previous : { source: review.source, comment })
}, [editing, nativeSide, review, file])
useEffect(() => {
if (nativeSide === null || editing) return
const changed = (): void => { if (dragging.current === null) nativeSelection() }
document.addEventListener('selectionchange', changed)
return () => { document.removeEventListener('selectionchange', changed) }
}, [nativeSide, editing, nativeSelection])
useEffect(() => {
if (nativeSide === null) return
const copy = (event: ClipboardEvent): void => {
if (root.current === null || event.clipboardData === null
|| (event.target instanceof Element && event.target.closest('input, textarea, [contenteditable="true"]') !== null)) return
const selected = readCodeSelection(root.current, nativeSide)
if (selected === undefined) return
event.preventDefault()
event.clipboardData.setData('text/plain', selected.text)
}
document.addEventListener('copy', copy)
return () => { document.removeEventListener('copy', copy) }
}, [nativeSide])
useEffect(() => {
if (!selecting) return
const finish = (event: PointerEvent): void => {
if (dragging.current === 'text') nativeSelection()
const compose = dragging.current === 'add' && event.type === 'pointerup'
dragging.current = null
setSelecting(false)
if (compose) setEditing(true)
}
window.addEventListener('pointerup', finish)
window.addEventListener('pointercancel', finish)
return () => {
window.removeEventListener('pointerup', finish)
window.removeEventListener('pointercancel', finish)
}
}, [selecting, nativeSelection])
const segments = useMemo(() => segmentsOf(file, lines, open), [file, lines, open])
const spans = useFileSpans(file.path, segments)
// Returns whether the range was accepted; a range across hunks or outside them keeps the previous selection.
const choose = (side: 'L' | 'R', line: number, extend: boolean): boolean => {
if (review === undefined || editing) return false
const from = extend && origin.current?.side === side ? origin.current.line : line
const comment = selectReviewLines(file, side, from, line)
if (comment === undefined) return false
if (!extend || origin.current?.side !== side) origin.current = { side, line }
setSelection({ source: review.source, comment })
return true
}
// A drag that began on a hover plus extends through every row with a line on its starting side, wherever the pointer crosses that row.
const extendAdd = (buttons: number, old: number | null, next: number | null): void => {
const from = origin.current
if (dragging.current !== 'add' || buttons !== 1 || from === null) return
const line = from.side === 'L' ? old : next
if (line !== null) choose(from.side, line, true)
}
const selected = (side: 'L' | 'R', line: number | null): boolean => line !== null && selection !== null
&& selection.comment.side === side && line >= selection.comment.startLine && line <= selection.comment.endLine
// `hover` adds the mouse shortcut: a plus shown while a hovering pointer is over this side of the row.
const number = (side: 'L' | 'R', line: number | null, hover: boolean): ReactNode => {
if (line === null || review === undefined || !file.hunks.some((hunk) => {
const start = side === 'L' ? hunk.oldStart : hunk.newStart
return line >= start && line < start + (side === 'L' ? hunk.oldLines : hunk.newLines)
})) {
return <span className={css.number}>{line ?? ''}</span>
}
const plus = !selecting && !editing && selection?.comment.side === side && selection.comment.endLine === line
return <span className={clsx(css.number, reviewCss.gutter)} data-review-side={side}
onPointerEnter={(event) => { if (dragging.current === 'gutter' && origin.current?.side === side && event.buttons === 1) choose(side, line, true) }}>
<button type="button" className={reviewCss.line}
aria-label={t('review.selectLine', { line: `${side}${line}` })} aria-pressed={selected(side, line)}
onPointerDown={(event) => {
if (event.button !== 0 || editing) return
event.preventDefault()
window.getSelection()?.removeAllRanges()
setNativeSide(null)
dragging.current = 'gutter'
setSelecting(true)
choose(side, line, event.shiftKey)
}}
onClick={(event) => {
if (event.detail !== 0) return
window.getSelection()?.removeAllRanges()
setNativeSide(null)
choose(side, line, event.shiftKey)
}}>{line}</button>
{plus && (
<Tooltip label={t('review.addComment')} portal>
<button type="button" className={reviewCss.add} aria-label={t('review.addComment')}
onPointerDown={(event) => { event.preventDefault() }} onClick={() => { setEditing(true) }}>
<IconPlusOutlineRegular size={14} />
</button>
</Tooltip>
)}
{/* Keyboard and assistive technology reach the same editor through the line button and the labeled plus above. */}
{hover && !plus && !editing && (
<span className={reviewCss.hoverAdd} aria-hidden="true" data-review-add={`${side}${line}`}
onPointerDown={(event) => {
if (event.button !== 0) return
event.preventDefault()
// A refused range keeps the previous selection, which the release must not open.
if (!choose(side, line, event.shiftKey)) return
window.getSelection()?.removeAllRanges()
setNativeSide(null)
dragging.current = 'add'
setSelecting(true)
}}>
<IconPlusOutlineRegular size={14} />
</span>
)}
</span>
}
const cell = (value: SplitCell | null, side: 'L' | 'R'): ReactNode => value === null
? <span className={clsx(css.cell, css.empty)} data-diff-side={side} />
: <span className={clsx(css.cell, css[value.kind], selected(side, value.no) && (editing || nativeSide === null) && reviewCss.selected)}
data-diff-side={side}>
{number(side, value.no, true)}<Code text={value.text} spans={(side === 'L' ? spans?.old : spans?.new)?.get(value.no)} old={side === 'L' ? value.no : null} next={side === 'R' ? value.no : null} />
</span>
const rows: ReactNode[] = []
let inserted = false
const editor = review === undefined || selection === null || !editing ? null : <VisibleComposer key="comment" selection={selection} split={columns}
actions={review} close={() => {
const native = window.getSelection()
if (root.current?.contains(native?.anchorNode ?? null) === true) native?.removeAllRanges()
setSelection(null)
setEditing(false)
setNativeSide(null)
origin.current = null
}} t={t} />
const remaining = new Set(comments.threads)
const appendComments = (old: number | null, next: number | null): void => {
if (review === undefined) return
for (const thread of remaining) {
const comment = thread.selection.comment
if ((comment.side === 'L' ? old : next) !== comment.endLine || !reviewMatches(thread.selection, review.source, file)) continue
rows.push(<ReviewThread key={`record-${thread.key}`} thread={thread} actions={review} locked={comments.locked} outdated={false} split={columns} t={t} />)
remaining.delete(thread)
}
}
const endsAt = (old: number | null, next: number | null): boolean => selection !== null
&& (selection.comment.side === 'L' ? old : next) === selection.comment.endLine
for (const [at, segment] of segments.entries()) {
if (segment.kind === 'gap') {
rows.push(<button key={`gap-${segment.index}`} type="button" className={css.gap} disabled={text === 'loading'}
data-forge-diff-gap={segment.index} onClick={() => { handlers.expand(file, segment.index) }}>
<IconChevronsUpDownOutlineRegular size={12} />
{segment.count === null ? t('diff.unchangedRest') : t('diff.unchanged', { count: segment.count.toLocaleString('en-US') })}
</button>)
} else if (columns) {
for (const [index, pair] of splitRows(segment.rows).entries()) {
rows.push(<div key={`${at}-${index}`} className={css.pair} data-diff-pair={pairKind(pair)}
onPointerEnter={(event) => { extendAdd(event.buttons, pair.left?.no ?? null, pair.right?.no ?? null) }}>
{cell(pair.left, 'L')}{cell(pair.right, 'R')}
</div>)
if (!inserted && endsAt(pair.left?.no ?? null, pair.right?.no ?? null)) {
rows.push(editor)
inserted = true
}
appendComments(pair.left?.no ?? null, pair.right?.no ?? null)
}
} else {
for (const [index, row] of segment.rows.entries()) {
// Both numbers place their plus in the sign column, so a row showing the labeled plus offers no hover plus.
const labeled = !selecting && !editing && endsAt(row.old, row.new)
rows.push(<div key={`${at}-${index}`} className={clsx(css.line, css[row.kind], (selected('L', row.old) || selected('R', row.new)) && (editing || nativeSide === null) && reviewCss.selected)} data-diff-line={row.kind}
onPointerEnter={(event) => { extendAdd(event.buttons, row.old, row.new) }}>
{singleSide !== 'R' && number('L', row.old, row.kind === 'del' && !labeled)}{singleSide !== 'L' && number('R', row.new, row.kind !== 'del' && !labeled)}<span className={css.sign}>{SIGN[row.kind]}</span>
<Code text={row.text} old={row.old} next={row.new} spans={row.kind === 'del' ? spans?.old.get(row.old) : spans?.new.get(row.new)} />
</div>)
if (!inserted && endsAt(row.old, row.new)) {
rows.push(editor)
inserted = true
}
appendComments(row.old, row.new)
}
}
}
if (review !== undefined) for (const thread of remaining) {
rows.push(<ReviewThread key={`record-${thread.key}`} thread={thread} actions={review} locked={comments.locked} outdated split={false} t={t} />)
}
if (!inserted) rows.push(editor)
return <div ref={root} className={css.code} style={style} data-single-side={singleSide ?? undefined} data-split={columns ? '' : undefined} data-wrap={wrap ? '' : undefined}
data-review={review === undefined ? undefined : ''}
data-native-side={nativeSide ?? undefined}
onPointerDownCapture={(event) => {
if (event.button !== 0 || !(event.target instanceof Node)) return
const side = codeSideAt(event.currentTarget, event.target)
if (side === undefined) return
if (!event.shiftKey || nativeSide !== side) window.getSelection()?.removeAllRanges()
if (!editing) dragging.current = 'text'
// The chosen column must allow selection before the browser starts its native text range.
flushSync(() => {
setNativeSide(side)
if (editing) return
setSelection(null)
setSelecting(true)
})
}}>{rows}</div>
}
/** Whether a file's body draws its hunks line by line rather than a note or a collapsed strip. */
function drawsLines(file: ForgeDiffFile, large: boolean, revealed: boolean): boolean {
return !file.binary && !file.oversized && file.hunks.length > 0 && (!large || revealed)
}
function Body(props: DiffFileProps): ReactNode {
const { file, large, revealed, handlers, review, t } = props
const comments = useFileReview(review, file.path)
const records = review === undefined ? null : comments.threads.map(thread => <ReviewThread key={thread.key} thread={thread}
actions={review} locked={comments.locked} outdated={!reviewMatches(thread.selection, review.source, file)} split={false} t={t} />)
if (file.binary) return <><p className={css.fileNote}>{t('diff.binary')}</p>{records}</>
if (file.oversized) return <><p className={css.fileNote}>{t('diff.fileTooLarge')}</p>{records}</>
if (file.hunks.length === 0) return records
if (large && !revealed) {
// A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines.
return (
<><button type="button" className={css.gap} onClick={() => { handlers.reveal(file.path) }}>
<IconChevronsUpDownOutlineRegular size={12} />
{t('diff.largeCollapsed', { count: (file.additions + file.deletions).toLocaleString('en-US') })}
</button>{records}</>
)
}
return <Lines {...props} comments={comments} />
}
/** Props of a file's menu. */
interface FileMenuProps {
readonly name: string
readonly file: ForgeDiffFile
readonly handlers: DiffFileHandlers
readonly t: Translate
}
/** File actions live only while their owning tab is visible, so hiding the tab closes the menu. */
function FileMenu(props: FileMenuProps): ReactNode {
return useContext(ViewVisibleContext) ? <OpenableFileMenu {...props} /> : null
}
function OpenableFileMenu({ name, file, handlers, t }: FileMenuProps): ReactNode {
const [menuOpen, setMenuOpen] = useState(false)
return (
<Menu
open={menuOpen}
portal
align="end"
dense
autoFocus
onClose={() => { setMenuOpen(false) }}
anchor={(
<button
type="button"
className={css.tool}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={t('diff.moreActions', { name })}
onClick={() => { setMenuOpen(value => !value) }}
>
<IconEllipsisOutlineRegular />
</button>
)}
>
{handlers.actions(file).map(action => (
<MenuItemButton
key={action.label}
onSelect={() => {
setMenuOpen(false)
action.run()
}}
>
{t(action.label)}
</MenuItemButton>
))}
</Menu>
)
}
/**
* Render one file of a diff. Until the section first comes within reach of the scrollport, an expanded file that
* draws lines shows a box of about their height instead.
* @param props - the file, its section and gap state, layout choices, shared actions, and copy.
* @returns the file section.
*/
function DiffFileSection(props: DiffFileProps): ReactNode {
const { file, expanded, large, revealed, badge, tooltip, viewed, handlers, t } = props
const name = file.path.slice(file.path.lastIndexOf('/') + 1)
const reach = useContext(SectionReachContext)
const element = useRef<HTMLElement | null>(null)
const [reached, setReached] = useState(false)
const waiting = expanded && !reached && reach !== null && drawsLines(file, large, revealed)
useEffect(() => {
/* v8 ignore next -- React attaches the section before it runs effects */
if (!waiting || element.current === null) return
return reach.observe(element.current, () => { setReached(true) })
}, [waiting, reach])
const sectionRef = useCallback((node: HTMLElement | null) => {
element.current = node
handlers.section(file.path, node)
}, [handlers, file.path])
const placeholder: CSSProperties & { '--forge-rows': number } = { '--forge-rows': drawnLines(file) }
return (
<section ref={sectionRef} className={css.file} data-forge-diff-file={file.path} data-expanded={expanded ? '' : undefined}
data-viewed={viewed}>
<div className={css.fileHeader}>
<button
type="button"
className={css.fileToggle}
aria-expanded={expanded}
aria-label={t(expanded ? 'change.collapse' : 'change.expand', { name })}
onClick={() => { handlers.toggle(file) }}
>
<IconChevronDownOutlineRegular size={14} className={css.chevron} />
</button>
<span className={css.fileTitle}>
<FileTypeIcon path={file.path} size={16} className={css.fileIcon} />
<span className={css.filePath}>
<FileLabel path={file.path} tooltip={tooltip} />
</span>
{viewed === 'changed' && (
<Tag tone="warning" className={css.changedTag}><span className={css.changedText}>{t('viewed.changed')}</span></Tag>
)}
<span className={css.fileSpacer} data-forge-path-spacer="" />
{badge !== '' && <span className={css.badge}>{badge}</span>}
</span>
{!file.binary && <Counts file={file} t={t} />}
<label className={css.viewed} title={t('viewed.hint')}>
<input type="checkbox" checked={viewed === 'viewed'} aria-label={t('viewed.aria', { name })}
onChange={(event) => { handlers.view(file, event.target.checked) }} />
<span className={css.viewedLabel} aria-hidden="true">{t('viewed.label')}</span>
</label>
<FileMenu name={name} file={file} handlers={handlers} t={t} />
</div>
{expanded && (waiting ? <div className={css.unreached} style={placeholder} data-forge-diff-unreached="" /> : <Body {...props} />)}
</section>
)
}
/** One file of a diff, redrawn only when its props change. */
export const DiffFile = memo(DiffFileSection)