DiffBlock.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. // DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy
  2. // control over one or more per-file hunks, each a bold path header followed by
  3. // the removed block (`-`, error color) and the added block (`+`, success
  4. // color), with a dim `└ +A -R · N file(s)` footer. Unlike the TUI's exact
  5. // changed-row comparison, this block renders the old and new sides in full.
  6. // Both front ends share the line-terminator rule and distinct-path file count.
  7. // Output never soft-wraps — an aligned source line keeps its indentation and
  8. // scrolls horizontally instead of folding. Colors resolve through --dsw-*
  9. // tokens; geometry mirrors CodeBlock.
  10. import { useCallback, useMemo, useState } from 'react'
  11. import clsx from 'clsx'
  12. import { writeClipboard } from './clipboard.ts'
  13. import css from './DiffBlock.module.css'
  14. /**
  15. * Output lines shown before the height cap collapses the middle. Matches
  16. * {@link DEFAULT_TERMINAL_MAX_LINES} so a diff card and a terminal card cut a
  17. * long body at the same place.
  18. */
  19. export const DEFAULT_DIFF_MAX_LINES = 16
  20. /**
  21. * One file's change, in the shape {@link DiffBlock} draws. Structurally the
  22. * render-intent contract's `FileDiff`, redeclared here so this primitive stays
  23. * free of the tool contract (the terminal card's decoupling, applied to diffs).
  24. */
  25. export interface DiffHunk {
  26. /** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */
  27. path: string
  28. /** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */
  29. oldText: string | null
  30. /** Content after the change (the added side). */
  31. newText: string
  32. }
  33. export interface DiffBlockProps {
  34. /** One entry per applied hunk, in file order; empty renders nothing. */
  35. diffs: DiffHunk[]
  36. /** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */
  37. maxLines?: number | undefined
  38. /** Extra class merged onto the wrapper (callers position; this component draws). */
  39. className?: string | undefined
  40. }
  41. /** A single rendered body line and its role, so the height cap slices a flat list. */
  42. interface DiffRow {
  43. kind: 'path' | 'del' | 'add' | 'gap'
  44. text: string
  45. }
  46. /** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */
  47. /* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */
  48. function assertNever(value: never): never {
  49. throw new Error(`unreachable diff row kind: ${String(value)}`)
  50. }
  51. /** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */
  52. const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
  53. path: css.path,
  54. del: css.del,
  55. add: css.add,
  56. gap: css.gap,
  57. }
  58. /**
  59. * Flatten the hunks into the body's rows plus the footer counts. A path header
  60. * opens each new file; a same-file second hunk (a scattered edit) opens with a
  61. * `⋯` gap instead of repeating the path. Every old-side line counts toward
  62. * `removed` and every new-side line toward `added`. The file count is of
  63. * DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file
  64. * read as `1 file` on both front ends.
  65. * @param diffs - the hunks to render.
  66. * @returns the body rows, the +/- totals, and the distinct-file count.
  67. */
  68. function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
  69. const rows: DiffRow[] = []
  70. const paths = new Set<string>()
  71. let added = 0
  72. let removed = 0
  73. let prevPath: string | undefined
  74. for (const diff of diffs) {
  75. paths.add(diff.path)
  76. if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path })
  77. else rows.push({ kind: 'gap', text: '⋯' })
  78. prevPath = diff.path
  79. if (diff.oldText !== null) {
  80. for (const line of contentLines(diff.oldText)) {
  81. rows.push({ kind: 'del', text: line })
  82. removed++
  83. }
  84. }
  85. for (const line of contentLines(diff.newText)) {
  86. rows.push({ kind: 'add', text: line })
  87. added++
  88. }
  89. }
  90. return { rows, added, removed, files: paths.size }
  91. }
  92. /**
  93. * Split a side's text into its content lines. Empty text is zero lines (a full
  94. * deletion's `newText` or a create's absent `oldText` side draws nothing), and a
  95. * single trailing newline is a line terminator rather than an extra empty line —
  96. * the same terminator rule TerminalBlock applies to command output. An interior
  97. * blank line (a genuine `\n\n`) survives.
  98. * @param text - the removed or added side's text.
  99. * @returns the content lines, without the terminating newline.
  100. */
  101. function contentLines(text: string): string[] {
  102. if (text === '') return []
  103. const body = text.endsWith('\n') ? text.slice(0, -1) : text
  104. return body.split('\n')
  105. }
  106. /**
  107. * The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its
  108. * content, exactly what the card shows. The removed and added blocks are the
  109. * change; the path headers keep a multi-file copy attributable.
  110. * @param rows - the flattened body rows.
  111. * @returns the diff as plain text.
  112. */
  113. function copyText(rows: DiffRow[]): string {
  114. return rows.map((row) => {
  115. switch (row.kind) {
  116. case 'del': return `- ${row.text}`
  117. case 'add': return `+ ${row.text}`
  118. case 'path': return row.text
  119. case 'gap': return row.text
  120. /* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */
  121. default: return assertNever(row.kind)
  122. }
  123. }).join('\n')
  124. }
  125. /**
  126. * Render a file mutation as an inline diff surface.
  127. * @param props - see {@link DiffBlockProps}.
  128. * @returns the diff block element.
  129. */
  130. export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
  131. const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
  132. const [expanded, setExpanded] = useState(false)
  133. const [copied, setCopied] = useState(false)
  134. const onCopy = useCallback(() => {
  135. if (copied) return
  136. void writeClipboard(copyText(rows)).then((ok) => {
  137. if (!ok) return
  138. setCopied(true)
  139. window.setTimeout(() => { setCopied(false) }, 1000)
  140. })
  141. }, [copied, rows])
  142. const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
  143. if (rows.length === 0) return null
  144. const hidden = rows.length - maxLines
  145. const capped = hidden > 0 && !expanded
  146. // Same split arithmetic as TerminalBlock and the TUI transcript's collapsed
  147. // card, so a body's head and tail slices agree across the front ends.
  148. const headLines = Math.ceil(maxLines / 2)
  149. const tailLines = maxLines - headLines
  150. const head = capped ? rows.slice(0, headLines) : rows
  151. const tail = capped ? rows.slice(rows.length - tailLines) : []
  152. return (
  153. <div className={clsx(css.block, className)} data-diff="">
  154. <button type="button" className={css.copyButton} onClick={onCopy}>
  155. {copied ? '复制成功' : '复制'}
  156. </button>
  157. <div className={css.body}>
  158. {head.map((row, index) => (
  159. <div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
  160. ))}
  161. {hidden > 0 && (
  162. <button
  163. type="button"
  164. className={css.expand}
  165. aria-expanded={expanded}
  166. aria-label={expanded ? '收起差异' : `展开其余 ${hidden} 行差异`}
  167. onClick={onToggle}
  168. >
  169. {expanded ? '收起' : `… 其余 ${hidden} 行`}
  170. </button>
  171. )}
  172. {tail.map((row, index) => (
  173. <div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
  174. ))}
  175. </div>
  176. <div className={css.footer}>└ +{added} -{removed} · {files} file{files === 1 ? '' : 's'}</div>
  177. </div>
  178. )
  179. }