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.
592 lines
28 KiB
TypeScript
592 lines
28 KiB
TypeScript
/**
|
||
* 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)
|