ReadBlock.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. // ReadBlock: the file surface for a read tool result — a banner (label +
  2. // language + a "showing N of M" note when the read is a window + a copy
  3. // control) over line-numbered, syntax-highlighted source. Each row carries the
  4. // file's OWN line number in a gutter, so a windowed read past an offset keeps
  5. // its file numbering rather than re-counting from 1. Highlighting reuses the
  6. // CodeBlock shiki path (highlight.ts) at the per-line granularity a gutter
  7. // needs; an unknown or absent language renders plain monospace. Long content is
  8. // height-capped with the same head/tail arithmetic TerminalBlock uses, so the
  9. // two cards collapse a long body at the same place. Colors resolve through
  10. // --shiki-*/--dsw-* tokens.
  11. import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
  12. import clsx from 'clsx'
  13. import { writeClipboard } from './clipboard.ts'
  14. import {
  15. grammarLoadCount,
  16. highlightLines,
  17. subscribeGrammarLoaded,
  18. type HighlightSpan,
  19. } from './markdown/highlight.ts'
  20. import css from './ReadBlock.module.css'
  21. /**
  22. * Content lines shown before the height cap collapses the middle. Matches
  23. * TerminalBlock's default so a long read and a long command output cut at the
  24. * same place in the same flow.
  25. */
  26. export const DEFAULT_READ_MAX_LINES = 16
  27. /** One line of the read window: its file line number and its text (no trailing newline). */
  28. export interface ReadBlockLine {
  29. /** 1-based line number in the file (a window past an offset keeps the file's own numbering). */
  30. number: number
  31. /** The line's text, already truncated to the read tool's per-line cap. */
  32. text: string
  33. }
  34. export interface ReadBlockProps {
  35. /** Banner label (the file path, or a tool-supplied replacement title); omitted draws no label. */
  36. label?: string | undefined
  37. /** The returned window's lines, in file order, each keeping its file line number. */
  38. lines: readonly ReadBlockLine[]
  39. /** Exact total line count in the file, for the "showing N of M" note when the read is a window. */
  40. totalLines: number
  41. /** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */
  42. lang?: string | undefined
  43. /** Height cap in content lines before the middle collapses (default {@link DEFAULT_READ_MAX_LINES}). */
  44. maxLines?: number | undefined
  45. /** Extra class merged onto the wrapper (callers position; this component draws). */
  46. className?: string | undefined
  47. }
  48. /**
  49. * Render one line's highlighted runs. The css-variables theme colors every run,
  50. * so each run is a styled span; a line with no highlighting at all takes the
  51. * bare-text path in the caller instead (an unknown or absent language).
  52. * @param spans - the line's styled runs.
  53. * @returns the line's children.
  54. */
  55. function renderSpans(spans: readonly HighlightSpan[]) {
  56. return spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)
  57. }
  58. /**
  59. * Render a read tool result as a line-numbered, optionally syntax-highlighted
  60. * file view.
  61. * @param props - see {@link ReadBlockProps}.
  62. * @returns the read block element.
  63. */
  64. export function ReadBlock({
  65. label,
  66. lines,
  67. totalLines,
  68. lang,
  69. maxLines = DEFAULT_READ_MAX_LINES,
  70. className,
  71. }: ReadBlockProps) {
  72. // The raw text the copy control writes and the highlighter tokenizes: the
  73. // window's lines joined by newlines, without the file numbers or any chrome.
  74. // Highlighting the whole window in one call (not line by line) keeps grammar
  75. // context across lines — a multi-line string or comment stays one construct.
  76. const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
  77. // Re-render when a lazy grammar finishes loading, so a read card that showed
  78. // plain text while its language's grammar imported picks up highlighting. The
  79. // snapshot value is opaque; only its change across renders drives the memo.
  80. const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
  81. // Per-line highlighted runs aligned 1:1 with `lines`; undefined for an
  82. // unknown/absent (or not-yet-loaded) language, when every line renders as
  83. // bare text.
  84. const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded])
  85. const [expanded, setExpanded] = useState(false)
  86. const [copied, setCopied] = useState(false)
  87. const onCopy = useCallback(() => {
  88. if (copied) return
  89. // The window's raw text, never the rendered tree: the gutter numbers and the
  90. // banner are chrome the file does not contain.
  91. void writeClipboard(raw).then((ok) => {
  92. if (!ok) return
  93. setCopied(true)
  94. window.setTimeout(() => { setCopied(false) }, 1000)
  95. })
  96. }, [copied, raw])
  97. const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
  98. const hidden = lines.length - maxLines
  99. const capped = hidden > 0 && !expanded
  100. // Same split arithmetic as TerminalBlock's height cap, so a long read and a
  101. // long command output slice their head and tail at the same place.
  102. const headLines = Math.ceil(maxLines / 2)
  103. const tailLines = maxLines - headLines
  104. // A read is a window when its returned lines are fewer than the file's total;
  105. // the note states that so a reader is not misled that the file ends here.
  106. const windowed = lines.length < totalLines
  107. /**
  108. * Render a slice of the line array as gutter-numbered rows.
  109. * @param slice - the lines to draw, each with its aligned run array.
  110. * @returns the row elements.
  111. */
  112. const rows = (slice: readonly (readonly [ReadBlockLine, readonly HighlightSpan[] | undefined])[]) =>
  113. slice.map(([line, spans]) => (
  114. <div key={line.number} className={css.line}>
  115. <span className={css.gutter} aria-hidden>{line.number}</span>
  116. <span className={css.content}>{spans === undefined ? line.text : renderSpans(spans)}</span>
  117. </div>
  118. ))
  119. // Pair each line with its aligned run array up front, so head/tail slicing
  120. // keeps the two in step without re-indexing.
  121. const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] =>
  122. [line, highlighted?.[index]])
  123. return (
  124. <div className={clsx(css.block, className)} data-read="">
  125. <div className={css.banner}>
  126. <div className={css.label}>{label ?? ''}</div>
  127. <div className={css.action}>
  128. {windowed && (
  129. <span className={css.count}>{`显示 ${lines.length} / ${totalLines} 行`}</span>
  130. )}
  131. <span className={css.lang}>{lang ?? ''}</span>
  132. {/* Hide copy on an empty window, matching TerminalBlock's empty-output
  133. guard: a successful read of an empty file returns lines: [] with
  134. card:'read', so this branch is reachable, and copying then would
  135. wipe the clipboard with an empty string. */}
  136. {lines.length > 0 && (
  137. <button type="button" className={css.copyButton} onClick={onCopy}>
  138. {copied ? '复制成功' : '复制'}
  139. </button>
  140. )}
  141. </div>
  142. </div>
  143. <div className={css.body}>
  144. {rows(capped ? paired.slice(0, headLines) : paired)}
  145. {hidden > 0 && (
  146. <button
  147. type="button"
  148. className={css.expand}
  149. aria-expanded={expanded}
  150. aria-label={expanded ? '收起内容' : `展开其余 ${hidden} 行`}
  151. onClick={onToggle}
  152. >
  153. {expanded ? '收起' : `… 其余 ${hidden} 行`}
  154. </button>
  155. )}
  156. {capped && rows(paired.slice(paired.length - tailLines))}
  157. </div>
  158. </div>
  159. )
  160. }