Merge remote-tracking branch 'origin/feat/web-search-card' into feat/web-cards-toolrow

This commit is contained in:
Chinesezjc
2026-07-31 16:05:48 +08:00
28 changed files with 2117 additions and 125 deletions

View File

@@ -0,0 +1,120 @@
/* 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 copy control holding its
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);
}
.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,277 @@
// 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<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}`, 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<ReadonlySet<number>>(() => 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<SearchRow, { type: 'file' }> =>
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 <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, truncated, total)}</span>
{!empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
{empty
? <div className={css.empty}></div>
: (
<div className={css.body}>
{head.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>
)}
{tailHeader !== undefined && (
<div key={`tailHeader:${rowKey(tailHeader)}`}>{renderRow(tailHeader)}</div>
)}
{tail.map(row => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
</div>
)}
</div>
)
}

View File

@@ -8,7 +8,8 @@
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { parseAnsiLines, type AnsiLine } from './ansi.ts'
import { writeClipboard } from './clipboard.ts'
import { headTailCap } from './head-tail-cap.ts'
import { useCopyFeedback } from './use-copy-feedback.ts'
import { Pill } from './Pill.tsx'
import { StateDot, type StateDotState } from './StateDot.tsx'
import css from './TerminalBlock.module.css'
@@ -202,18 +203,9 @@ export function TerminalBlock({
return terminated ? parsed.slice(0, -1) : parsed
}, [text])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
// The raw output, never the rendered tree: the prompt line and the status
// pill are chrome the user did not run.
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, text])
// The raw output, never the rendered tree: the prompt line and the status pill
// are chrome the user did not run.
const { copied, onCopy } = useCopyFeedback(text)
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
@@ -232,12 +224,7 @@ export function TerminalBlock({
// the raw text drew an output box of blank rows plus a copy control for
// invisible bytes, and hid the placeholder that belongs there.
const empty = lines.every(line => line.every(span => span.text.trim() === ''))
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as the TUI transcript's collapsed tool card, so a
// command's head and tail slices agree between the two front ends.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
const { hidden, capped, headLines, tailLines } = headTailCap(lines.length, maxLines, expanded)
return (
<div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>

View File

@@ -0,0 +1,33 @@
// Head/tail height-cap arithmetic shared by the block primitives (TerminalBlock,
// SearchBlock) and matching the TUI transcript's collapsed tool card, so a long
// result's head and tail slices agree across every surface. The split is
// `ceil(maxLines / 2)` head rows and the remainder as tail rows; a result within
// the cap shows every row and hides none.
/** The head/tail split metrics for a capped list. */
export interface HeadTailCap {
/** Rows beyond the cap (list length maxLines); ≤ 0 means nothing is hidden. */
hidden: number
/** Whether the list is over the cap and not expanded, so it shows a head/tail slice. */
capped: boolean
/** Head-slice row count: `ceil(maxLines / 2)`. */
headLines: number
/** Tail-slice row count: the remainder after the head. */
tailLines: number
}
/**
* Compute the head/tail cap metrics for a list of `total` rows against `maxLines`,
* given whether the surface is expanded. Pure arithmetic; the caller slices its
* own rows with `headLines`/`tailLines` so a block can layer its own concerns
* (SearchBlock restores a tail file header) on top.
* @param total - the list's row count.
* @param maxLines - the collapsed-height cap in rows.
* @param expanded - whether the surface is expanded (uncaps the list).
* @returns the split metrics.
*/
export function headTailCap(total: number, maxLines: number, expanded: boolean): HeadTailCap {
const hidden = total - maxLines
const headLines = Math.ceil(maxLines / 2)
return { hidden, capped: hidden > 0 && !expanded, headLines, tailLines: maxLines - headLines }
}

View File

@@ -28,6 +28,10 @@ export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
export type {
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
} from './SearchBlock.tsx'
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'

View File

@@ -0,0 +1,37 @@
// The copy-to-clipboard-with-feedback hook shared by the block primitives
// (TerminalBlock, SearchBlock): write the given text, and on success flip a
// transient `copied` flag that the caller renders as a "复制成功" label for one
// second. A refused write leaves the flag untouched, so the control never claims
// a copy the host declined.
import { useCallback, useState } from 'react'
import { writeClipboard } from './clipboard.ts'
/** How long the `copied` flag stays true after a successful write, in ms. */
const COPIED_FEEDBACK_MS = 1000
/** The copy-feedback hook's return: the transient flag and the copy handler. */
export interface CopyFeedback {
/** True for {@link COPIED_FEEDBACK_MS} after a successful write; render the success label off it. */
copied: boolean
/** Copy the hook's text; no-op while `copied` is still true, silent on a refused write. */
onCopy: () => void
}
/**
* Copy `text` to the clipboard with one-second success feedback.
* @param text - the text to write on copy.
* @returns the `copied` flag and the `onCopy` handler.
*/
export function useCopyFeedback(text: string): CopyFeedback {
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, COPIED_FEEDBACK_MS)
})
}, [copied, text])
return { copied, onCopy }
}