Files
deepseek-harness/packages/client/ui-primitives/src/DiffBlock.tsx
T
Yif 452013effa fix(client): web session and input UI polish
Batch of visually verified Web UI fixes: trigger-menu z-index over the
resize handle (#3228, #3229), input scrollbar offset, tool-row file
links and diff stats (#3230), @ menu crumb alignment, light-mode
divider, and @ menu flicker while typing (#3234) via
stale-while-revalidate — a refinement hit keeps the previous items and
highlight on screen until the new generation settles, so neither the
skeleton nor the first-row focus blinks per keystroke.
2026-08-28 11:16:00 +08:00

207 lines
7.4 KiB
TypeScript

import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { FoldToggle } from './FoldToggle.tsx'
import { writeClipboard } from './clipboard.ts'
import css from './DiffBlock.module.css'
/** Output lines shown before the height cap collapses the middle. */
export const DEFAULT_DIFF_MAX_LINES = 16
/**
* One file change in the form {@link DiffBlock} renders. It is declared here
* so this primitive stays independent of the tool contract.
*/
export interface DiffHunk {
/** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */
path: string
/** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */
oldText: string | null
/** Content after the change (the added side). */
newText: string
}
export interface DiffBlockProps {
/** One entry per applied hunk, in file order; empty renders nothing. */
diffs: DiffHunk[]
/** Localized chrome supplied by the owning render site. */
labels: DiffBlockLabels
/** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/** Localized chrome for {@link DiffBlock}. */
export interface DiffBlockLabels {
copy: string
copied: string
collapseAria: string
expandAria: (hidden: number) => string
collapse: string
expand: (hidden: number) => string
files: (count: number) => string
}
/** A single rendered body line and its role, so the height cap slices a flat list. */
interface DiffRow {
kind: 'path' | 'del' | 'add' | 'gap'
text: string
}
/** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */
/* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */
function assertNever(value: never): never {
throw new Error(`unreachable diff row kind: ${String(value)}`)
}
/** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */
const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
path: css.path,
del: css.del,
add: css.add,
gap: css.gap,
}
/**
* Total added/removed line counts across hunks — the same numbers the footer
* prints, exported so a summary row can show them without rebuilding the body.
* Every old-side line counts toward `removed` and every new-side line toward
* `added`, under {@link contentLines}'s terminator rule.
* @param diffs - the hunks to count.
* @returns the +/- totals.
*/
export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number } {
let added = 0
let removed = 0
for (const diff of diffs) {
if (diff.oldText !== null) removed += contentLines(diff.oldText).length
added += contentLines(diff.newText).length
}
return { added, removed }
}
/**
* Flatten the hunks into the body's rows plus the footer counts. A path header
* opens each new file; a same-file second hunk (a scattered edit) opens with a
* `⋯` gap instead of repeating the path. The +/- totals are
* {@link diffTotals}'s. The file count is of DISTINCT paths, matching the TUI
* diff card's footer, so two hunks in one file read as `1 file` on both front
* ends.
* @param diffs - the hunks to render.
* @returns the body rows, the +/- totals, and the distinct-file count.
*/
function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
const rows: DiffRow[] = []
const paths = new Set<string>()
let prevPath: string | undefined
for (const diff of diffs) {
paths.add(diff.path)
if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path })
else rows.push({ kind: 'gap', text: '⋯' })
prevPath = diff.path
if (diff.oldText !== null) {
for (const line of contentLines(diff.oldText)) {
rows.push({ kind: 'del', text: line })
}
}
for (const line of contentLines(diff.newText)) {
rows.push({ kind: 'add', text: line })
}
}
return { rows, ...diffTotals(diffs), files: paths.size }
}
/**
* Split a side's text into its content lines. Empty text is zero lines (a full
* deletion's `newText` or a create's absent `oldText` side draws nothing), and a
* single trailing newline is a line terminator rather than an extra empty line —
* the same terminator rule TerminalBlock applies to command output. An interior
* blank line (a genuine `\n\n`) survives.
* @param text - the removed or added side's text.
* @returns the content lines, without the terminating newline.
*/
function contentLines(text: string): string[] {
if (text === '') return []
const body = text.endsWith('\n') ? text.slice(0, -1) : text
return body.split('\n')
}
/**
* The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its
* content, exactly what the card shows. The removed and added blocks are the
* change; the path headers keep a multi-file copy attributable.
* @param rows - the flattened body rows.
* @returns the diff as plain text.
*/
function copyText(rows: DiffRow[]): string {
return rows.map((row) => {
switch (row.kind) {
case 'del': return `- ${row.text}`
case 'add': return `+ ${row.text}`
case 'path': return row.text
case 'gap': return row.text
/* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */
default: return assertNever(row.kind)
}
}).join('\n')
}
/**
* Render a file mutation as an inline diff surface.
* @param props - see {@link DiffBlockProps}.
* @returns the diff block element.
*/
export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
void writeClipboard(copyText(rows)).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, rows])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
if (rows.length === 0) return null
const hidden = rows.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock and the TUI transcript's collapsed
// card, so a body's head and tail slices agree across the front ends.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
const head = capped ? rows.slice(0, headLines) : rows
const tail = capped ? rows.slice(rows.length - tailLines) : []
return (
<div className={clsx(css.block, className)} data-diff="">
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? labels.copied : labels.copy}
</button>
<div className={css.body}>
{head.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
{hidden > 0 && (
<FoldToggle
className={css.expand}
expanded={expanded}
hidden={hidden}
labels={labels}
onToggle={onToggle}
/>
)}
{tail.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
</div>
<div className={css.footer}> +{added} -{removed} · {labels.files(files)}</div>
</div>
)
}