// SearchBlock: the search surface for a completed content or path search — a // banner (result summary that folds the pre-cap total in when the tool capped // the result, plus a copy control), then either grep matches grouped by file // (each file a bold // path header with its `lineNumber: line` rows, the group collapsible) or a // flat glob path list. Both shapes flatten to one list of rows the height cap // slices head/tail over, and neither soft-wraps: a long match line or path // scrolls horizontally instead of folding. Geometry mirrors CodeBlock and // TerminalBlock so a search card reads as one family with them. import { useCallback, useState, type ReactNode } from 'react' import clsx from 'clsx' import { headTailCap } from './head-tail-cap.ts' import { useCopyFeedback } from './use-copy-feedback.ts' import css from './SearchBlock.module.css' /** * Result rows shown before the height cap collapses the middle. Matches * {@link DEFAULT_TERMINAL_MAX_LINES} so a search card and a terminal card cut a * long result at the same place. */ export const DEFAULT_SEARCH_MAX_LINES = 16 /** One matched line inside a {@link SearchFileGroup}: its 1-based line number and text. */ export interface SearchBlockLineMatch { /** 1-based line number of the match within its file. */ lineNumber: number /** The matched line text, as the tool surfaced it. */ line: string } /** One file's grouped matches, in first-seen file order. */ export interface SearchFileGroup { /** The file the matches belong to (the display path). */ path: string /** The file's matched lines, in output order. */ matches: SearchBlockLineMatch[] } /** Fields both search shapes carry (the render site positions; this component draws). */ interface SearchBlockCommon { /** * Whether the tool capped the inline result: the shape carries only the * retained results, not every result the search found. The banner summary * folds the pre-cap `total` in (`显示 X / 共 N …`) so the card never presents a * capped result as complete. */ truncated: boolean /** Total results the search found before capping (equals the retained count when not `truncated`). */ total: number /** Height cap in rows before the middle collapses (default {@link DEFAULT_SEARCH_MAX_LINES}). */ maxLines?: number | undefined /** Extra class merged onto the wrapper. */ className?: string | undefined } /** Props for the grouped-matches (`grep`) shape. */ export interface SearchMatchesBlockProps extends SearchBlockCommon { kind: 'matches' /** Matched lines grouped by file, in first-seen file order. */ files: SearchFileGroup[] } /** Props for the flat-path (`glob`) shape. */ export interface SearchPathsBlockProps extends SearchBlockCommon { kind: 'paths' /** The discovered paths, in the tool's result order (the retained page when `truncated`). */ paths: string[] } /** {@link SearchBlock} props: one card, two `kind`-discriminated shapes. */ export type SearchBlockProps = SearchMatchesBlockProps | SearchPathsBlockProps /** * One flattened render row. A matches card produces a `file` header row per * group followed by a `match` row per retained line while the group is * expanded; a paths card produces one `path` row per path. The height cap * counts these rows uniformly, so a file header costs one row exactly as a * match line or a path does. */ type SearchRow = | { type: 'file'; path: string; count: number; index: number; collapsed: boolean } | { type: 'match'; lineNumber: number; line: string; key: string; fileIndex: number } | { type: 'path'; path: string } /** * The plain-text form the copy control writes: the whole structured result * regardless of the height cap or which groups are collapsed, so the clipboard * carries the result rather than what the card happens to be showing. * @param props - the card's props. * @returns the copyable text, or the empty string for an empty result. */ function copyText(props: SearchBlockProps): string { if (props.kind === 'paths') return props.paths.join('\n') return props.files .map(file => [file.path, ...file.matches.map(m => `${m.lineNumber}: ${m.line}`)].join('\n')) .join('\n\n') } /** * Number of retained results the card holds: the matched-line count across all * files for a matches card, the path count for a paths card. This is the count * the banner summary reports against `total` when the result was capped. * @param props - the card's props. * @returns the retained result count. */ function shownCount(props: SearchBlockProps): number { return props.kind === 'paths' ? props.paths.length : props.files.reduce((sum, file) => sum + file.matches.length, 0) } /** * The banner summary. When the search was capped it reads `显示 X / 共 N …` so * the retained count and the pre-cap total sit in one clause (mirroring the read * card's `显示 X / Y 行`); when it was not capped it is a plain count of what the * card holds. The unit — `处匹配 · K 个文件` for grep, `个路径` for glob — trails * the count either way. * @param props - the card's props. * @param shown - the retained result count from {@link shownCount}. * @param truncated - whether the search was capped. * @param total - the pre-cap total the truncation clause reports. * @returns the summary text. */ function summaryText(props: SearchBlockProps, shown: number, truncated: boolean, total: number): string { const count = truncated ? `显示 ${shown} / 共 ${total}` : `${shown}` return props.kind === 'paths' ? `${count} 个路径` : `${count} 处匹配 · ${props.files.length} 个文件` } /** * Flatten a card's shape into its render rows, dropping a collapsed file * group's match rows. * @param props - the card's props. * @param collapsed - the set of collapsed file-group indices (matches only). * @returns the flattened rows in output order. */ function toRows(props: SearchBlockProps, collapsed: ReadonlySet): SearchRow[] { if (props.kind === 'paths') return props.paths.map((path): SearchRow => ({ type: 'path', path })) const rows: SearchRow[] = [] props.files.forEach((file, index) => { const isCollapsed = collapsed.has(index) rows.push({ type: 'file', path: file.path, count: file.matches.length, index, collapsed: isCollapsed }) if (isCollapsed) return for (const match of file.matches) { rows.push({ type: 'match', lineNumber: match.lineNumber, line: match.line, key: `${index}:${match.lineNumber}`, fileIndex: index }) } }) return rows } /** * A stable React key for a flattened render row: the group-scoped match key, a * file-index-scoped header key, or the path itself. Rows of different types * never collide, since each key carries its type prefix or the group index. * @param row - the flattened row. * @returns the key. */ function rowKey(row: SearchRow): string { switch (row.type) { case 'match': return `match:${row.key}` case 'file': return `file:${row.index}` case 'path': return `path:${row.path}` } } /** * Render a completed search as a grouped-matches or flat-path card. * @param props - see {@link SearchBlockProps}. * @returns the search block element. */ export function SearchBlock(props: SearchBlockProps) { const { truncated, total, maxLines = DEFAULT_SEARCH_MAX_LINES, className } = props const [expanded, setExpanded] = useState(false) const [collapsed, setCollapsed] = useState>(() => new Set()) // `props` is a fresh object each render, so memoizing on it never hits; the // flatten is cheap, so it runs inline keyed on the collapse set instead. const rows = toRows(props, collapsed) const shown = shownCount(props) const empty = rows.length === 0 const { copied, onCopy } = useCopyFeedback(copyText(props)) const onToggle = useCallback(() => { setExpanded(value => !value) }, []) const toggleFile = useCallback((index: number) => { setCollapsed((prev) => { const next = new Set(prev) if (next.has(index)) next.delete(index) else next.add(index) return next }) }, []) const { hidden, capped, headLines, tailLines } = headTailCap(rows.length, maxLines, expanded) const head = capped ? rows.slice(0, headLines) : rows const naturalTail = capped ? rows.slice(rows.length - tailLines) : [] // When the tail slice begins inside a file's matches, its own header sits // above the cut and is not shown, so those rows could not be attributed to a // file. Restore the owning header at the top of the tail — unless the head // slice already carries it (a single large file), where it would duplicate. const tailLead = naturalTail[0] const tailHeader = tailLead?.type === 'match' && !head.some(row => row.type === 'file' && row.index === tailLead.fileIndex) ? rows.find((row): row is Extract => row.type === 'file' && row.index === tailLead.fileIndex) : undefined // The restored header is itself a row. Left extra it would push the card to // maxLines + 1 and overstate `hidden` by one, so it consumes a tail slot: drop // the tail's first row (the match whose header this is) for it. Visible rows // hold at maxLines and `hidden` stays exact; the dropped match joins the // hidden middle. const tail = tailHeader === undefined ? naturalTail : naturalTail.slice(1) const renderRow = (row: SearchRow): ReactNode => { if (row.type === 'path') return
{row.path}
if (row.type === 'match') { return (
{row.lineNumber}: {row.line}
) } return ( ) } return (
{summaryText(props, shown, truncated, total)} {!empty && ( )}
{empty ?
无结果
: (
{head.map(row => (
{renderRow(row)}
))} {hidden > 0 && ( )} {tailHeader !== undefined && (
{renderRow(tailHeader)}
)} {tail.map(row => (
{renderRow(row)}
))}
)}
) }