feat(web): render grep/glob search output as a search card

Consume the card:'search' result view (matches grouped by file for grep, a
path list for glob) the search backend PR added. SearchBlock (ui-primitives)
draws both kinds via the kind discriminant with a per-file collapse, a
truncation pill, a height cap matching TerminalBlock, and a copy control;
search-card-model is the single resultView derivation; a keyed SearchRow
registers under grep and glob with the card resident under its summary. The
generic fallback and the details panel are search-aware. Fixture gains grep and
glob turns for the built-boot snapshot.
This commit is contained in:
Chinesezjc
2026-07-30 17:42:59 +08:00
parent 3e22adab28
commit 71adea8ba4
18 changed files with 1415 additions and 31 deletions

View File

@@ -0,0 +1,125 @@
/* Geometry mirrors CodeBlock and TerminalBlock (12px radius, code-block
surface + banner row, markdown code-block font) so a search card reads as one
family with them. The deliberate divergence they share: the result rows keep
`white-space: pre` and scroll horizontally, because folding a long match line
or path destroys the alignment a reader scans by. */
.block {
--dsl-search-radius: 12px;
--dsl-search-line-height: 22px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-search-radius);
}
/* The banner: result summary on the left, the truncation pill and copy control
holding their intrinsic width on the right. */
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 14px;
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-search-radius);
border-top-right-radius: var(--dsl-search-radius);
}
.summary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-secondary);
}
.truncated {
flex: none;
color: var(--dsw-alias-state-business-primary);
}
.copyButton {
flex: none;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.body {
padding: 8px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* No wrapping: a match line or a path keeps its content on one row and scrolls
sideways instead of folding. */
.line {
min-height: var(--dsl-search-line-height);
padding-left: 14px;
white-space: pre;
}
/* The 1-based line number ahead of a grep match line, dimmed so the match text
stays the salient content. */
.lineNumber {
color: var(--dsw-alias-label-tertiary);
}
/* A file group's header: a bold path label plus its match count, the whole row
the collapse control. */
.fileHeader {
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
min-height: var(--dsl-search-line-height);
padding: 0 14px;
border: none;
background-color: transparent;
cursor: pointer;
font: inherit;
text-align: left;
}
.filePath {
min-width: 0;
font-weight: 600;
color: var(--dsw-alias-label-primary);
white-space: pre;
}
.fileCount {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.expand {
display: block;
width: 100%;
padding: 0 14px;
border: none;
background-color: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
text-align: left;
}
.expand:hover {
color: var(--dsw-alias-label-secondary);
}
.empty {
padding: 12px 14px;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,263 @@
// SearchBlock: the search surface for a completed content or path search — a
// banner (result count + a truncation pill when the tool capped the result +
// 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, useMemo, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import { Pill } from './Pill.tsx'
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. A truncation pill is
* shown 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 }
| { 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 truncation pill reports against `total`.
* @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: the structural count of the retained result. The
* truncation pill beside it carries the capped-vs-complete signal, so this
* stays a plain count of what the card holds.
* @param props - the card's props.
* @param shown - the retained result count from {@link shownCount}.
* @returns the summary text.
*/
function summaryText(props: SearchBlockProps, shown: number): string {
return props.kind === 'paths'
? `${shown} 个路径`
: `${shown} 处匹配 · ${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<number>): 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}` })
}
})
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<ReadonlySet<number>>(() => new Set())
const [copied, setCopied] = useState(false)
const rows = useMemo(() => toRows(props, collapsed), [props, collapsed])
const shown = shownCount(props)
const empty = rows.length === 0
const text = copyText(props)
const onCopy = useCallback(() => {
if (copied) return
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, text])
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 = rows.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock (and the TUI transcript's collapsed
// tool card), so a long result's head and tail slices agree across surfaces.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
const renderRow = (row: SearchRow): ReactNode => {
if (row.type === 'path') return <div className={css.line}>{row.path}</div>
if (row.type === 'match') {
return (
<div className={css.line}>
<span className={css.lineNumber}>{row.lineNumber}: </span>
{row.line}
</div>
)
}
return (
<button
type="button"
className={css.fileHeader}
aria-expanded={!row.collapsed}
onClick={() => { toggleFile(row.index) }}
>
<span className={css.filePath}>{row.path}</span>
<span className={css.fileCount}>{row.count}</span>
</button>
)
}
return (
<div className={clsx(css.block, className)} data-search={props.kind}>
<div className={css.header}>
<span className={css.summary}>{summaryText(props, shown)}</span>
{truncated && <Pill className={css.truncated}>{`已截断 · 共 ${total}`}</Pill>}
{!empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
{empty
? <div className={css.empty}>无结果</div>
: (
<div className={css.body}>
{(capped ? rows.slice(0, headLines) : rows).map(row => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起结果' : `展开其余 ${hidden} 行结果`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden} 行`}
</button>
)}
{capped && rows.slice(rows.length - tailLines).map(row => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
</div>
)}
</div>
)
}

View File

@@ -22,6 +22,10 @@ export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps } from './TerminalBlock.tsx'
export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
export type {
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
} from './SearchBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'