SearchBlock.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import { useCallback, useState, type ReactNode } from 'react'
  2. import clsx from 'clsx'
  3. import { headTailCap } from './head-tail-cap.ts'
  4. import { useCopyFeedback } from './use-copy-feedback.ts'
  5. import css from './SearchBlock.module.css'
  6. /**
  7. * Result rows shown before the height cap collapses the middle. Matches
  8. * {@link DEFAULT_TERMINAL_MAX_LINES} so a search card and a terminal card cut a
  9. * long result at the same place.
  10. */
  11. export const DEFAULT_SEARCH_MAX_LINES = 16
  12. /** One matched line inside a {@link SearchFileGroup}: its 1-based line number and text. */
  13. export interface SearchBlockLineMatch {
  14. /** 1-based line number of the match within its file. */
  15. lineNumber: number
  16. /** The matched line text, as the tool surfaced it. */
  17. line: string
  18. }
  19. /** One file's grouped matches, in first-seen file order. */
  20. export interface SearchFileGroup {
  21. /** The file the matches belong to (the display path). */
  22. path: string
  23. /** The file's matched lines, in output order. */
  24. matches: SearchBlockLineMatch[]
  25. }
  26. /** Fields both search shapes carry (the render site positions; this component draws). */
  27. interface SearchBlockCommon {
  28. /** Localized chrome supplied by the owning render site. */
  29. labels: SearchBlockLabels
  30. /**
  31. * Whether the tool capped the inline result: the shape carries only the
  32. * retained results, not every result the search found. The banner summary
  33. * folds the pre-cap `total` in (`显示 X / 共 N …`) so the card never presents a
  34. * capped result as complete.
  35. */
  36. truncated: boolean
  37. /** Total results the search found before capping (equals the retained count when not `truncated`). */
  38. total: number
  39. /** Height cap in rows before the middle collapses (default {@link DEFAULT_SEARCH_MAX_LINES}). */
  40. maxLines?: number | undefined
  41. /** Extra class merged onto the wrapper. */
  42. className?: string | undefined
  43. }
  44. /** Localized chrome for {@link SearchBlock}. */
  45. export interface SearchBlockLabels {
  46. pathsSummary: (shown: number, total: number, truncated: boolean) => string
  47. matchesSummary: (shown: number, total: number, files: number, truncated: boolean) => string
  48. copy: string
  49. copied: string
  50. noResults: string
  51. collapseAria: string
  52. expandAria: (hidden: number) => string
  53. collapse: string
  54. expand: (hidden: number) => string
  55. }
  56. /** Props for the grouped-matches (`grep`) shape. */
  57. export interface SearchMatchesBlockProps extends SearchBlockCommon {
  58. kind: 'matches'
  59. /** Matched lines grouped by file, in first-seen file order. */
  60. files: SearchFileGroup[]
  61. }
  62. /** Props for the flat-path (`glob`) shape. */
  63. export interface SearchPathsBlockProps extends SearchBlockCommon {
  64. kind: 'paths'
  65. /** The discovered paths, in the tool's result order (the retained page when `truncated`). */
  66. paths: string[]
  67. }
  68. /** {@link SearchBlock} props: one card, two `kind`-discriminated shapes. */
  69. export type SearchBlockProps = SearchMatchesBlockProps | SearchPathsBlockProps
  70. /**
  71. * One flattened render row. A matches card produces a `file` header row per
  72. * group followed by a `match` row per retained line while the group is
  73. * expanded; a paths card produces one `path` row per path. The height cap
  74. * counts these rows uniformly, so a file header costs one row exactly as a
  75. * match line or a path does.
  76. */
  77. type SearchRow =
  78. | { type: 'file'; path: string; count: number; index: number; collapsed: boolean }
  79. | { type: 'match'; lineNumber: number; line: string; key: string; fileIndex: number }
  80. | { type: 'path'; path: string }
  81. /**
  82. * The plain-text form the copy control writes: the whole structured result
  83. * regardless of the height cap or which groups are collapsed, so the clipboard
  84. * carries the result rather than what the card happens to be showing.
  85. * @param props - the card's props.
  86. * @returns the copyable text, or the empty string for an empty result.
  87. */
  88. function copyText(props: SearchBlockProps): string {
  89. if (props.kind === 'paths') return props.paths.join('\n')
  90. return props.files
  91. .map(file => [file.path, ...file.matches.map(m => `${m.lineNumber}: ${m.line}`)].join('\n'))
  92. .join('\n\n')
  93. }
  94. /**
  95. * Number of retained results the card holds: the matched-line count across all
  96. * files for a matches card, the path count for a paths card. This is the count
  97. * the banner summary reports against `total` when the result was capped.
  98. * @param props - the card's props.
  99. * @returns the retained result count.
  100. */
  101. function shownCount(props: SearchBlockProps): number {
  102. return props.kind === 'paths'
  103. ? props.paths.length
  104. : props.files.reduce((sum, file) => sum + file.matches.length, 0)
  105. }
  106. /**
  107. * The banner summary. When the search was capped it reads `显示 X / 共 N …` so
  108. * the retained count and the pre-cap total sit in one clause (mirroring the read
  109. * card's `显示 X / Y 行`); when it was not capped it is a plain count of what the
  110. * card holds. The unit — `处匹配 · K 个文件` for grep, `个路径` for glob — trails
  111. * the count either way.
  112. * @param props - the card's props.
  113. * @param shown - the retained result count from {@link shownCount}.
  114. * @param truncated - whether the search was capped.
  115. * @param total - the pre-cap total the truncation clause reports.
  116. * @returns the summary text.
  117. */
  118. function summaryText(props: SearchBlockProps, shown: number, truncated: boolean, total: number): string {
  119. return props.kind === 'paths'
  120. ? props.labels.pathsSummary(shown, total, truncated)
  121. : props.labels.matchesSummary(shown, total, props.files.length, truncated)
  122. }
  123. /**
  124. * Flatten a card's shape into its render rows, dropping a collapsed file
  125. * group's match rows.
  126. * @param props - the card's props.
  127. * @param collapsed - the set of collapsed file-group indices (matches only).
  128. * @returns the flattened rows in output order.
  129. */
  130. function toRows(props: SearchBlockProps, collapsed: ReadonlySet<number>): SearchRow[] {
  131. if (props.kind === 'paths') return props.paths.map((path): SearchRow => ({ type: 'path', path }))
  132. const rows: SearchRow[] = []
  133. props.files.forEach((file, index) => {
  134. const isCollapsed = collapsed.has(index)
  135. rows.push({ type: 'file', path: file.path, count: file.matches.length, index, collapsed: isCollapsed })
  136. if (isCollapsed) return
  137. for (const match of file.matches) {
  138. rows.push({ type: 'match', lineNumber: match.lineNumber, line: match.line, key: `${index}:${match.lineNumber}`, fileIndex: index })
  139. }
  140. })
  141. return rows
  142. }
  143. /**
  144. * A stable React key for a flattened render row: the group-scoped match key, a
  145. * file-index-scoped header key, or the path itself. Rows of different types
  146. * never collide, since each key carries its type prefix or the group index.
  147. * @param row - the flattened row.
  148. * @returns the key.
  149. */
  150. function rowKey(row: SearchRow): string {
  151. switch (row.type) {
  152. case 'match': return `match:${row.key}`
  153. case 'file': return `file:${row.index}`
  154. case 'path': return `path:${row.path}`
  155. }
  156. }
  157. /**
  158. * Render a completed search as a grouped-matches or flat-path card.
  159. * @param props - see {@link SearchBlockProps}.
  160. * @returns the search block element.
  161. */
  162. export function SearchBlock(props: SearchBlockProps) {
  163. const { truncated, total, maxLines = DEFAULT_SEARCH_MAX_LINES, className } = props
  164. const [expanded, setExpanded] = useState(false)
  165. const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
  166. // `props` is a fresh object each render, so memoizing on it never hits; the
  167. // flatten is cheap, so it runs inline keyed on the collapse set instead.
  168. const rows = toRows(props, collapsed)
  169. const shown = shownCount(props)
  170. const empty = rows.length === 0
  171. const { copied, onCopy } = useCopyFeedback(copyText(props))
  172. const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
  173. const toggleFile = useCallback((index: number) => {
  174. setCollapsed((prev) => {
  175. const next = new Set(prev)
  176. if (next.has(index)) next.delete(index)
  177. else next.add(index)
  178. return next
  179. })
  180. }, [])
  181. const { hidden, capped, headLines, tailLines } = headTailCap(rows.length, maxLines, expanded)
  182. const head = capped ? rows.slice(0, headLines) : rows
  183. const naturalTail = capped ? rows.slice(rows.length - tailLines) : []
  184. // When the tail slice begins inside a file's matches, its own header sits
  185. // above the cut and is not shown, so those rows could not be attributed to a
  186. // file. Restore the owning header at the top of the tail — unless the head
  187. // slice already carries it (a single large file), where it would duplicate.
  188. const tailLead = naturalTail[0]
  189. const tailHeader = tailLead?.type === 'match'
  190. && !head.some(row => row.type === 'file' && row.index === tailLead.fileIndex)
  191. ? rows.find((row): row is Extract<SearchRow, { type: 'file' }> =>
  192. row.type === 'file' && row.index === tailLead.fileIndex)
  193. : undefined
  194. // The restored header is itself a row. Left extra it would push the card to
  195. // maxLines + 1 and overstate `hidden` by one, so it consumes a tail slot: drop
  196. // the tail's first row (the match whose header this is) for it. Visible rows
  197. // hold at maxLines and `hidden` stays exact; the dropped match joins the
  198. // hidden middle.
  199. const tail = tailHeader === undefined ? naturalTail : naturalTail.slice(1)
  200. const renderRow = (row: SearchRow): ReactNode => {
  201. if (row.type === 'path') return <div className={css.line}>{row.path}</div>
  202. if (row.type === 'match') {
  203. return (
  204. <div className={css.line}>
  205. <span className={css.lineNumber}>{row.lineNumber}: </span>
  206. {row.line}
  207. </div>
  208. )
  209. }
  210. return (
  211. <button
  212. type="button"
  213. className={css.fileHeader}
  214. aria-expanded={!row.collapsed}
  215. onClick={() => { toggleFile(row.index) }}
  216. >
  217. <span className={css.filePath}>{row.path}</span>
  218. <span className={css.fileCount}>{row.count}</span>
  219. </button>
  220. )
  221. }
  222. return (
  223. <div className={clsx(css.block, className)} data-search={props.kind}>
  224. <div className={css.header}>
  225. <span className={css.summary}>{summaryText(props, shown, truncated, total)}</span>
  226. {!empty && (
  227. <button type="button" className={css.copyButton} onClick={onCopy}>
  228. {copied ? props.labels.copied : props.labels.copy}
  229. </button>
  230. )}
  231. </div>
  232. {empty
  233. ? <div className={css.empty}>{props.labels.noResults}</div>
  234. : (
  235. <div className={css.body}>
  236. {head.map(row => (
  237. <div key={rowKey(row)}>{renderRow(row)}</div>
  238. ))}
  239. {hidden > 0 && (
  240. <button
  241. type="button"
  242. className={css.expand}
  243. aria-expanded={expanded}
  244. aria-label={expanded ? props.labels.collapseAria : props.labels.expandAria(hidden)}
  245. onClick={onToggle}
  246. >
  247. {expanded ? props.labels.collapse : props.labels.expand(hidden)}
  248. </button>
  249. )}
  250. {tailHeader !== undefined && (
  251. <div key={`tailHeader:${rowKey(tailHeader)}`}>{renderRow(tailHeader)}</div>
  252. )}
  253. {tail.map(row => (
  254. <div key={rowKey(row)}>{renderRow(row)}</div>
  255. ))}
  256. </div>
  257. )}
  258. </div>
  259. )
  260. }