/** * 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. */ import { useCallback, 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, 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 { segmentsOf, splitRows, type DiffRow, 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 css from './ChangesTab.module.css' type Translate = PropsLocale['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 /** Props of one file section. */ export interface DiffFileProps { /** Whether the owning tab is visible and may show temporary menus. */ readonly visible: boolean /** Comment destinations and the source being reviewed. */ readonly review?: ReviewActions /** The file and its hunks. */ readonly file: ForgeDiffFile /** Whether the section shows its lines. */ readonly expanded: boolean /** Expand or collapse the section. */ readonly onToggle: () => void /** 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 /** Show a large file anyway. */ readonly onReveal: () => void /** The file's new side, which supplies collapsed unchanged lines. */ readonly text: DiffFileText /** Indexes of the expanded gaps. */ readonly open: ReadonlySet /** Expand one gap. */ readonly onExpand: (index: number) => void /** 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 /** Rows of the file's menu. */ readonly actions: readonly DiffFileAction[] /** Receives the section element, which scrolling to the file targets. */ readonly sectionRef: (element: HTMLElement | null) => void /** 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 readonly t: Translate }): ReactNode { if (file.binary) return {t('diff.binaryLabel')} return ( {`+${file.additions.toLocaleString('en-US')}`} {`−${file.deletions.toLocaleString('en-US')}`} ) } const SIGN: Record = { add: '+', del: '-', context: '' } /** Highlighted tokens of one side's lines by line number, or undefined while the grammar is unavailable. */ type SideSpans = ReadonlyMap | undefined /** A segment ready to draw: collapsed lines, or rows with the tokens of each side. */ type Prepared = | { readonly kind: 'gap'; readonly index: number; readonly count: number | null } | { readonly kind: 'rows'; readonly rows: readonly DiffRow[]; readonly old: SideSpans; readonly new: SideSpans } /** Highlight one side of a run of rows as one fragment, so tokens spanning lines keep their state. */ function sideSpans(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): SideSpans { const numbered: { readonly no: number; readonly text: string }[] = [] for (const row of rows) { const no = row[side] if (no !== null) numbered.push({ no, text: row.text }) } if (numbered.length === 0) return new Map() const highlighted = highlighter(numbered.map(line => line.text).join('\n')) if (highlighted === undefined) return undefined // Shiki omits a final empty token row, so a final empty line gets no tokens. return new Map(numbered.map((line, index) => [line.no, highlighted[index] ?? []])) } function Code({ text, spans, old, next }: { readonly text: string readonly spans: readonly HighlightSpan[] | undefined readonly old: number | null readonly next: number | null }): ReactNode { return ( {spans === undefined ? text : spans.map((span, index) => {span.text})} ) } /** 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 hunks of one file with their collapsed and expanded unchanged lines. */ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comments, t }: Pick & { 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 highlighter = useCodeHighlighter(languageForPath(file.path)) const [selection, setSelection] = useState(null) const root = useRef(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 prepared = useMemo(() => segmentsOf(file, lines, open).map((segment): Prepared => segment.kind === 'gap' ? segment : { ...segment, old: sideSpans(segment.rows, 'old', highlighter), new: sideSpans(segment.rows, 'new', highlighter) }), [file, lines, open, highlighter]) const choose = (side: 'L' | 'R', line: number, extend: boolean): void => { if (review === undefined || editing) return const from = extend && origin.current?.side === side ? origin.current.line : line const comment = selectReviewLines(file, side, from, line) if (comment === undefined) return if (!extend || origin.current?.side !== side) origin.current = { side, line } setSelection({ source: review.source, comment }) } 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 {line ?? ''} } const plus = !selecting && !editing && selection?.comment.side === side && selection.comment.endLine === line return { if ((dragging.current === 'gutter' || dragging.current === 'add') && origin.current?.side === side && event.buttons === 1) choose(side, line, true) }}> {plus && ( )} {/* Keyboard and assistive technology reach the same editor through the line button and the labeled plus above. */} {hover && !plus && !editing && ( )} } const cell = (value: SplitCell | null, spans: SideSpans, side: 'L' | 'R'): ReactNode => value === null ? : {number(side, value.no, true)} const rows: ReactNode[] = [] let inserted = false const editor = review === undefined || selection === null || !editing ? null : { 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() 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 prepared.entries()) { if (segment.kind === 'gap') { rows.push() } else if (columns) { for (const [index, pair] of splitRows(segment.rows).entries()) { rows.push(
{cell(pair.left, segment.old, 'L')}{cell(pair.right, segment.new, 'R')}
) 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()) { rows.push(
{singleSide !== 'R' && number('L', row.old, row.kind === 'del')}{singleSide !== 'L' && number('R', row.new, row.kind !== 'del')}{SIGN[row.kind]}
) 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() } if (!inserted) rows.push(editor) return
{ 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}
} function Body(props: DiffFileProps): ReactNode { const { file, large, revealed, onReveal, review, t } = props const comments = useFileReview(review, file.path) const records = review === undefined ? null : comments.threads.map(thread => ) if (file.binary) return <>

{t('diff.binary')}

{records} if (file.oversized) return <>

{t('diff.fileTooLarge')}

{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 ( <>{records} ) } return } /** File actions live only while their owning tab is visible. */ function FileMenu({ name, actions, t }: { readonly name: string readonly actions: readonly DiffFileAction[] readonly t: Translate }): ReactNode { const [menuOpen, setMenuOpen] = useState(false) return ( { setMenuOpen(false) }} anchor={( )} > {actions.map(action => ( { setMenuOpen(false) action.run() }} > {t(action.label)} ))} ) } /** * Render one file of a diff. * @param props - the file, its section and gap state, layout choices, menu actions, and copy. * @returns the file section. */ export function DiffFile(props: DiffFileProps): ReactNode { const { file, expanded, onToggle, badge, tooltip, actions, sectionRef, t } = props const name = file.path.slice(file.path.lastIndexOf('/') + 1) return (
{badge !== '' && {badge}} {!file.binary && } {props.visible && }
{expanded && }
) }