返回 DeepSeek-Reasonix
DiffBlock.tsx
根目录 / desktop / frontend / src / components / harness-chat / DiffBlock.tsx
1 // Ported from DeepSeek Harness c291e7961a (MIT).
2 import { useCallback, useMemo, useState } from 'react'
3 const clsx = (...values: Array<string | false | undefined>) => values.filter(Boolean).join(' ')
4 import { FoldToggle } from './FoldToggle.tsx'
5 import { writeClipboard } from './clipboard.ts'
6 import css from './DiffBlock.styles'
7
8 /** Output lines shown before the height cap collapses the middle. */
9 export const DEFAULT_DIFF_MAX_LINES = 16
10
11 /**
12 * One file change in the form {@link DiffBlock} renders. It is declared here
13 * so this primitive stays independent of the tool contract.
14 */
15 export interface DiffHunk {
16 /** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */
17 path: string
18 /** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */
19 oldText: string | null
20 /** Content after the change (the added side). */
21 newText: string
22 }
23
24 export interface DiffBlockProps {
25 /** One entry per applied hunk, in file order; empty renders nothing. */
26 diffs: DiffHunk[]
27 /** Localized chrome supplied by the owning render site. */
28 labels: DiffBlockLabels
29 /** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */
30 maxLines?: number | undefined
31 /** Extra class merged onto the wrapper (callers position; this component draws). */
32 className?: string | undefined
33 }
34
35 /** Localized chrome for {@link DiffBlock}. */
36 export interface DiffBlockLabels {
37 copy: string
38 copied: string
39 collapseAria: string
40 expandAria: (hidden: number) => string
41 collapse: string
42 expand: (hidden: number) => string
43 files: (count: number) => string
44 }
45
46 /** A single rendered body line and its role, so the height cap slices a flat list. */
47 interface DiffRow {
48 kind: 'path' | 'del' | 'add' | 'gap'
49 text: string
50 }
51
52 /** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */
53 /* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */
54 function assertNever(value: never): never {
55 throw new Error(`unreachable diff row kind: ${String(value)}`)
56 }
57
58 /** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */
59 const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
60 path: css.path,
61 del: css.del,
62 add: css.add,
63 gap: css.gap,
64 }
65
66 /**
67 * Total added/removed line counts across hunks — the same numbers the footer
68 * prints, exported so a summary row can show them without rebuilding the body.
69 * Every old-side line counts toward `removed` and every new-side line toward
70 * `added`, under {@link contentLines}'s terminator rule.
71 * @param diffs - the hunks to count.
72 * @returns the +/- totals.
73 */
74 export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number } {
75 let added = 0
76 let removed = 0
77 for (const diff of diffs) {
78 if (diff.oldText !== null) removed += contentLines(diff.oldText).length
79 added += contentLines(diff.newText).length
80 }
81 return { added, removed }
82 }
83
84 /**
85 * Flatten the hunks into the body's rows plus the footer counts. A path header
86 * opens each new file; a same-file second hunk (a scattered edit) opens with a
87 * `⋯` gap instead of repeating the path. The +/- totals are
88 * {@link diffTotals}'s. The file count is of DISTINCT paths, matching the TUI
89 * diff card's footer, so two hunks in one file read as `1 file` on both front
90 * ends.
91 * @param diffs - the hunks to render.
92 * @returns the body rows, the +/- totals, and the distinct-file count.
93 */
94 function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
95 const rows: DiffRow[] = []
96 const paths = new Set<string>()
97 let prevPath: string | undefined
98 for (const diff of diffs) {
99 paths.add(diff.path)
100 if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path })
101 else rows.push({ kind: 'gap', text: '⋯' })
102 prevPath = diff.path
103 if (diff.oldText !== null) {
104 for (const line of contentLines(diff.oldText)) {
105 rows.push({ kind: 'del', text: line })
106 }
107 }
108 for (const line of contentLines(diff.newText)) {
109 rows.push({ kind: 'add', text: line })
110 }
111 }
112 return { rows, ...diffTotals(diffs), files: paths.size }
113 }
114
115 /**
116 * Split a side's text into its content lines. Empty text is zero lines (a full
117 * deletion's `newText` or a create's absent `oldText` side draws nothing), and a
118 * single trailing newline is a line terminator rather than an extra empty line —
119 * the same terminator rule TerminalBlock applies to command output. An interior
120 * blank line (a genuine `\n\n`) survives.
121 * @param text - the removed or added side's text.
122 * @returns the content lines, without the terminating newline.
123 */
124 function contentLines(text: string): string[] {
125 if (text === '') return []
126 const body = text.endsWith('\n') ? text.slice(0, -1) : text
127 return body.split('\n')
128 }
129
130 /**
131 * The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its
132 * content, exactly what the card shows. The removed and added blocks are the
133 * change; the path headers keep a multi-file copy attributable.
134 * @param rows - the flattened body rows.
135 * @returns the diff as plain text.
136 */
137 function copyText(rows: DiffRow[]): string {
138 return rows.map((row) => {
139 switch (row.kind) {
140 case 'del': return `- ${row.text}`
141 case 'add': return `+ ${row.text}`
142 case 'path': return row.text
143 case 'gap': return row.text
144 /* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */
145 default: return assertNever(row.kind)
146 }
147 }).join('\n')
148 }
149
150 /**
151 * Render a file mutation as an inline diff surface.
152 * @param props - see {@link DiffBlockProps}.
153 * @returns the diff block element.
154 */
155 export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
156 const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
157 const [expanded, setExpanded] = useState(false)
158 const [copied, setCopied] = useState(false)
159
160 const onCopy = useCallback(() => {
161 if (copied) return
162 void writeClipboard(copyText(rows)).then((ok) => {
163 if (!ok) return
164 setCopied(true)
165 window.setTimeout(() => { setCopied(false) }, 1000)
166 })
167 }, [copied, rows])
168
169 const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
170
171 if (rows.length === 0) return null
172
173 const hidden = rows.length - maxLines
174 const capped = hidden > 0 && !expanded
175 // Same split arithmetic as TerminalBlock and the TUI transcript's collapsed
176 // card, so a body's head and tail slices agree across the front ends.
177 const headLines = Math.ceil(maxLines / 2)
178 const tailLines = maxLines - headLines
179 const head = capped ? rows.slice(0, headLines) : rows
180 const tail = capped ? rows.slice(rows.length - tailLines) : []
181
182 return (
183 <div className={clsx(css.block, className)} data-diff="">
184 <button type="button" className={css.copyButton} onClick={onCopy}>
185 {copied ? labels.copied : labels.copy}
186 </button>
187 <div className={css.body}>
188 {head.map((row, index) => (
189 <div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
190 ))}
191 {hidden > 0 && (
192 <FoldToggle
193 className={css.expand}
194 expanded={expanded}
195 hidden={hidden}
196 labels={labels}
197 onToggle={onToggle}
198 />
199 )}
200 {tail.map((row, index) => (
201 <div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
202 ))}
203 </div>
204 <div className={css.footer}>└ +{added} -{removed} · {labels.files(files)}</div>
205 </div>
206 )
207 }
208
208 lines Plain Text