/** * 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 { useMemo, useState, type ReactNode } from 'react' import clsx from 'clsx' import { FileTypeIcon, IconChevronDownOutlineRegular, IconChevronsUpDownOutlineRegular, IconEllipsisOutlineRegular, 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 { 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 { /** 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 }: { readonly text: string; readonly spans: readonly HighlightSpan[] | undefined }): ReactNode { return ( {spans === undefined ? text : spans.map((span, index) => {span.text})} ) } function Cell({ cell, spans }: { readonly cell: SplitCell | null; readonly spans: SideSpans }): ReactNode { if (cell === null) return return ( {cell.no} ) } /** 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, t }: Pick): ReactNode { const lines = typeof text === 'object' ? text : undefined const columns = split && file.additions > 0 && file.deletions > 0 const highlighter = useCodeHighlighter(languageForPath(file.path)) 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]) return (
{prepared.map((segment, at) => { if (segment.kind === 'gap') { return ( ) } if (columns) { return splitRows(segment.rows).map((pair, index) => (
)) } return segment.rows.map((row, index) => (
{row.old ?? ''} {row.new ?? ''} {SIGN[row.kind]}
)) })}
) } function Body(props: DiffFileProps): ReactNode { const { file, large, revealed, onReveal, t } = props if (file.binary) return

{t('diff.binary')}

if (file.oversized) return

{t('diff.fileTooLarge')}

if (file.hunks.length === 0) return null if (large && !revealed) { // A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines. return ( ) } return } /** * 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 [menuOpen, setMenuOpen] = useState(false) const name = file.path.slice(file.path.lastIndexOf('/') + 1) return (
{badge !== '' && {badge}} {!file.binary && } { setMenuOpen(false) }} anchor={( )} > {actions.map(action => ( { setMenuOpen(false) action.run() }} > {t(action.label)} ))}
{expanded && }
) }